diff --git a/.github/actions/download-pinned-orca-release/action.yml b/.github/actions/download-pinned-orca-release/action.yml new file mode 100644 index 00000000000..b6dbe86aa6c --- /dev/null +++ b/.github/actions/download-pinned-orca-release/action.yml @@ -0,0 +1,42 @@ +name: Download pinned Orca release +description: Downloads a released Linux .deb, checks its SHA-512, extracts it and points ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE at its executable. + +inputs: + version: + description: Release version without the leading v (e.g. 1.4.224). + required: true + sha512: + description: Base64 SHA-512 of orca-ide__amd64.deb. + required: true + github-token: + description: Token for gh release download. + required: true + +runs: + using: composite + steps: + - name: Download and verify + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + ORCA_PINNED_VERSION: ${{ inputs.version }} + ORCA_PINNED_SHA512: ${{ inputs.sha512 }} + run: | + gh release download "v$ORCA_PINNED_VERSION" --repo stablyai/orca --pattern "orca-ide_${ORCA_PINNED_VERSION}_amd64.deb" --dir "$RUNNER_TEMP/old-orca" + python3 - <<'PYVERIFY' + import base64,hashlib,os,pathlib,subprocess + version=os.environ['ORCA_PINNED_VERSION'] + root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca' + package=root/f'orca-ide_{version}_amd64.deb' + 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()==os.environ['ORCA_PINNED_SHA512'] + extracted=root/'extracted' + subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True) + executable=extracted/'opt'/'Orca'/'orca-ide' + assert executable.is_file() and os.access(executable,os.X_OK) + with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n') + print('Verified old package:',executable) + PYVERIFY diff --git a/.github/workflows/cloud-deploy-relay-asia-topology.yml b/.github/workflows/cloud-deploy-relay-asia-topology.yml index 3e8929376be..7787da8efb4 100644 --- a/.github/workflows/cloud-deploy-relay-asia-topology.yml +++ b/.github/workflows/cloud-deploy-relay-asia-topology.yml @@ -79,6 +79,7 @@ jobs: production:production-gce-c31) ;; production:production-gce-c32,production-gce-c33) target_region=us-central1 ;; production:production-gce-c34) ;; + production:production-gce-c35) ;; *) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;; esac echo "TARGET_REGION=${target_region}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 333c1baebe3..8026d91da94 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -197,7 +197,7 @@ jobs: EXPECTED_REGION=us-central1 EXPECTED_DATABASE_POOL_MAX= ;; - c27|c28|c29|c30|c31|c34) + c27|c28|c29|c30|c31|c34|c35) EXPECTED_HARD_CAP=3000 EXPECTED_REGION=asia-east2 EXPECTED_DATABASE_POOL_MAX=16 diff --git a/.github/workflows/cloud-operate-relay-asia-admission.yml b/.github/workflows/cloud-operate-relay-asia-admission.yml index a5bb87d1d98..43d6533b0f8 100644 --- a/.github/workflows/cloud-operate-relay-asia-admission.yml +++ b/.github/workflows/cloud-operate-relay-asia-admission.yml @@ -39,7 +39,7 @@ on: required: false type: string evidence-run-id: - description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30-C34 take none and each proves itself by its own canary + description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30-C35 take none and each proves itself by its own canary required: false type: string evidence-run-attempt: @@ -156,7 +156,7 @@ jobs: evidence_kind=c27 artifact_name="relay-asia-c27-canary-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" ;; - production-gce-c30|production-gce-c31|production-gce-c34) + production-gce-c30|production-gce-c31|production-gce-c34|production-gce-c35) # No earlier proof binds a later cell's generation; its own canary below rolls it back on failure. canary_cell="${TARGET_CELL_IDS}" ;; @@ -330,7 +330,7 @@ jobs: verify_cells=production-gce-c27,production-gce-c28,production-gce-c29 expected_states='{"production-gce-c27":"general","production-gce-c28":"migration-only","production-gce-c29":"migration-only"}' ;; - production-gce-c30|production-gce-c31|production-gce-c32|production-gce-c33|production-gce-c34) + production-gce-c30|production-gce-c31|production-gce-c32|production-gce-c33|production-gce-c34|production-gce-c35) verify_cells="${CANARY_CELL}" expected_states="{\"${CANARY_CELL}\":\"general\"}" ;; diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6b07312e158..8095e8e61e8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -70,13 +70,7 @@ jobs: - name: Build E2E outputs env: VITE_EXPOSE_STORE: 'true' - run: | - status=0 - pnpm run build:relay & - relay_pid=$! - pnpm run build:electron-vite:parallel --mode e2e || status=1 - wait "$relay_pid" || status=1 - exit "$status" + run: pnpm run build:electron-vite:parallel --mode e2e # The CLI emits into out/main too; start only after Electron finishes clearing that tree. - name: Build shared E2E CLI @@ -99,9 +93,6 @@ jobs: with: name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ - # Why: build-relay.mjs writes each relay's marker as `out/relay//.version`, - # and upload-artifact drops dotfiles by default — consumers then fail SSH specs with - # "local relay build is missing its version marker". include-hidden-files: true retention-days: 1 if-no-files-found: error @@ -224,7 +215,6 @@ jobs: SKIP_BUILD: '1' ORCA_E2E_FORWARD_APP_LOGS: '1' ORCA_E2E_WEB_CLIENT: '1' - ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay run: | mkdir -p ci-shards pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --list --reporter=json > ci-shards/discovery.json @@ -236,7 +226,7 @@ jobs: - name: Run E2E tests (${{ matrix.shard_name }}) 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 + 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 pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json - name: Summarize E2E failures if: always() @@ -313,25 +303,8 @@ jobs: . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and . != "tests/e2e/orcad-serve-mode-switch.spec.ts" and . != "tests/e2e/workspace-layout-oracle-headless.spec.ts" and - . != "tests/e2e/ssh-orcad-auto-convert.spec.ts" and . != "tests/e2e/windows-missing-appdata-startup.spec.ts" and - . != "tests/e2e/ssh-orcad-idle-exit.spec.ts" and - . != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" and - . != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" and - . != "tests/e2e/ssh-orcad-browser-routing.spec.ts" and - . != "tests/e2e/ssh-orcad-editor-ownership.spec.ts" and - . != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" and - . != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and - . != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and - . != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and - . != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts" and - . != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts" and - . != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" and - . != "tests/e2e/ssh-orcad-browser-drop-owner.spec.ts" and - . != "tests/e2e/ssh-orcad-terminal-root-owner.spec.ts" and - . != "tests/e2e/ssh-orcad-terminal-journeys.spec.ts" and - . != "tests/e2e/ssh-orcad-workspace-tools.spec.ts" and - . != "tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts" + (startswith("tests/e2e/ssh-orcad-") | not) )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -339,7 +312,7 @@ jobs: echo "Changed specs are all owned by dedicated lanes." exit 0 fi - E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay") + E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1) E2E_PROJECT_ARGS=() if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then E2E_PROJECT_ARGS+=(--project=electron-headful) @@ -434,65 +407,18 @@ jobs: retention-days: 7 if-no-files-found: ignore - # #24979 on a real host: a relay-era Docker host converts to managed orcad on connect, and a managed - # orcad idles out and restarts on the next connect. Needs the + # Every managed-orcad SSH spec on a Docker host, including #24979's relay-era conversion. Needs the # orcad template for the fixture's target (Debian, linux-x64-glibc), which only this job builds. - orcad-auto-convert-docker: - name: ssh host auto-converts to managed orcad (Docker) + orcad-ssh-docker: + name: ssh on managed orcad (Docker ${{ matrix.shard }}) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-root-owner.spec.ts') - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - ORCA_BACKGROUND_LAUNCH: '1' - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - persist-credentials: false - - name: Install headless tools - run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - uses: actions/download-artifact@v8 - with: - name: e2e-build-out${{ inputs.artifact_suffix }} - path: out/ - # Built to a path the app does not look at by default, so the relay phase has no template. - - name: Build the linux-x64-glibc orcad template - run: | - pnpm build:orcad-prebuilds - pnpm build:orcad-prebuilds --require-slots linux-x64-glibc - pnpm build:orcad - node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc - mv out/orcad-template "$RUNNER_TEMP/orcad-convert-template" - - name: Convert a relay-era Docker host - env: - SKIP_BUILD: '1' - ORCA_E2E_SSH_DOCKER: '1' - ORCA_E2E_ORCAD_CONVERT_HOST: docker - ORCA_E2E_FORWARD_APP_LOGS: '1' - ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay - run: | - export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-selected-host.spec.ts tests/e2e/ssh-orcad-browser-drop-owner.spec.ts tests/e2e/ssh-orcad-terminal-root-owner.spec.ts --project=electron-headless --workers=1 - - uses: actions/upload-artifact@v7 - if: failure() - with: - name: orcad-auto-convert-docker-traces${{ inputs.artifact_suffix }} - path: test-results/ - retention-days: 7 - if-no-files-found: ignore - - # SSH user journeys (terminal reconnect/restore/faults, Quick Open, ports, links, agent status) on a - # Docker host served by managed orcad. Its own runner so the convert lane keeps its time budget. - orcad-journeys-docker: - name: ssh journeys on managed orcad (Docker) - needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-workspace-tools.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-') runs-on: ubuntu-latest timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + shard: ['1/2', '2/2'] env: ORCA_BACKGROUND_LAUNCH: '1' steps: @@ -509,6 +435,7 @@ jobs: with: name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ + # Built to a path the app does not look at by default, so the relay-era phase has no template. - name: Build the linux-x64-glibc orcad template run: | pnpm build:orcad-prebuilds @@ -516,19 +443,21 @@ jobs: pnpm build:orcad node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc mv out/orcad-template "$RUNNER_TEMP/orcad-convert-template" - - name: Run SSH journeys on a managed Docker host + # The Windows CLI matrix collects here too and skips: it needs a Windows host descriptor. + - name: Run managed SSH specs on a Docker host env: SKIP_BUILD: '1' ORCA_E2E_SSH_DOCKER: '1' ORCA_E2E_ORCAD_CONVERT_HOST: docker ORCA_E2E_FORWARD_APP_LOGS: '1' + SHARD: ${{ matrix.shard }} run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-terminal-journeys.spec.ts tests/e2e/ssh-orcad-workspace-tools.spec.ts tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts --project=electron-headless --workers=1 + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad- --project=electron-headless --workers=1 --shard="$SHARD" - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-journeys-docker-traces${{ inputs.artifact_suffix }} + name: orcad-ssh-docker-traces-${{ strategy.job-index }}${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml index 7d36e7fd1b1..1ca301ae4e8 100644 --- a/.github/workflows/packaged-browser-e2e.yml +++ b/.github/workflows/packaged-browser-e2e.yml @@ -1,4 +1,7 @@ name: Packaged browser compatibility +# Both jobs pair the current build with a pinned released one. `layout-compat` holds all the layout +# rework baselines (P3), compared with the shapes recorded on main in +# tests/e2e/layout-compat-baselines.json; its artifact carries the observed shapes for re-recording. on: workflow_dispatch: inputs: @@ -30,27 +33,11 @@ jobs: with: native-runtime: electron - name: Download pinned old release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release download v1.4.188 --repo stablyai/orca --pattern orca-ide_1.4.188_amd64.deb --dir "$RUNNER_TEMP/old-orca" - python3 - <<'PYVERIFY' - import base64,hashlib,os,pathlib,subprocess - root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca' - package=root/'orca-ide_1.4.188_amd64.deb' - expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g==' - 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' - assert executable.is_file() and os.access(executable,os.X_OK) - with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n') - print('Verified old package:',executable) - PYVERIFY + uses: ./.github/actions/download-pinned-orca-release + with: + version: 1.4.188 + sha512: uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g== + github-token: ${{ github.token }} - name: Build current Electron app env: VITE_EXPOSE_STORE: 'true' @@ -76,3 +63,56 @@ jobs: name: packaged-mixed-version-audit path: test-results/ retention-days: 3 + layout-compat: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - name: Install headless tools + run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 ripgrep xvfb zsh openbox x11-utils + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - name: Download pinned old release + uses: ./.github/actions/download-pinned-orca-release + with: + version: 1.4.224 + sha512: +/VyaYSgqSAuSSWe1ySrCKRjHHJ6REXqmz58g69bxvoUdV41iH9vlobM18xdxWTkgJF2kbu/tSKFvH0jMnZ0+Q== + github-token: ${{ github.token }} + - name: Build current Electron app + env: + VITE_EXPOSE_STORE: 'true' + run: | + pnpm run build:relay + pnpm exec electron-vite build --mode e2e + pnpm run build:cli + pnpm run build:web-from-renderer + # The two specs without a released build ride along, so one dispatch reruns every baseline. + - name: Run the layout baselines + env: + ORCA_LAYOUT_COMPAT_REPORT_DIR: test-results/layout-compat + run: >- + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 + pnpm exec playwright test --config tests/playwright.config.ts + tests/e2e/layout-compat-old-desktop-on-new-server.spec.ts + tests/e2e/layout-compat-new-desktop-on-old-server.spec.ts + tests/e2e/layout-compat-old-phone-on-new-server.spec.ts + tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts + --project=electron-headless --workers=1 --retries=0 --reporter=list + - name: Require every layout scenario to have run + if: always() + # A missing env skips the specs and passes; each run writes its observation first. + run: | + for scenario in old-desktop-on-new-server old-cli-terminal-create new-desktop-on-old-server old-phone-on-new-server serve-restart-keeps-@@-terminals; do + test -f "test-results/layout-compat/$scenario.linux.json" || { echo "::error::$scenario did not run"; exit 1; } + done + - uses: actions/upload-artifact@v7 + if: always() + with: + name: packaged-layout-compat + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba11ca658fd..5f15611f0bc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -290,7 +290,7 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - pnpm run typecheck:ci + pnpm run typecheck:ci --bun-concurrency=2 --bun-threads=2 - name: Check Zustand selector fan-out budget if: needs.code_paths.outputs.static_analysis == 'true' @@ -348,7 +348,7 @@ jobs: - name: Check compatibility-folder imports if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:compat-boundary-imports + run: bun config/scripts/check-compat-boundary-imports.mjs - name: Check Node runtime pin if: needs.code_paths.outputs.static_analysis == 'true' @@ -356,6 +356,7 @@ jobs: # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. + # Use preflight's pinned Bun even when the optional local pin differs. - name: Verify localization extraction if: '!cancelled()' id: localization-extraction @@ -372,7 +373,7 @@ jobs: 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 + bun config/scripts/verify-localization-extraction.mjs fi # Why both: the ratchet proves nothing reachable from the runtime imports electron, @@ -1236,6 +1237,7 @@ jobs: run: >- pnpm exec vitest run --config config/vitest.config.ts config/scripts/nsis-process-check.test.mjs + config/scripts/idle-cpu-process-sampling.test.mjs - name: Test Windows-specific boundaries run: >- diff --git a/.github/workflows/preflight-benchmark.yml b/.github/workflows/preflight-benchmark.yml index f141b8e6025..20432b878c6 100644 --- a/.github/workflows/preflight-benchmark.yml +++ b/.github/workflows/preflight-benchmark.yml @@ -58,3 +58,48 @@ jobs: name: preflight-static-analysis path: .build/preflight-benchmark/ retention-days: 30 + compatibility-imports: + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 2 + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: none + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.3 + - name: Compare compatibility-import scan and runtimes + run: node config/scripts/benchmark-preflight-compat-boundary.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: preflight-compat-boundary + path: .build/preflight-benchmark/ + retention-days: 30 + localization-extraction: + runs-on: ubuntu-24.04-arm + timeout-minutes: 12 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: none + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.3 + - name: Compare localization extraction runtimes and complete catalogs + run: node config/scripts/benchmark-preflight-localization-extraction.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: preflight-localization-extraction + path: .build/preflight-benchmark/ + retention-days: 30 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index e81fdcf04a5..275849c062a 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -732,19 +732,13 @@ jobs: # message and tag name explicitly (avoids npm's `v1.2.3` prefix # assumptions and any lifecycle scripts that would run on bump). npm version "$VERSION" --no-git-tag-version --allow-same-version - # Why: the cut is the only point where committed skill bytes become a - # released revision. Without this row the ledger never advances, so the - # next skill change rebuilds the revision this tag ships over different - # bytes and every install of it stops matching a known snapshot. - # --release is provenance-only: it fails if the content-addressed - # artifacts do not already match this ref and writes just the mapping - # row, so the version commit stays skill-independent. Node built-ins - # only, so this needs no install. - if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then - echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2 + # Why: freshness compares installs against the bundled manifest, so a tag must + # never ship skill bytes it doesn't describe. Node built-ins only; no install. + if ! node config/scripts/generate-skill-bundle-manifest.mjs; then + echo "::error::Refusing to tag v$VERSION: the committed skill manifest does not match skills/. Land a regeneration on main, then re-run the cut." >&2 exit 1 fi - git add package.json resources/skills/release-mapping.json + git add package.json commit_message="release: v$VERSION" if [[ "$EVENT_NAME" == "schedule" ]]; then commit_message="$commit_message [rc-slot:$SLOT]" @@ -761,16 +755,16 @@ jobs: # var, a composite action, or concatenation, and `git commit` has forms # (-a, -i, --only, a pathspec) that commit the working tree rather than # the index. Assert what the commit actually carries, so the tag can - # only ever ship the version bump and the provenance row, no matter - # which step staged what or how the commit was spelled. + # only ever ship the version bump, no matter which step staged what or + # how the commit was spelled. # -F because the allowlist is literal: unanchored, `.` would match any # character and quietly admit a path like `packageXjson`. # -m --first-parent: plain diff-tree prints NOTHING for a merge commit, # which would make this guard pass silently rather than fail closed. committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD | - grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)" + grep -vxF -e 'package.json' || true)" if [[ -n "$committed" ]]; then - echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2 + echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json may ship in a version commit." >&2 exit 1 fi git tag -a "v$VERSION" -m "v$VERSION" diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml index 813bfffdffb..490b45483b5 100644 --- a/.github/workflows/skill-update-roundtrip.yml +++ b/.github/workflows/skill-update-roundtrip.yml @@ -11,6 +11,7 @@ on: - 'resources/skills/**' - 'config/scripts/verify-skill-update-roundtrip.mjs' - 'src/main/skills/skill-freshness-eligibility.ts' + - 'src/shared/agent-feature-install-commands.ts' - 'src/shared/skill-freshness.ts' - '.github/workflows/skill-update-roundtrip.yml' # Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge @@ -33,7 +34,8 @@ jobs: os: [macos-latest, ubuntu-latest, windows-latest] shape: [symlink, copy] autocrlf: ['false', 'true'] - skills-cli: ['1.5.17'] + # Why: `pinned` runs the skills CLI version Orca ships (SKILLS_CLI_VERSION). + skills-cli: [pinned] include: - os: ubuntu-latest shape: symlink @@ -44,20 +46,18 @@ jobs: steps: - uses: actions/checkout@v6 with: - # Historical skill snapshots need tags, but only their blobs are read. - fetch-depth: 0 - filter: blob:none persist-credentials: false - uses: actions/setup-node@v6 with: node-version-file: package.json - - name: Verify targeted update convergence and copy behavior + - name: Verify the pinned add replaces stale copies env: GITHUB_TOKEN: ${{ github.token }} SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }} + # A commit, the same kind of ref Orca pins official skills to. + SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} run: >- node config/scripts/verify-skill-update-roundtrip.mjs - --cli=${{ matrix.skills-cli }} + ${{ matrix.skills-cli == 'latest' && '--cli=latest' || '' }} --autocrlf=${{ matrix.autocrlf }} --shape=${{ matrix.shape }} diff --git a/.oxlintrc.json b/.oxlintrc.json index 78dee1acd90..200213e83e3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -21,6 +21,10 @@ { "name": "renderer-scrollbar-style", "specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs" + }, + { + "name": "native-chat-design-tokens", + "specifier": "./config/oxlint-plugins/native-chat-design-tokens.mjs" } ], "categories": { @@ -181,6 +185,14 @@ ] } }, + { + "files": ["src/renderer/src/components/native-chat/**/*.{ts,tsx}"], + "rules": { + "native-chat-design-tokens/no-arbitrary-font-size": "error", + "native-chat-design-tokens/no-softened-foreground": "error", + "native-chat-design-tokens/no-weakened-focus-ring": "error" + } + }, { "files": ["src/shared/**/*.ts"], "rules": { @@ -198,7 +210,10 @@ "rules": { "react/globals": "off", "quadratic-buffer-concat/no-loop-carried-concat": "off", - "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off" + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off", + "native-chat-design-tokens/no-arbitrary-font-size": "off", + "native-chat-design-tokens/no-softened-foreground": "off", + "native-chat-design-tokens/no-weakened-focus-ring": "off" } }, { diff --git a/AGENTS.md b/AGENTS.md index d30faf95d34..2451994d21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Design System -All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. +All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. In `components/native-chat/`, the "Chat type scale" and "Chat text colour, hover and focus" sections are binding, and `pnpm lint` fails on arbitrary font sizes, softened `text-foreground`, and weakened focus rings there. ## Electron UI Validation diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index 0e01877d061..2a3189944bc 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -21,7 +21,8 @@ const SHAPES = { domain: 'relay.onorca.dev', allCells: [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34' + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34', + 'production-gce-c35' ], // The launch set was registered together; each later cell registers alone beside it. registrationWaves: [ @@ -30,7 +31,8 @@ const SHAPES = { ['production-gce-c31'], ['production-gce-c32'], ['production-gce-c33'], - ['production-gce-c34'] + ['production-gce-c34'], + ['production-gce-c35'] ], promotionWaves: [ ['production-gce-c27'], @@ -39,7 +41,8 @@ const SHAPES = { ['production-gce-c31'], ['production-gce-c32'], ['production-gce-c33'], - ['production-gce-c34'] + ['production-gce-c34'], + ['production-gce-c35'] ] } } diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index 55eaab1aa16..547c190f80c 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -554,13 +554,15 @@ test('accepts only reviewed Asia admission waves', () => { ['rollback', 'production-gce-c30'], ['rollback', 'production-gce-c31'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'], - ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34,production-gce-c35'], ...['production-gce-c32', 'production-gce-c33'].flatMap((cellId) => [ 'inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback' ].map((mode) => [mode, cellId])), ...['inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback'] .map((mode) => [mode, 'production-gce-c34']), - ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'] + ...['inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback'] + .map((mode) => [mode, 'production-gce-c35']), + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34,production-gce-c35'] ] for (const [mode, cellIds] of accepted) { assert.deepEqual( @@ -570,6 +572,11 @@ test('accepts only reviewed Asia admission waves', () => { ) } const rejected = [ + ['promote', 'production-gce-c34,production-gce-c35'], + ['promote', 'production-gce-c31,production-gce-c35'], + ['register', 'production-gce-c34,production-gce-c35'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'], ['initialize', 'production-gce-c30'], ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'], ['register', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'], @@ -678,6 +685,29 @@ test('registers the C34 spare alone as migration-only in asia-east2', async () = assert.equal(subject.selector().membership.general.includes('production-gce-c34'), false) }) +test('registers the C35 spare alone as migration-only in asia-east2', async () => { + const general = [...launchCells, 'production-gce-c30', 'production-gce-c31'] + const subject = harness({ + generation: 17, + membership: { + existingOnly: [], + migrationOnly: [], + general: [...general, 'production-gce-c32', 'production-gce-c33', 'production-gce-c34'] + } + }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'register', cells: ['production-gce-c35'], + expectedGeneration: 17, imageDigest: digest, attemptId: 'asia_register_c35', token: 'not-logged' + }, subject) + const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells')) + assert.deepEqual(request.body.cells, [{ + cellId: 'production-gce-c35', cellUrl: 'https://c35.relay.onorca.dev', region: 'asia-east2', + capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60 + }]) + assert.deepEqual(result.states, { 'production-gce-c35': 'migration-only' }) + assert.equal(subject.selector().membership.general.includes('production-gce-c35'), false) +}) + test('promotes the registered C34 spare to general and leaves every other cell alone', async () => { const general = [ ...launchCells, 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' @@ -697,6 +727,26 @@ test('promotes the registered C34 spare to general and leaves every other cell a assert.deepEqual(subject.selector().membership.general, [...general, 'production-gce-c34'].sort()) }) +test('promotes the registered C35 spare to general and leaves every other cell alone', async () => { + const general = [ + ...launchCells, 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', + 'production-gce-c34' + ] + const membership = { + existingOnly: [], + migrationOnly: ['production-gce-c17', 'production-gce-c35'], + general: [...general] + } + const subject = harness({ generation: 345, membership }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'promote', cells: ['production-gce-c35'], + expectedGeneration: 345, imageDigest: digest, attemptId: 'asia_promote_c35', token: 'not-logged' + }, subject) + assert.deepEqual(result.states, { 'production-gce-c35': 'general' }) + assert.deepEqual(subject.selector().membership.migrationOnly, ['production-gce-c17']) + assert.deepEqual(subject.selector().membership.general, [...general, 'production-gce-c35'].sort()) +}) + const usRegions = { 'production-gce-c32': 'us-central1', 'production-gce-c33': 'us-central1' } test('registers C32 and C33 one at a time in us-central1 at the Asia shape', async () => { @@ -837,7 +887,7 @@ test('runs each later cell\'s own canary with load aimed at that cell\'s region' for (const [cellId, region] of [ ['production-gce-c30', 'asia-east2'], ['production-gce-c31', 'asia-east2'], ['production-gce-c32', 'us-central1'], ['production-gce-c33', 'us-central1'], - ['production-gce-c34', 'asia-east2'] + ['production-gce-c34', 'asia-east2'], ['production-gce-c35', 'asia-east2'] ]) { const outputs = promotionOutputs(cellId) assert.equal(outputs?.canary, 'true', cellId) @@ -847,8 +897,9 @@ test('runs each later cell\'s own canary with load aimed at that cell\'s region' } assert.equal(promotionOutputs('production-gce-c32,production-gce-c33'), null) assert.equal(promotionOutputs('production-gce-c33,production-gce-c34'), null) + assert.equal(promotionOutputs('production-gce-c34,production-gce-c35'), null) const canaryStart = workflowBlock('case "${CANARY_CELL}" in', '\n esac') - for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) { + for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34', 'production-gce-c35']) { const result = spawnSync('bash', ['-euo', 'pipefail', '-c', `${canaryStart}\necho "\${verify_cells} \${expected_states}"`], { env: { ...process.env, CANARY_CELL: cellId }, encoding: 'utf8' diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs index 6bbb65c50ce..aca39fd7cd8 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs @@ -21,7 +21,8 @@ const SHAPES = { 'production-gce-c31': 'asia-east2-b', 'production-gce-c32': 'us-central1-a', 'production-gce-c33': 'us-central1-b', - 'production-gce-c34': 'asia-east2-c' + 'production-gce-c34': 'asia-east2-c', + 'production-gce-c35': 'asia-east2-a' }, // The launch set, then each later additive cell; a plan targets one wave, never live cells. waves: [ @@ -30,7 +31,8 @@ const SHAPES = { ['production-gce-c31'], // Declared together, so they plan together: a lone C32 plan would hit C33's missing template. ['production-gce-c32', 'production-gce-c33'], - ['production-gce-c34'] + ['production-gce-c34'], + ['production-gce-c35'] ] } } diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs index 906c8bb1ace..70fe852feeb 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs @@ -15,7 +15,8 @@ const productionCells = () => Object.fromEntries([ [31, 'asia-east2-b'], [32, 'us-central1-a'], [33, 'us-central1-b'], - [34, 'asia-east2-c'] + [34, 'asia-east2-c'], + [35, 'asia-east2-a'] ].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, { hostname: `c${ordinal}`, region: zone.slice(0, -2), zone, machine_type: 'e2-standard-4', boot_disk_gb: 30, @@ -66,6 +67,14 @@ test('accepts the additive C34 spare wave in asia-east2-c at the Asia pool', () assert.equal(result.relay_gce_cells['production-gce-c34'].database_pool_max, 16) }) +test('accepts the additive C35 spare wave in asia-east2-a at the Asia pool', () => { + const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(), + existingAdditionalRegions: additionalRegions, environment: 'production', + cellIds: 'production-gce-c35', image }) + assert.equal(result.relay_gce_cells['production-gce-c35'].zone, 'asia-east2-a') + assert.equal(result.relay_gce_cells['production-gce-c35'].database_pool_max, 16) +}) + test('accepts the additive US C32+C33 wave at the default pool only', () => { const cellIds = 'production-gce-c32,production-gce-c33' for (const [cellId, zone] of [ @@ -113,7 +122,8 @@ test('matches every committed production Asia cell entry', () => { 'production-gce-c30', 'production-gce-c31', 'production-gce-c32,production-gce-c33', - 'production-gce-c34' + 'production-gce-c34', + 'production-gce-c35' ]) { const committedImage = committed[wave.split(',')[0]].image assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({ @@ -163,7 +173,8 @@ test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drif 'production-gce-c32', 'production-gce-c33', 'production-gce-c33,production-gce-c34', - 'production-gce-c35' + 'production-gce-c34,production-gce-c35', + 'production-gce-c36' ]) { assert.throws(() => prepareRelayAsiaTopologyInput({ existingCells: productionCells(), existingAdditionalRegions: additionalRegions, 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 fb2ad53895b..80866fef433 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -97,7 +97,7 @@ describe('production Relay capacity cell admission', () => { it('admits the same-cap Asia and migration-only cells only under the same-cap allowlist', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c34', + 'production-gce-c31', 'production-gce-c34', 'production-gce-c35', // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18', // US cells at the Asia shape: the 1,000-cap capacity rollout never touches them. @@ -118,7 +118,7 @@ describe('production Relay capacity cell admission', () => { paceWindowMs: 0 }) } - for (const cellId of ['production-gce-c12', 'production-gce-c35']) { + for (const cellId of ['production-gce-c12', 'production-gce-c36']) { const hostname = cellId.slice('production-gce-'.length) assert.throws(() => parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 79dddc7e921..d4338494b5f 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -2,9 +2,9 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' // Every cell that carries the rehome identity: the eighteen US cells and the -// six asia-east2 cells that drain mis-homed hosts back the other way. +// seven asia-east2 cells that drain mis-homed hosts back the other way. const PRODUCTION_CELL = - /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34)$/ + /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34|35)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index c7d8b5e5d4b..f70087a4aec 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -116,7 +116,8 @@ test('fails when both trust-probe attempts return a transient 503', async () => test('approves the asia-east2 and US 3,000 rehome sources and still rejects unlisted cells', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34' + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34', + 'production-gce-c35' ]) { const parsed = parseRehomeTrustProbeArguments( argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), @@ -124,7 +125,7 @@ test('approves the asia-east2 and US 3,000 rehome sources and still rejects unli ) assert.equal(parsed.cellId, cellId) } - for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c35']) { + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c36']) { assert.throws( () => parseRehomeTrustProbeArguments( @@ -183,7 +184,7 @@ test('approves exactly the committed production rehome source cells', () => { const sources = new Set( [...tfvars.slice(start, tfvars.indexOf(']', start)).matchAll(/"([^"]+)"/g)].map(([, cell]) => cell) ) - assert.ok(sources.has('production-gce-c34')) + assert.ok(sources.has('production-gce-c35')) for (let ordinal = 1; ordinal <= 40; ordinal++) { const cellId = `production-gce-c${ordinal}` const approved = (() => { diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs index 09c1942eb86..850a9c98f11 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs @@ -27,6 +27,9 @@ const PRODUCTION_CANARIES = { }, 'production-gce-c34': { kind: 'production-c34-canary', origin: 'https://c34.relay.onorca.dev', region: ASIA_REGION + }, + 'production-gce-c35': { + kind: 'production-c35-canary', origin: 'https://c35.relay.onorca.dev', region: ASIA_REGION } } const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/ diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs index b684bfed789..77e88a81452 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs @@ -415,6 +415,19 @@ test('builds a C34 canary that only C34 placement satisfies', () => { assert.throws(() => buildProductionCanaryEvidence(onC31), /C34 canary load was not placed only on C34/) }) +test('builds a C35 canary that only C35 placement satisfies', () => { + const c35 = 'production-gce-c35' + const evidence = buildProductionCanaryEvidence(canaryInput({}, c35)) + assert.equal(evidence.kind, 'production-c35-canary') + assert.equal(verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c35-canary', { cellIds: [c35], selectorGeneration: 9 }) + ), evidence) + const onC34 = canaryInput({}, c35) + onC34.loadReport.assignedCellOrigins = ['https://c34.relay.onorca.dev'] + assert.throws(() => buildProductionCanaryEvidence(onC34), /C35 canary load was not placed only on C35/) +}) + test('records but does not gate organic US-targeted fallbacks during a canary', () => { // Mirrors C31's 2026-10-01 canary: US-targeted fallbacks, none targeting Asia. const input = canaryInput({}, 'production-gce-c31') diff --git a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs index 8a6eba98b69..3af25a21852 100644 --- a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs @@ -51,7 +51,8 @@ test('accepts only the reviewed Asia topology waves', () => { 'production:production-gce-c30', 'production:production-gce-c31', 'production:production-gce-c32,production-gce-c33', - 'production:production-gce-c34' + 'production:production-gce-c34', + 'production:production-gce-c35' ] ) }) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index bc68bf49c69..0ea73086add 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -7,12 +7,12 @@ import { } from './relay-cloud-sql-connection-budget.mjs' test('production shared consumers keep allowance and reserve below the ceiling', () => { - // cells: 22 pools at 10 (220, C32/C33 included) + the six asia-east2 pools at 16 (96). + // cells: 22 pools at 10 (220, C32/C33 included) + the seven asia-east2 pools at 16 (112). const report = readRelayCloudSqlConnectionBudget() - assert.deepEqual(report.consumers, { cells: 316, directors: 15, auth: 20, api: 50 }) - assert.deepEqual(report.asia, { cells: 6, poolMax: 16 }) - assert.equal(report.configuredMaximum, 401) + assert.deepEqual(report.consumers, { cells: 332, directors: 15, auth: 20, api: 50 }) + assert.deepEqual(report.asia, { cells: 7, poolMax: 16 }) + assert.equal(report.configuredMaximum, 417) assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30) assert.equal(report.rolloutOverlap.apiCandidate, 65) assert.equal(report.rolloutOverlap.authCandidate, 35) @@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling', assert.equal(report.maintenanceAdminAllowance, 5) assert.equal(report.explicitReserve, 10) assert.equal(report.usableCeiling, 490) - assert.equal(report.operatingMaximum, 471) - assert.equal(report.remainingWithinUsableCeiling, 19) - assert.equal(report.budgetedTotal, 481) - assert.equal(report.unallocated, 19) + assert.equal(report.operatingMaximum, 487) + assert.equal(report.remainingWithinUsableCeiling, 3) + assert.equal(report.budgetedTotal, 497) + assert.equal(report.unallocated, 3) assert.equal(report.withinBudget, true) }) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index cf666c7238d..3389910ccfc 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -13,6 +13,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34', + 'production-gce-c35', ...SAME_CAP_MIGRATION_ONLY_CELLS ] diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index a9210812a3a..d679ae3c7e4 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -85,11 +85,11 @@ test('requires one canary or a bounded reviewed batch', () => { } assert.throws(() => validateSameCapWave({ mode: 'canary-apply', - cellIds: 'production-gce-c35', + cellIds: 'production-gce-c36', targetDigest, rollbackDigest, drainPaceWindowMs: '300000', - confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c35` + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c36` }), /cells/) }) @@ -136,11 +136,11 @@ test('the wave workflow chains exactly ten serial cell jobs', () => { assert.doesNotMatch(dispatch, /\n cell_11:/) }) -test('lists only C17 and C18 as migration-only now that C34 is promoted, and C30-C34 as general', () => { +test('lists only C17 and C18 as migration-only now that C35 is promoted, and C30-C35 as general', () => { assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, ['production-gce-c17', 'production-gce-c18']) for (const cellId of [ 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', - 'production-gce-c34' + 'production-gce-c34', 'production-gce-c35' ]) { assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId) } @@ -218,6 +218,19 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c34Mixed}`, canaryRunId: '42' }), /all general or all migration-only/) + // C35 is general once its Asia canary promotes it, so a rollback restores it general. + assert.equal(entryAdmission('production-gce-c35'), 'general') + assert.equal(selectorWaveDelta('production-gce-c35'), 2) + const c35Mixed = 'production-gce-c35,production-gce-c17' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: c35Mixed, + targetDigest, + rollbackDigest, + drainPaceWindowMs: '300000', + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c35Mixed}`, + canaryRunId: '42' + }), /all general or all migration-only/) const c31Mixed = 'production-gce-c31,production-gce-c18' assert.throws(() => validateSameCapWave({ mode: 'batch-apply', @@ -666,7 +679,7 @@ test('keeps Asia and migration-only cells on the default pace', () => { ) for (const cellId of SAME_CAP_CELLS) { const fast = SAME_CAP_FAST_DRAIN_PACE_CELLS.includes(cellId) - const asia = ['c27', 'c28', 'c29', 'c30', 'c31', 'c34'].some( + const asia = ['c27', 'c28', 'c29', 'c30', 'c31', 'c34', 'c35'].some( (cell) => cellId === `production-gce-${cell}` ) assert.equal(fast, entryAdmission(cellId) === 'general' && !asia, cellId) 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 90228f4c749..2fea683b82e 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -267,7 +267,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId) } assert.equal(resolveCellShape('production-gce-c12').status, 1) - assert.equal(resolveCellShape('production-gce-c35').status, 1) + assert.equal(resolveCellShape('production-gce-c36').status, 1) }) diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs index 019b31616fb..76523d435af 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs @@ -29,7 +29,8 @@ export const FLEET_POOL_CELL_IDS = [ 'production-gce-c29', 'production-gce-c30', 'production-gce-c31', - 'production-gce-c34' + 'production-gce-c34', + 'production-gce-c35' ] export const SHADOW_GATE_THRESHOLDS = { diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs index 34ac47fec7f..681b06c3187 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs @@ -279,6 +279,7 @@ function productionLikeEntries() { ...metricSamples({ cellId: 'production-gce-c30', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c31', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c34', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c35', from: '2026-09-20T20:20:00Z', count: 20 }), // One director instance's samples from ten minutes before the drain to the window's end. ...directorSamples({ from: '2026-09-20T19:50:00Z', count: 80, payload: {} }) ] @@ -340,10 +341,10 @@ function gcloudSeam(entries = productionLikeEntries()) { } } -test('reads every promoted asia-east2 cell as fleet pool, C34 included', () => { +test('reads every promoted asia-east2 cell as fleet pool, C35 included', () => { assert.deepEqual(FLEET_POOL_CELL_IDS, [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c34' + 'production-gce-c31', 'production-gce-c34', 'production-gce-c35' ]) }) @@ -365,6 +366,7 @@ test('a healthy roll reads as PASS and names the instance it proved serving', as 'fleetPool:production-gce-c30', 'fleetPool:production-gce-c31', 'fleetPool:production-gce-c34', + 'fleetPool:production-gce-c35', 'nonDrain503Budget' ]) assert.deepEqual(report.drain, { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs index adcb7807fba..3b73383730f 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs @@ -25,7 +25,8 @@ const CELL_SHAPES = { 'production-gce-c31': 'asia-east2-b', 'production-gce-c32': 'us-central1-a', 'production-gce-c33': 'us-central1-b', - 'production-gce-c34': 'asia-east2-c' + 'production-gce-c34': 'asia-east2-c', + 'production-gce-c35': 'asia-east2-a' }, waves: [ ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], @@ -33,7 +34,8 @@ const CELL_SHAPES = { ['production-gce-c31'], // Declared together, so they plan together: a lone C32 plan would hit C33's missing template. ['production-gce-c32', 'production-gce-c33'], - ['production-gce-c34'] + ['production-gce-c34'], + ['production-gce-c35'] ] }, staging: { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs index a4838aba046..0a939da7fbb 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs @@ -173,6 +173,18 @@ test('accepts the additive production C34 spare wave only in asia-east2-c', () = ) }) +test('accepts the additive production C35 spare wave only in asia-east2-a', () => { + const c35Config = { ...productionConfig, cells: ['production-gce-c35'] } + assert.deepEqual( + validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c35', 'asia-east2-a') }, c35Config), + { environment: 'production', cells: ['production-gce-c35'], changes: 4 } + ) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c35', 'asia-east2-c') }, c35Config), + /fixed-one Asia MIG shape/ + ) +}) + // A US cell joins the root region: no additional-region network, no region label or line, and // the default pool emits no line, exactly as the startup template renders a root-region cell. function usCellPlan(hostname, zone) { @@ -282,7 +294,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c29,production-gce-c27,production-gce-c28', 'production-gce-c30', 'production-gce-c31', - 'production-gce-c34' + 'production-gce-c34', + 'production-gce-c35' ]) { assert.doesNotThrow( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), @@ -300,7 +313,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c33', 'production-gce-c32,production-gce-c33,production-gce-c34', 'production-gce-c33,production-gce-c34', - 'production-gce-c35' + 'production-gce-c34,production-gce-c35', + 'production-gce-c36' ]) { assert.throws( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index faf404adf2a..66b56b1e901 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -227,6 +227,17 @@ director's serving digest and C34's runtime digest with the one `image-digest` i first rolled to the director's image as a migration-only same-cap wave. Once its canary promoted it, it moved to the same-cap general list and the fleet pool list together, so the same-cap job now rolls it as a general cell and a rollback restores it general. It is a declared rehome source. + +C35 is a seventh Asia cell at the C34 shape in `asia-east2-a`, starting a third zone round. It was +its own topology wave on image F (`sha256:41cba995…`), registered alone as migration-only, and then +the director was configured with `cell-ids` set to C35. It launched as a migration-only spare and has +a promotion wave of its own, with the same five-minute canary C30, C31 and C34 ran. Promotion +compares the director's serving digest and C35's runtime digest with the one `image-digest` input, +so C35 was first rolled to the director's image as a migration-only same-cap wave. Once its canary +promoted it, it moved to the same-cap general list and the fleet pool list together, so the same-cap +job now rolls it as a general cell and a rollback restores it general. It is a declared rehome source. +Its pool of 16 leaves 3 connections under the usable Cloud SQL ceiling +(`relay-cloud-sql-connection-budget.mjs`). Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index 70001984c7b..eebe07af809 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -332,7 +332,7 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It rejects deletes, replacements, loss of an existing host route, US-resource changes, and unrelated drift. Do not add production C27-C29 until the compatible image has been published and each entry can pin its immutable -digest. A later cell, such as C30, C31 or the C34 spare, is its own reviewed wave. The shared URL map +digest. A later cell, such as C30, C31, C34 or the C35 spare, is its own reviewed wave. The shared URL map pulls every live cell into its plan, so the workflow plans each live cell at the image its state template already serves, and the validator rejects any change to a cell outside the wave. US C32 and C33 use the same workflow at the same diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 102666a6dde..d427b2c28a3 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -468,6 +468,21 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } + # Seventh Asia cell: launched as a migration-only spare, now general; a starts the third zone round. + "production-gce-c35" = { + hostname = "c35" + region = "asia-east2" + zone = "asia-east2-a" + machine_type = "e2-standard-4" + boot_disk_gb = 30 + boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" + capacity_requests = 6000 + database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters). + image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:41cba995b288cab70579882ebef23e74c5ee426b6f8924cea7c986aba23bcbfe" + initially_enabled = false + connection_hard_cap = 3000 + connection_unobserved_bound = 60 + } } relay_region_rehome_source_cell_ids = [ @@ -495,7 +510,8 @@ relay_region_rehome_source_cell_ids = [ "production-gce-c31", "production-gce-c32", "production-gce-c33", - "production-gce-c34" + "production-gce-c34", + "production-gce-c35" ] # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 6f39a744c56..20f9e85dc52 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -194,17 +194,15 @@ 1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx 1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts 1 src/renderer/src/components/skills/SkillsPage.tsx -7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +2 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx 1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx 1 src/renderer/src/components/status-bar/PortsStatusSegment.tsx 1 src/renderer/src/components/status-bar/SshStatusSegment.tsx 1 src/renderer/src/components/status-bar/StatusBarSurface.tsx 1 src/renderer/src/components/status-bar/ports-status-popover-rows.tsx 2 src/renderer/src/components/status-bar/provider-account-sync-key.ts -1 src/renderer/src/components/status-bar/status-bar-claude-accounts.ts -1 src/renderer/src/components/status-bar/status-bar-codex-accounts.ts 1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts -7 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +2 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/status-bar/use-status-bar-controller.ts 1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts @@ -337,7 +335,7 @@ 1 src/renderer/src/lib/launch-structured-agent-session.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 1 src/renderer/src/lib/linear-issue-url-lookup.ts -2 src/renderer/src/lib/local-preflight-context.ts +1 src/renderer/src/lib/local-preflight-context.ts 3 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts 2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts diff --git a/config/oxlint-plugins/native-chat-design-tokens.mjs b/config/oxlint-plugins/native-chat-design-tokens.mjs new file mode 100644 index 00000000000..26c0e56a622 --- /dev/null +++ b/config/oxlint-plugins/native-chat-design-tokens.mjs @@ -0,0 +1,49 @@ +import { plainClassName } from './renderer-scrollbar-style.mjs' + +const CHECKS = { + 'no-arbitrary-font-size': { + pattern: /^text-\[(?:length:)?[\d.]+(?:px|rem|em)\]$/, + message: + 'Use a chat type step (text-sm, text-xs, text-2xs, text-3xs, text-chat-code), not an arbitrary size. See "Chat type scale" in docs/STYLEGUIDE.md.' + }, + 'no-softened-foreground': { + pattern: /^text-foreground\/\d+$/, + message: + "Don't soften text-foreground by hand: use text-chat-foreground on the chat canvas or text-muted-foreground on a lifted surface. See docs/STYLEGUIDE.md." + }, + 'no-weakened-focus-ring': { + pattern: /^ring-ring\/\d+$/, + message: + 'A focus ring at reduced opacity fails 3:1 contrast on the chat canvas: use ring-ring. See docs/STYLEGUIDE.md.' + } +} + +function literalTexts(node) { + if (node.type === 'Literal') { + return typeof node.value === 'string' ? [node.value] : [] + } + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw) +} + +function createRule({ pattern, message }) { + return { + create(context) { + const check = (node) => { + const offending = literalTexts(node) + .flatMap((text) => text.split(/\s+/)) + .find((token) => pattern.test(plainClassName(token))) + if (offending) { + context.report({ node, message: `${offending}: ${message}` }) + } + } + return { Literal: check, TemplateLiteral: check } + } + } +} + +export default { + meta: { name: 'native-chat-design-tokens' }, + rules: Object.fromEntries( + Object.entries(CHECKS).map(([name, check]) => [name, createRule(check)]) + ) +} diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 40b240f3296..a649927052d 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -710,15 +710,16 @@ diff --git a/src/win/conpty.cc b/src/win/conpty.cc index 7b286d3d64..5239ba4e40 100644 --- a/src/win/conpty.cc +++ b/src/win/conpty.cc -@@ -18,6 +18,7 @@ +@@ -18,6 +18,8 @@ #include #include #include +#include ++#include #include #include #include -@@ -44,15 +45,37 @@ struct pty_baton { +@@ -44,15 +46,39 @@ struct pty_baton { HANDLE hOut; HPCON hpc; @@ -751,6 +752,8 @@ index 7b286d3d64..5239ba4e40 100644 static std::vector> ptyHandles; +// Orca: every table access shares the exit watcher's lock; handles can be recycled. +static std::mutex ptyJobMutex; ++// Orca: 0 = no per-terminal ceiling (the default); set once at daemon start. ++static std::atomic ptyActiveProcessLimit{0}; static volatile LONG ptyCounter; -static pty_baton* get_pty_baton(int id) { @@ -758,7 +761,7 @@ index 7b286d3d64..5239ba4e40 100644 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) { +@@ -62,7 +88,7 @@ static pty_baton* get_pty_baton(int id) { return nullptr; } @@ -767,7 +770,7 @@ index 7b286d3d64..5239ba4e40 100644 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) { +@@ -102,8 +128,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 @@ -801,7 +804,7 @@ index 7b286d3d64..5239ba4e40 100644 auto status = tsfn.BlockingCall(exit_event, callback); // In main thread switch (status) { -@@ -242,6 +288,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info, +@@ -242,6 +291,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info, return HRESULT_FROM_WIN32(GetLastError()); } @@ -822,7 +825,7 @@ index 7b286d3d64..5239ba4e40 100644 static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { Napi::Env env(info.Env()); Napi::HandleScope scope(env); -@@ -301,8 +361,10 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { +@@ -301,8 +364,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)); @@ -835,7 +838,7 @@ index 7b286d3d64..5239ba4e40 100644 } else { throw Napi::Error::New(env, "Cannot launch conpty"); } -@@ -350,11 +412,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -350,11 +415,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { const bool useConptyDll = info[4].As().Value(); Napi::Function exitCallback = info[5].As(); @@ -855,7 +858,7 @@ index 7b286d3d64..5239ba4e40 100644 // 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) { +@@ -409,6 +478,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "UpdateProcThreadAttribute failed"); } @@ -871,7 +874,7 @@ index 7b286d3d64..5239ba4e40 100644 PROCESS_INFORMATION piClient{}; fSuccess = !!CreateProcessW( nullptr, -@@ -416,7 +491,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -416,7 +494,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { nullptr, // lpProcessAttributes nullptr, // lpThreadAttributes false, // bInheritHandles VERY IMPORTANT that this is false @@ -883,7 +886,7 @@ index 7b286d3d64..5239ba4e40 100644 envArg, // lpEnvironment mutableCwd.get(), // lpCurrentDirectory &siEx.StartupInfo, // lpStartupInfo -@@ -426,8 +504,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -426,8 +507,54 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "Cannot create process"); } @@ -909,6 +912,12 @@ index 7b286d3d64..5239ba4e40 100644 + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{}; + jobLimits.BasicLimitInformation.LimitFlags = + handle->allowJobBreakaway ? JOB_OBJECT_LIMIT_BREAKAWAY_OK : 0; ++ // Orca: an opt-in ceiling; forks past it fail inside this terminal's tree only. ++ const uint32_t activeProcessLimit = ptyActiveProcessLimit.load(); ++ if (activeProcessLimit > 0) { ++ jobLimits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; ++ jobLimits.BasicLimitInformation.ActiveProcessLimit = activeProcessLimit; ++ } + if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) || + !AssignProcessToJobObject(hJob, piClient.hProcess)) { + // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK @@ -934,7 +943,7 @@ index 7b286d3d64..5239ba4e40 100644 if (useConptyDll && fLoadedDll) { PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress( -@@ -438,8 +556,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -438,8 +565,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { } } @@ -949,7 +958,7 @@ index 7b286d3d64..5239ba4e40 100644 // Close the thread handle to avoid resource leak CloseHandle(piClient.hThread); -@@ -472,9 +594,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) { +@@ -472,9 +603,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) { SHORT rows = static_cast(info[2].As().Uint32Value()); const bool useConptyDll = info[3].As().Value(); @@ -962,7 +971,7 @@ index 7b286d3d64..5239ba4e40 100644 HANDLE hLibrary = LoadConptyDll(info, useConptyDll); bool fLoadedDll = hLibrary != nullptr; if (fLoadedDll) -@@ -513,9 +636,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) { +@@ -513,9 +645,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) { return env.Undefined(); } @@ -975,7 +984,7 @@ index 7b286d3d64..5239ba4e40 100644 HANDLE hLibrary = LoadConptyDll(info, useConptyDll); bool fLoadedDll = hLibrary != nullptr; if (fLoadedDll) -@@ -544,29 +668,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { +@@ -544,29 +677,229 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { int id = info[0].As().Int32Value(); const bool useConptyDll = info[1].As().Value(); @@ -1201,17 +1210,32 @@ index 7b286d3d64..5239ba4e40 100644 + hHostJob = job; + return Napi::Boolean::New(env, true); +} ++ ++/** ++ * Orca: setActiveProcessLimit(limit) applies JOB_OBJECT_LIMIT_ACTIVE_PROCESS to ++ * every ConPTY job created afterwards. 0 turns it off. Existing jobs keep theirs. ++ */ ++static Napi::Value PtySetActiveProcessLimit(const Napi::CallbackInfo& info) { ++ Napi::Env env(info.Env()); ++ Napi::HandleScope scope(env); ++ if (info.Length() != 1 || !info[0].IsNumber()) { ++ throw Napi::Error::New(env, "Usage: pty.setActiveProcessLimit(limit)"); ++ } ++ ptyActiveProcessLimit.store(info[0].As().Uint32Value()); ++ return Napi::Boolean::New(env, true); ++} + /** * Init */ -@@ -577,6 +887,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { +@@ -577,6 +910,10 @@ 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)); + exports.Set("terminateJob", Napi::Function::New(env, PtyTerminateJob)); + exports.Set("listJobProcessIds", Napi::Function::New(env, PtyListJobProcessIds)); + exports.Set("assignCurrentProcessToJob", Napi::Function::New(env, PtyAssignCurrentProcessToJob)); ++ exports.Set("setActiveProcessLimit", Napi::Function::New(env, PtySetActiveProcessLimit)); return exports; }; diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt deleted file mode 100644 index a308ec4b62c..00000000000 --- a/config/runtime-electron-baseline.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Modules reachable from the Orca runtime or structured-chat code that import `electron`. -# Generated by config/scripts/check-runtime-electron-ratchet.mjs. -# This list is EMPTY and must stay that way: both run in orcad, the headless runtime, -# on plain Node (see `pnpm run build:orcad`). Any entry means that code got less portable; -# migrate the module behind a host port instead (src/main/host/). - diff --git a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs index 128627676c4..bdcd9c62fd0 100644 --- a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs +++ b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs @@ -12,7 +12,7 @@ import fs from 'node:fs' import nodeModule from 'node:module' import path from 'node:path' import process from 'node:process' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' if (!process.execArgv.includes('--experimental-transform-types')) { const result = spawnSync( @@ -51,13 +51,17 @@ for (const paneCount of PANE_COUNTS) { } const { nextCadenceInspectionDelayMs } = await import( - path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts') + pathToFileURL( + path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts') + ).href ) const { POLL_TIER_INTERVAL_MS } = await import( - path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-cadence.ts') + pathToFileURL( + path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-cadence.ts') + ).href ) const { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } = await import( - path.join(ROOT, 'src/shared/process-table-snapshot-reader.ts') + pathToFileURL(path.join(ROOT, 'src/shared/process-table-snapshot-reader.ts')).href ) // Pre-change: independent ±10% jitter per pane, re-rolled on every reschedule. diff --git a/config/scripts/benchmark-preflight-compat-boundary.mjs b/config/scripts/benchmark-preflight-compat-boundary.mjs new file mode 100644 index 00000000000..521be683cf5 --- /dev/null +++ b/config/scripts/benchmark-preflight-compat-boundary.mjs @@ -0,0 +1,130 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { measurePreflightCommand } from './preflight-benchmark-command.mjs' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const output = path.join(root, '.build/preflight-benchmark') +mkdirSync(output, { recursive: true }) +const bun = process.env.ORCA_BENCHMARK_BUN ?? 'bun' +const sourcePath = 'config/scripts/check-compat-boundary-imports.mjs' +const baselineRef = process.env.ORCA_COMPAT_BASELINE_REF ?? 'HEAD^' +const baselinePath = path.join(output, 'compat-baseline.mjs') +const report = { + sourceSha: process.env.GITHUB_SHA ?? null, + baselineRef, + measurements: [], + comparisons: {}, + negativeProbe: null +} +const save = () => + writeFileSync(path.join(output, 'compat-boundary.json'), `${JSON.stringify(report, null, 2)}\n`) +const command = (program, args, label) => + measurePreflightCommand({ + program, + args, + label, + root, + output, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) +async function requireSuccess(program, args, label) { + const result = await command(program, args, label) + if (result.code !== 0 || result.signal || result.timedOut) { + throw new Error(`${label}: ${JSON.stringify(result)}`) + } + return result +} +report.bunVersion = (await requireSuccess(bun, ['--version'], 'compat-bun-version')).stdout.trim() +if (report.bunVersion !== '1.4.3') { + throw new Error('Pin Bun 1.4.3') +} +report.baselineSha = ( + await requireSuccess('git', ['rev-parse', baselineRef], 'compat-baseline-sha') +).stdout.trim() +const baselineSource = ( + await requireSuccess( + 'git', + ['show', `${report.baselineSha}:${sourcePath}`], + 'compat-baseline-source' + ) +).stdout +// Both versions inspect this checkout and load the same parser and source inventory. +writeFileSync( + baselinePath, + baselineSource.replace( + /from '(\.\/[^']+)'/g, + (_, specifier) => + `from '${pathToFileURL(path.resolve(root, 'config/scripts', specifier)).href}'` + ) +) +const arms = { + baseline: [process.execPath, baselinePath], + node: [process.execPath, sourcePath], + bun: [bun, sourcePath] +} +for (const [comparison, first, second] of [ + ['compat-prefilter', 'baseline', 'node'], + ['compat-runtime', 'node', 'bun'] +]) { + let diagnostics + for (const [pair, order] of buildCounterbalancedSchedule(4, first, second).entries()) { + for (const arm of order) { + const [program, script] = arms[arm] + const result = await requireSuccess(program, [script], `${comparison}-${pair}-${arm}`) + const findings = `${result.stdout}${result.stderr}` + if (diagnostics !== undefined && diagnostics !== findings) { + throw new Error(`Diagnostics changed: ${comparison}`) + } + diagnostics = findings + report.measurements.push({ comparison, pair, arm, ...result }) + save() + console.log(JSON.stringify({ comparison, pair, arm, durationMs: result.durationMs })) + } + } + report.comparisons[comparison] = Object.fromEntries( + [first, second].map((arm) => [ + arm, + summarizeBenchmarkSamples( + report.measurements + .filter((sample) => sample.comparison === comparison && sample.arm === arm) + .map((sample) => sample.durationMs) + ) + ]) + ) + save() +} +const probeDirectory = mkdtempSync(path.join(root, 'src/shared/preflight-compat-probe-')) +try { + for (const [name, specifier] of [ + ['plain', '../workspace-layout/old-client'], + ['escaped', '../workspace-layout/old-\\u0063lient/private'] + ]) { + writeFileSync(path.join(probeDirectory, `${name}.ts`), `import '${specifier}'\n`) + } + let diagnostics + for (const [arm, [program, script]] of Object.entries(arms)) { + const result = await command(program, [script], `compat-invalid-${arm}`) + const findings = `${result.stdout}${result.stderr}` + if ( + result.code !== 1 || + result.signal || + result.timedOut || + !findings.includes('/plain.ts:') || + !findings.includes('/escaped.ts:') + ) { + throw new Error(`Compatibility check did not reject fixtures: ${JSON.stringify(result)}`) + } + if (diagnostics !== undefined && diagnostics !== findings) { + throw new Error(`Negative-probe diagnostics changed: ${arm}`) + } + diagnostics = findings + } + report.negativeProbe = 'identical findings in baseline Node, optimized Node and optimized Bun' + save() +} finally { + rmSync(probeDirectory, { recursive: true, force: true }) +} +console.log(JSON.stringify(report.comparisons)) diff --git a/config/scripts/benchmark-preflight-localization-extraction.mjs b/config/scripts/benchmark-preflight-localization-extraction.mjs new file mode 100644 index 00000000000..1dce9715b90 --- /dev/null +++ b/config/scripts/benchmark-preflight-localization-extraction.mjs @@ -0,0 +1,96 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { measurePreflightCommand } from './preflight-benchmark-command.mjs' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const output = path.join(root, '.build/preflight-benchmark') +mkdirSync(output, { recursive: true }) +const bun = process.env.ORCA_BENCHMARK_BUN ?? 'bun' +const report = { sourceSha: process.env.GITHUB_SHA ?? null, measurements: [], comparison: null } +const save = () => + writeFileSync( + path.join(output, 'localization-extraction.json'), + `${JSON.stringify(report, null, 2)}\n` + ) +async function requireSuccess(program, args, label, env = process.env) { + const result = await measurePreflightCommand({ program, args, label, root, output, env }) + if (result.code !== 0 || result.signal || result.timedOut) { + throw new Error(`${label}: ${JSON.stringify(result)}`) + } + return result +} +report.bunVersion = ( + await requireSuccess(bun, ['--version'], 'extraction-bun-version') +).stdout.trim() +if (report.bunVersion !== '1.4.3') { + throw new Error('Pin Bun 1.4.3') +} +const directory = mkdtempSync(path.join(output, 'extraction-')) +const outputPattern = path.join(directory, '{{language}}.json').split(path.sep).join('/') +const args = [ + 'node_modules/i18next-cli/dist/esm/cli.js', + '--config', + 'config/i18next.config.ts', + 'extract', + '--sync-primary', + '--quiet' +] +let catalog +try { + for (const [pair, order] of buildCounterbalancedSchedule(4, 'node', 'bun').entries()) { + for (const arm of order) { + rmSync(path.join(directory, 'en.json'), { force: true }) + const result = await requireSuccess( + arm === 'bun' ? bun : process.execPath, + args, + `extraction-${pair}-${arm}`, + { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_I18N_EXTRACTION_OUTPUT: outputPattern } + ) + const extracted = JSON.stringify( + JSON.parse(readFileSync(path.join(directory, 'en.json'), 'utf8')) + ) + if (catalog !== undefined && catalog !== extracted) { + throw new Error(`Extracted catalog changed: ${arm}`) + } + catalog = extracted + report.measurements.push({ pair, arm, ...result }) + save() + console.log(JSON.stringify({ pair, arm, durationMs: result.durationMs })) + } + } + report.comparison = Object.fromEntries( + ['node', 'bun'].map((arm) => [ + arm, + summarizeBenchmarkSamples( + report.measurements + .filter((sample) => sample.arm === arm) + .map((sample) => sample.durationMs) + ) + ]) + ) + report.catalogParity = 'identical full extracted catalogs in all eight runs' + save() +} finally { + rmSync(directory, { recursive: true, force: true }) +} +let findings +for (const [arm, program] of [ + ['node', process.execPath], + ['bun', bun] +]) { + const result = await requireSuccess( + program, + ['config/scripts/verify-localization-extraction.mjs'], + `extraction-check-${arm}` + ) + if (findings !== undefined && findings !== result.stdout) { + throw new Error(`Extraction gate findings changed: ${arm}`) + } + findings = result.stdout +} +report.gateParity = findings +save() +console.log(JSON.stringify(report.comparison)) diff --git a/config/scripts/build-source-commit.mjs b/config/scripts/build-source-commit.mjs new file mode 100644 index 00000000000..70aece6ef98 --- /dev/null +++ b/config/scripts/build-source-commit.mjs @@ -0,0 +1,22 @@ +import { execFileSync } from 'node:child_process' + +/** + * The full commit this build is made from. Official skill installs pin to it, so the + * skills an Orca build installs are the ones it bundles on every release channel. + */ +export function resolveBuildSourceCommit() { + let commit = '' + try { + commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + } catch (error) { + throw new Error( + `Could not read the source commit with \`git rev-parse HEAD\`: ${ + error instanceof Error ? error.message : String(error) + }. Orca builds must run from a git checkout so official skills can pin to it.` + ) + } + if (!/^[0-9a-f]{40}$/.test(commit)) { + throw new Error(`\`git rev-parse HEAD\` returned "${commit}", not a full commit hash.`) + } + return commit +} diff --git a/config/scripts/check-compat-boundary-imports.mjs b/config/scripts/check-compat-boundary-imports.mjs index 2887c12bbbf..0b57187e105 100644 --- a/config/scripts/check-compat-boundary-imports.mjs +++ b/config/scripts/check-compat-boundary-imports.mjs @@ -35,7 +35,15 @@ function importTarget(file, specifier) { export function assessCompatBoundaryImports(sources, folders = COMPAT_FOLDERS) { const violations = [] + const folderNames = folders.map(({ folder }) => path.posix.basename(folder)) for (const [file, contents] of sources) { + // Escapes can hide a folder name; relative imports can inherit it from the file path. + if ( + !contents.includes('\\') && + !folderNames.some((name) => contents.includes(name) || file.includes(name)) + ) { + continue + } for (const specifier of collectModuleSpecifiers(file, contents)) { const target = importTarget(file, specifier)?.replace(/\.[cm]?[jt]sx?$/, '') const compat = folders.find( diff --git a/config/scripts/check-compat-boundary-imports.test.mjs b/config/scripts/check-compat-boundary-imports.test.mjs index a1ecee12116..352c8e9d38a 100644 --- a/config/scripts/check-compat-boundary-imports.test.mjs +++ b/config/scripts/check-compat-boundary-imports.test.mjs @@ -47,4 +47,43 @@ describe('compatibility folder import boundary', () => { }) ).toEqual(['src/main/other.test.ts: import src/main/compat through its index, not ./compat/a']) }) + + it.each([ + './co\\u006dpat/a', + './co\\x6dpat/a', + './co\\mpat/a', + './co\\\nmpat/a', + './co\\u{6d}pat/a' + ])('rejects deep imports with escaped folder names: %s', (specifier) => { + expect(assess({ 'src/main/owner.ts': `import { a } from '${specifier}'` })).toEqual([ + 'src/main/owner.ts: import src/main/compat through its index, not ./compat/a' + ]) + }) + + it('checks const-bound loaders and dot segments', () => { + expect( + assess({ + 'src/main/other.ts': "const entry = './compat/nested/../index'; require(entry)", + 'src/renderer/src/store.ts': "export { f } from '@renderer/runtime/frozen/inner/../index'" + }) + ).toEqual([ + 'src/main/other.ts: only src/main/owner.ts may import src/main/compat', + 'src/renderer/src/store.ts: import src/renderer/src/runtime/frozen through its index, not @renderer/runtime/frozen/inner/../index' + ]) + }) + + it('checks imports inherited from a compatibility folder in the file path', () => { + expect( + assessCompatBoundaryImports( + new Map([['src/main/compat.ts/consumer.ts', "import { a } from '.'"]]), + [{ folder: 'src/main/compat', importers: [] }] + ) + ).toEqual(['src/main/compat.ts/consumer.ts: only no file may import src/main/compat']) + }) + + it('keeps parse failures fatal for candidate files', () => { + expect(() => assess({ 'src/main/owner.ts': "import { from './compat'" })).toThrow( + 'Cannot parse' + ) + }) }) diff --git a/config/scripts/check-focus-read-ratchet.mjs b/config/scripts/check-focus-read-ratchet.mjs index 5ab5e7a322d..bed97e87264 100644 --- a/config/scripts/check-focus-read-ratchet.mjs +++ b/config/scripts/check-focus-read-ratchet.mjs @@ -1,8 +1,14 @@ -import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' +import { + diffCounts, + parseCountBaseline, + pruneCountBaseline, + readTrackedSources, + totalCount +} from './ratchet-baseline.mjs' // Ratchet for renderer code that routes host-owned work by the "Active Server" setting instead of // by the resource's own host. Per file it counts every read of the setting plus every call of an @@ -352,86 +358,12 @@ export function isScannedPath(rel) { return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel) } -/** ` [# note]` lines; `#` comment lines and blanks ignored. */ -export function parseBaseline(text) { - const counts = new Map() - for (const raw of text.split('\n')) { - const line = raw.trim() - if (!line || line.startsWith('#')) { - continue - } - const [count, file] = line.split(/\s+/, 2) - counts.set(file, Number(count)) - } - return counts -} - -/** Trailing `# note` per row, so a pruned baseline keeps why an entry is still allowed. */ -export function parseBaselineNotes(text) { - const notes = new Map() - for (const raw of text.split('\n')) { - const match = /^\s*\d+\s+(\S+)\s+#\s*(.+?)\s*$/.exec(raw) - if (match) { - notes.set(match[1], match[2]) - } - } - return notes -} - -export function formatBaseline(counts, header = BASELINE_HEADER, notes = new Map()) { - const rows = [...counts] - .filter(([, count]) => count > 0) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([file, count]) => - notes.has(file) ? `${count} ${file} # ${notes.get(file)}` : `${count} ${file}` - ) - return `${[...header, ''].join('\n')}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}` -} - -/** `grown`: above baseline (fails). `shrunk`: below baseline, must be pruned so it cannot regrow. */ -export function diffCounts(current, baseline) { - const grown = [] - const shrunk = [] - for (const file of new Set([...current.keys(), ...baseline.keys()])) { - const now = current.get(file) ?? 0 - const allowed = baseline.get(file) ?? 0 - if (now > allowed) { - grown.push({ file, now, allowed }) - } else if (now < allowed) { - shrunk.push({ file, now, allowed }) - } - } - const byFile = (a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) - return { grown: grown.sort(byFile), shrunk: shrunk.sort(byFile) } -} - -function readTrackedSources(root, scanRoot) { - const tracked = execFileSync('git', ['ls-files', scanRoot], { - cwd: root, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024 - }) - .split('\n') - .filter((rel) => rel && isScannedPath(rel)) - const sources = new Map() - for (const rel of tracked) { - try { - sources.set(rel, fs.readFileSync(path.join(root, rel), 'utf8')) - } catch { - // Deleted in the working tree but still tracked. - } - } - return sources -} - // One read and one discovery per root per run; `--prune` then `main` reuse them. const scanCache = new Map() function scanSources(root) { if (!scanCache.has(root)) { - const sources = new Map( - FOCUS_SCAN_ROOTS.flatMap((scanRoot) => [...readTrackedSources(root, scanRoot)]) - ) + const sources = readTrackedSources(root, FOCUS_SCAN_ROOTS, isScannedPath) scanCache.set(root, { sources, readers: discoverFocusReaders(sources) }) } return scanCache.get(root) @@ -460,10 +392,6 @@ export function collectCurrentCounts(root = process.cwd()) { return { counts, forbidden, aliased } } -function total(counts) { - return [...counts.values()].reduce((sum, count) => sum + count, 0) -} - export function main(root = process.cwd()) { const baselineFile = path.join(root, BASELINE_PATH) if (!fs.existsSync(baselineFile)) { @@ -483,7 +411,7 @@ export function main(root = process.cwd()) { } const { grown, shrunk } = diffCounts( current, - parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + parseCountBaseline(fs.readFileSync(baselineFile, 'utf8')) ) for (const { file, now, allowed } of grown) { console.error( @@ -498,23 +426,14 @@ export function main(root = process.cwd()) { if (forbidden.length + aliased.length + grown.length + shrunk.length > 0) { return 1 } - console.log(`Focus-read ratchet OK — ${total(current)} read(s).`) + console.log(`Focus-read ratchet OK — ${totalCount(current)} read(s).`) return 0 } export function prune(root = process.cwd()) { - const baselineFile = path.join(root, BASELINE_PATH) - const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' const { counts: current } = collectCurrentCounts(root) - // Lowers entries only; growth still has to be fixed in the code. - const pruned = new Map( - [...parseBaseline(text)].map(([file, allowed]) => [ - file, - Math.min(allowed, current.get(file) ?? 0) - ]) - ) - fs.writeFileSync(baselineFile, formatBaseline(pruned, BASELINE_HEADER, parseBaselineNotes(text))) - console.log(`Pruned ${BASELINE_PATH} to ${total(pruned)} read(s).`) + const kept = pruneCountBaseline(root, BASELINE_PATH, BASELINE_HEADER, current) + console.log(`Pruned ${BASELINE_PATH} to ${kept} read(s).`) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/check-focus-read-ratchet.test.mjs b/config/scripts/check-focus-read-ratchet.test.mjs index 7d7477fd92e..22f2d2ddad9 100644 --- a/config/scripts/check-focus-read-ratchet.test.mjs +++ b/config/scripts/check-focus-read-ratchet.test.mjs @@ -3,13 +3,9 @@ import { describe, expect, it } from 'vitest' import { countFocusSettingReads, countForbiddenHelperUses, - diffCounts, discoverFocusReaders, - formatBaseline, hasFocusRoutingAlias, - isScannedPath, - parseBaseline, - parseBaselineNotes + isScannedPath } from './check-focus-read-ratchet.mjs' describe('countForbiddenHelperUses', () => { @@ -247,53 +243,3 @@ describe('isScannedPath', () => { expect(isScannedPath('src/renderer/src/a.spec.tsx')).toBe(false) }) }) - -describe('baseline', () => { - it('round-trips through format and parse, dropping zero rows', () => { - const counts = new Map([ - ['src/b.ts', 2], - ['src/a.ts', 1], - ['src/c.ts', 0] - ]) - expect(parseBaseline(formatBaseline(counts))).toEqual( - new Map([ - ['src/a.ts', 1], - ['src/b.ts', 2] - ]) - ) - }) - - it('keeps a row note through a prune', () => { - const text = formatBaseline( - new Map([['src/a.ts', 1]]), - ['# header'], - new Map([['src/a.ts', 'waits for V4b']]) - ) - expect(text).toBe('# header\n1 src/a.ts # waits for V4b\n') - expect(parseBaseline(text)).toEqual(new Map([['src/a.ts', 1]])) - expect(parseBaselineNotes(text)).toEqual(new Map([['src/a.ts', 'waits for V4b']])) - }) - - it('fails growth, including a new file, and asks to prune shrinkage', () => { - const { grown, shrunk } = diffCounts( - new Map([ - ['src/a.ts', 3], - ['src/new.ts', 1], - ['src/b.ts', 1] - ]), - new Map([ - ['src/a.ts', 2], - ['src/b.ts', 2], - ['src/gone.ts', 1] - ]) - ) - expect(grown).toEqual([ - { file: 'src/a.ts', now: 3, allowed: 2 }, - { file: 'src/new.ts', now: 1, allowed: 0 } - ]) - expect(shrunk).toEqual([ - { file: 'src/b.ts', now: 1, allowed: 2 }, - { file: 'src/gone.ts', now: 0, allowed: 1 } - ]) - }) -}) diff --git a/config/scripts/check-layout-writer-ratchets.mjs b/config/scripts/check-layout-writer-ratchets.mjs index 4381123e576..73c338a320e 100644 --- a/config/scripts/check-layout-writer-ratchets.mjs +++ b/config/scripts/check-layout-writer-ratchets.mjs @@ -1,4 +1,3 @@ -import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -7,10 +6,12 @@ import ts from 'typescript-api' import { diffCounts, - formatBaseline, - parseBaseline, - parseBaselineNotes -} from './check-focus-read-ratchet.mjs' + formatCountBaseline, + listTrackedFiles, + parseCountBaseline, + pruneCountBaseline, + totalCount +} from './ratchet-baseline.mjs' // Two ratchets for writers of workspace tab layout, which is moving to one owner in the runtime. // Per-file counts may only go down. Deleted symbols are left to the compiler. @@ -398,19 +399,9 @@ export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') { return count } -function trackedFiles(root, scanRoot) { - return execFileSync('git', ['ls-files', scanRoot], { - cwd: root, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024 - }) - .split('\n') - .filter((rel) => rel && isScannedPath(rel)) -} - export function collectCurrentCounts(ratchet, root = process.cwd()) { const counts = new Map() - for (const rel of trackedFiles(root, ratchet.scanRoot)) { + for (const rel of listTrackedFiles(root, [ratchet.scanRoot]).filter(isScannedPath)) { if (ratchet.allowed(rel)) { continue } @@ -429,10 +420,6 @@ export function collectCurrentCounts(ratchet, root = process.cwd()) { return counts } -function total(counts) { - return [...counts.values()].reduce((sum, count) => sum + count, 0) -} - const ADVICE = 'Workspace layout is moving to one runtime owner; send a layout command instead of writing it here.' @@ -448,7 +435,7 @@ export function main(root = process.cwd()) { const current = collectCurrentCounts(ratchet, root) const { grown, shrunk } = diffCounts( current, - parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + parseCountBaseline(fs.readFileSync(baselineFile, 'utf8')) ) for (const { file, now, allowed } of grown) { console.error( @@ -464,7 +451,9 @@ export function main(root = process.cwd()) { if (grown.length > 0) { failed = true } else { - console.log(`Layout-writer ratchet (${ratchet.id}) OK — ${total(current)} ${ratchet.label}.`) + console.log( + `Layout-writer ratchet (${ratchet.id}) OK — ${totalCount(current)} ${ratchet.label}.` + ) } } return failed ? 1 : 0 @@ -472,18 +461,9 @@ export function main(root = process.cwd()) { export function prune(root = process.cwd()) { for (const ratchet of RATCHETS) { - const baselineFile = path.join(root, ratchet.baselinePath) - const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' const current = collectCurrentCounts(ratchet, root) - // Lowers entries only; growth still has to be fixed in the code. - const pruned = new Map( - [...parseBaseline(text)].map(([file, allowed]) => [ - file, - Math.min(allowed, current.get(file) ?? 0) - ]) - ) - fs.writeFileSync(baselineFile, formatBaseline(pruned, ratchet.header, parseBaselineNotes(text))) - console.log(`Pruned ${ratchet.baselinePath} to ${total(pruned)} ${ratchet.label}.`) + const kept = pruneCountBaseline(root, ratchet.baselinePath, ratchet.header, current) + console.log(`Pruned ${ratchet.baselinePath} to ${kept} ${ratchet.label}.`) } } @@ -495,7 +475,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const counts = collectCurrentCounts(ratchet, root) fs.writeFileSync( path.join(root, ratchet.baselinePath), - formatBaseline(counts, ratchet.header) + formatCountBaseline(counts, ratchet.header) ) } } else if (process.argv[2] === '--prune') { diff --git a/config/scripts/check-max-lines-ratchet.mjs b/config/scripts/check-max-lines-ratchet.mjs index 052e4b5b7c4..1a357bf7e5c 100644 --- a/config/scripts/check-max-lines-ratchet.mjs +++ b/config/scripts/check-max-lines-ratchet.mjs @@ -1,8 +1,8 @@ -import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' +import { readTrackedSources, runEntryRatchet } from './ratchet-baseline.mjs' // Ratchet gate for the oxlint `max-lines` rule. // @@ -74,46 +74,16 @@ export function collectMobileBumps(configText) { return bumps } -export function parseBaseline(text) { - return new Set( - text - .split('\n') - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#')) - ) -} - -export function diffBaseline(current, baseline) { - const cur = new Set(current) - const base = baseline instanceof Set ? baseline : new Set(baseline) - const added = [...cur].filter((e) => !base.has(e)).sort() - const stale = [...base].filter((e) => !cur.has(e)).sort() - return { added, stale } -} - // Collect every current suppression entry from the tracked tree. export function collectCurrentSuppressions(root = process.cwd()) { - const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts', '*.mjs'], { - cwd: root, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024 - }) - .split('\n') - .filter(Boolean) - .filter((f) => !SELF_FILES.has(f)) - - const entries = [] - for (const rel of tracked) { - let src - try { - src = fs.readFileSync(path.join(root, rel), 'utf8') - } catch { - continue - } - if (hasMaxLinesDisable(src)) { - entries.push(`inline ${rel}`) - } - } + const sources = readTrackedSources( + root, + ['*.ts', '*.tsx', '*.mts', '*.cts', '*.mjs'], + (rel) => !SELF_FILES.has(rel) + ) + const entries = [...sources] + .filter(([, src]) => hasMaxLinesDisable(src)) + .map(([rel]) => `inline ${rel}`) const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH) if (fs.existsSync(mobileCfgPath)) { @@ -123,134 +93,29 @@ export function collectCurrentSuppressions(root = process.cwd()) { return entries.sort() } -function printAddedFailure(added) { - for (const entry of added) { - console.error(`::error::New max-lines bypass not allowed: ${entry}`) - } - console.error('') - console.error('╭────────────────────────────────────────────────────────────────────────────╮') - console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │') - console.error('╰────────────────────────────────────────────────────────────────────────────╯') - console.error('') - console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`) - console.error('') - for (const entry of added) { - const [kind, ...rest] = entry.split(' ') - const target = rest.join(' ') - const how = - kind === 'inline' - ? 'added an eslint/oxlint-disable max-lines comment' - : 'added a per-file max-lines bump in mobile/.oxlintrc.json' - console.error(` • ${target}\n ↳ ${how}`) - } - console.error('') - console.error( - ' Orca caps file size (300 .ts/.mts/.cts / 400 .tsx / 600 .mjs / 800 test — non-blank,' - ) - console.error( - ' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.' - ) - console.error('') - console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.') - console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".') - console.error('') - console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable') - console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`) - console.error('') -} - -function printStaleFailure(stale) { - for (const entry of stale) { - console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`) - } - console.error('') - console.error('╭────────────────────────────────────────────────────────────────────────────╮') - console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │') - console.error('╰────────────────────────────────────────────────────────────────────────────╯') - console.error('') - console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`) - console.error( - ' The baseline may only shrink, so these must be removed to keep re-adding blocked:' - ) - console.error('') - for (const entry of stale) { - console.error(` • ${entry}`) - } - console.error('') - console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`) - console.error('') -} - -export function main(root = process.cwd()) { - const baselineFile = path.join(root, BASELINE_PATH) - if (!fs.existsSync(baselineFile)) { - console.error( - `::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init` - ) - return 1 - } - const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const current = collectCurrentSuppressions(root) - const { added, stale } = diffBaseline(current, baseline) - - if (added.length > 0) { - printAddedFailure(added) - if (stale.length > 0) { - console.error( - ` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)` - ) - printStaleFailure(stale) - } - return 1 - } - if (stale.length > 0) { - printStaleFailure(stale) - return 1 - } - console.log( - `max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.` - ) - return 0 -} - -function writeBaseline(root, entries) { - const header = [ +const RATCHET = { + name: 'max-lines', + baselinePath: BASELINE_PATH, + header: [ '# Files/globs currently allowed to exceed the oxlint `max-lines` budget.', '# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —', '# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").', - '# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)', - '' - ].join('\n') - fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`) + '# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)' + ], + collect: collectCurrentSuppressions, + fix: [ + 'An `inline` entry added an eslint/oxlint-disable max-lines comment; a `mobile-config` entry', + 'added a per-file max-lines bump in mobile/.oxlintrc.json. Orca caps file size (300', + '.ts/.mts/.cts / 400 .tsx / 600 .mjs / 800 test — non-blank, non-comment lines).', + 'Fix it: SPLIT the file into focused modules — do NOT suppress the rule.', + 'See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".' + ] +} + +export function main(root = process.cwd(), arg = undefined) { + return runEntryRatchet(RATCHET, root, arg) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const root = process.cwd() - const arg = process.argv[2] - if (arg === '--init') { - // One-time bootstrap: capture the current suppression set as the baseline. - const entries = collectCurrentSuppressions(root) - writeBaseline(root, entries) - console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`) - process.exit(0) - } - if (arg === '--prune') { - // Remove baseline entries whose suppression is gone (shrink only; never adds). - const current = new Set(collectCurrentSuppressions(root)) - const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8')) - const kept = [...baseline].filter((e) => current.has(e)).sort() - const newlyAdded = [...current].filter((e) => !baseline.has(e)) - writeBaseline(root, kept) - console.log( - `Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).` - ) - if (newlyAdded.length > 0) { - console.error( - `::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.` - ) - process.exit(1) - } - process.exit(0) - } - process.exit(main(root)) + process.exit(main(process.cwd(), process.argv[2])) } diff --git a/config/scripts/check-max-lines-ratchet.test.mjs b/config/scripts/check-max-lines-ratchet.test.mjs index 0ee7bab3a54..a1202fa4ede 100644 --- a/config/scripts/check-max-lines-ratchet.test.mjs +++ b/config/scripts/check-max-lines-ratchet.test.mjs @@ -9,9 +9,7 @@ import { collectCurrentSuppressions, collectMobileBumps, defaultLimitForPath, - diffBaseline, - hasMaxLinesDisable, - parseBaseline + hasMaxLinesDisable } from './check-max-lines-ratchet.mjs' describe('hasMaxLinesDisable', () => { @@ -228,27 +226,3 @@ describe('max-lines repository coverage', () => { } ) }) - -describe('parseBaseline', () => { - it('drops comments and blank lines', () => { - const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n') - expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx'])) - }) -}) - -describe('diffBaseline', () => { - it('reports added and stale entries', () => { - const { added, stale } = diffBaseline( - ['inline b.ts', 'inline c.ts'], - new Set(['inline a.ts', 'inline b.ts']) - ) - expect(added).toEqual(['inline c.ts']) // new bypass - expect(stale).toEqual(['inline a.ts']) // suppression removed - }) - - it('is clean when current matches baseline', () => { - const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts'])) - expect(added).toEqual([]) - expect(stale).toEqual([]) - }) -}) diff --git a/config/scripts/check-process-host-imports.mjs b/config/scripts/check-process-host-imports.mjs index 8967f3f186c..7ae76262eff 100644 --- a/config/scripts/check-process-host-imports.mjs +++ b/config/scripts/check-process-host-imports.mjs @@ -2,6 +2,7 @@ import { globSync, readFileSync } from 'node:fs' import { isBuiltin } from 'node:module' import path from 'node:path' import { pathToFileURL } from 'node:url' +import { diffEntries, parseEntryBaseline } from './ratchet-baseline.mjs' import { collectModuleSpecifiers } from './static-module-specifiers.mjs' import { isTestOnlySourcePath } from './test-only-source-path.mjs' @@ -97,23 +98,13 @@ export function assessProcessHostImports(sources, manifest, baseline) { } } } - const current = [...direct].sort() - const baselineSet = new Set(baseline) return { - direct: current, - added: current.filter((file) => !baselineSet.has(file)), - stale: baseline.filter((file) => !direct.has(file)).sort(), + direct: [...direct].sort(), + ...diffEntries(direct, baseline), violations: violations.sort() } } -export function readProcessHostBaseline(contents) { - return contents - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) -} - export function collectProcessHostSources(root = ROOT) { return new Map( globSync(SOURCE_PATTERNS, { @@ -130,7 +121,7 @@ export function main(root = ROOT) { const manifest = JSON.parse( readFileSync(path.join(root, PACKAGE_DIRECTORY, 'package.json'), 'utf8') ) - const baseline = readProcessHostBaseline(readFileSync(path.join(root, BASELINE_PATH), 'utf8')) + const baseline = parseEntryBaseline(readFileSync(path.join(root, BASELINE_PATH), 'utf8')) const sources = collectProcessHostSources(root) if ( ![...sources.keys()].some( diff --git a/config/scripts/check-process-host-imports.test.mjs b/config/scripts/check-process-host-imports.test.mjs index 32bd657f9f6..1ae8607ee7b 100644 --- a/config/scripts/check-process-host-imports.test.mjs +++ b/config/scripts/check-process-host-imports.test.mjs @@ -5,8 +5,7 @@ import path from 'node:path' import { assessProcessHostImports, collectProcessHostSources, - main, - readProcessHostBaseline + main } from './check-process-host-imports.mjs' import { collectModuleSpecifiers } from './static-module-specifiers.mjs' @@ -240,10 +239,4 @@ describe('process-host import boundary', () => { }) expect(() => main(root)).toThrow('Missing process-host implementation') }) - - it('reads a commented baseline without allowing formatting to invent entries', () => { - expect(readProcessHostBaseline('# migration debt\n\n src/main/legacy.ts \n')).toEqual([ - 'src/main/legacy.ts' - ]) - }) }) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index facd6fd562f..2fd9d82357f 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -5,18 +5,16 @@ * The runtime boots on plain Node, where Electron is unavailable. Keep desktop * dependencies out of its graph, including structured chat not yet wired into it. * - * This bundles the runtime and the structured-chat lanes with esbuild, reads the metafile for every module that - * imports `electron`, and compares that set to a checked-in baseline. A NEW module - * fails the build; a removed one must be dropped from the baseline. The baseline - * may only shrink, so the migration is measurable and cannot regress. + * This bundles the runtime and the structured-chat lanes with esbuild and reads the metafile for + * every module that imports `electron`. Any such module fails: the migration reached zero. * * This is a reachability check, not a lint rule: the point is precisely the edges * that no per-file rule can see. * - * Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write] + * Usage: node config/scripts/check-runtime-electron-ratchet.mjs */ import { build } from 'esbuild' -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' @@ -27,7 +25,6 @@ import { createWorkspaceSourceResolver } from './workspace-source-exports.mjs' // editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack // instead of a usable message. const ROOT = path.join(import.meta.dirname, '..', '..') -const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt') // The two module graphs a Node backend would have to boot: the runtime service // itself and the RPC server that fronts it. @@ -49,7 +46,10 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'main', 'codex'] }, { directory: ['src', 'shared'] }, { directory: ['src', 'packages', 'process-host', 'src'] }, - { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, + { + directory: ['src', 'main', 'runtime'], + basename: /^(?:structured-|agent-session-)/ + }, { directory: ['src', 'main', 'provider-process'] }, { directory: ['src', 'main', 'acp'] }, { directory: ['src', 'main', 'jsonl-rpc'] }, @@ -110,7 +110,10 @@ const EXTERNAL = [ const externalNativeAddons = { name: 'external-native-addons', setup(pluginBuild) { - pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) + pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ + path: args.path, + external: true + })) } } @@ -163,53 +166,15 @@ export async function collectElectronImporters(entryPoints, { plugins = [] } = { return [...importers].sort() } -export function readBaseline(text) { - return text - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('#')) - .sort() -} - -export function diffAgainstBaseline(current, baseline) { - const baselineSet = new Set(baseline) - const currentSet = new Set(current) - return { - added: current.filter((file) => !baselineSet.has(file)), - removed: baseline.filter((file) => !currentSet.has(file)) - } -} - -function renderBaseline(files) { - return [ - '# Modules reachable from the Orca runtime or structured-chat code that import `electron`.', - '# Generated by config/scripts/check-runtime-electron-ratchet.mjs.', - '# This list is EMPTY and must stay that way: both run in orcad, the headless runtime,', - '# on plain Node (see `pnpm run build:orcad`). Any entry means that code got less portable;', - '# migrate the module behind a host port instead (src/main/host/).', - '', - ...files - ].join('\n') -} - // Exported so tests run the CLI path itself; `plugins` is the same in-memory hook as above. -export async function main(argv = process.argv, { plugins = [] } = {}) { - const write = argv.includes('--write') - const current = await collectElectronImporters(defaultEntryPoints(), { plugins }) - - if (write) { - writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`) - console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`) - return 0 - } - - const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8')) - const { added, removed } = diffAgainstBaseline(current, baseline) - - if (added.length > 0) { +export async function main({ plugins = [] } = {}) { + const current = await collectElectronImporters(defaultEntryPoints(), { + plugins + }) + if (current.length > 0) { console.error( - `[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime or structured-chat code now import electron: -${added.map((file) => ` + ${file}`).join('\n')} + `[runtime-electron-ratchet] ${current.length} module(s) reachable from the Orca runtime or structured-chat code import electron: +${current.map((file) => ` + ${file}`).join('\n')} The runtime and structured chat must run in orcad, the headless runtime, where Electron is unavailable. That holds for structured-chat code the runtime doesn't load yet, so renaming or @@ -218,19 +183,7 @@ depend on the port, or drop the import that pulls Electron in.` ) return 1 } - - if (removed.length > 0) { - console.error( - `[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice. -Refresh the baseline so the gate keeps its new, tighter floor: -${removed.map((file) => ` - ${file}`).join('\n')} - - node config/scripts/check-runtime-electron-ratchet.mjs --write` - ) - return 1 - } - - console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`) + console.log('[runtime-electron-ratchet] ok — nothing reachable imports electron.') return 0 } diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index cb1e06b3a34..984fe088916 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -7,9 +7,7 @@ import { collectElectronImporters, collectStructuredChatEntryPoints, defaultEntryPoints, - diffAgainstBaseline, - main, - readBaseline + main } from './check-runtime-electron-ratchet.mjs' describe('structured chat coverage', () => { @@ -240,53 +238,13 @@ describe('the command-line check', () => { } } const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - expect(await main([], { plugins: [addElectron] })).toBe(1) + expect(await main({ plugins: [addElectron] })).toBe(1) expect(error.mock.calls.join('\n')).toContain('+ src/main/native-chat/transcript-read-cache.ts') }, 120_000) // Why real: the value of this gate is the transitive edges, which a fixture cannot model. - it('passes on the tree as it is, matching the checked-in baseline', async () => { + it('passes on the tree as it is, with nothing reachable importing electron', async () => { vi.spyOn(console, 'log').mockImplementation(() => {}) - expect(await main([])).toBe(0) + expect(await main()).toBe(0) }, 120_000) }) - -describe('readBaseline', () => { - it('drops comments and blank lines and sorts, so baseline formatting cannot cause a false diff', () => { - expect(readBaseline('# header\n\n b/second.ts \na/first.ts\n')).toEqual([ - 'a/first.ts', - 'b/second.ts' - ]) - }) -}) - -describe('diffAgainstBaseline', () => { - it('reports a module that started importing electron', () => { - expect(diffAgainstBaseline(['a.ts', 'b.ts'], ['a.ts'])).toEqual({ - added: ['b.ts'], - removed: [] - }) - }) - - it('reports a module that stopped, so the baseline is forced to tighten rather than drift', () => { - expect(diffAgainstBaseline(['a.ts'], ['a.ts', 'b.ts'])).toEqual({ - added: [], - removed: ['b.ts'] - }) - }) - - it('is quiet when the set is unchanged', () => { - expect(diffAgainstBaseline(['a.ts'], ['a.ts'])).toEqual({ added: [], removed: [] }) - }) -}) - -describe('the checked-in baseline', () => { - // Why an exact-empty assertion now: the reachable set reached zero, so "may only - // shrink" has no room left and any entry at all is a regression. This is strictly - // stronger than the old under-src/ check, which only stopped a node_modules path from - // padding a non-empty count. - it('stays empty, so nothing reachable from the runtime imports electron', () => { - const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8')) - expect(baseline).toEqual([]) - }) -}) diff --git a/config/scripts/check-ts-nocheck-ratchet.mjs b/config/scripts/check-ts-nocheck-ratchet.mjs index ecd3effbc72..f0fbb3f222b 100644 --- a/config/scripts/check-ts-nocheck-ratchet.mjs +++ b/config/scripts/check-ts-nocheck-ratchet.mjs @@ -1,8 +1,6 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' +import { readTrackedSources, runEntryRatchet } from './ratchet-baseline.mjs' // Ratchet gate for the `@ts-nocheck` directive. // @@ -60,171 +58,42 @@ export function hasTsNoCheck(sourceText) { return false } -export function parseBaseline(text) { - return new Set( - text - .split('\n') - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#')) - ) -} - -export function diffBaseline(current, baseline) { - const cur = new Set(current) - const base = baseline instanceof Set ? baseline : new Set(baseline) - const added = [...cur].filter((e) => !base.has(e)).sort() - const stale = [...base].filter((e) => !cur.has(e)).sort() - return { added, stale } -} - // Collect every currently tracked file that carries a `@ts-nocheck` header. export function collectCurrentTsNoCheckFiles(root = process.cwd()) { - const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts'], { - cwd: root, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024 - }) - .split('\n') - .filter(Boolean) - .filter((f) => !SELF_FILES.has(f)) - - const entries = [] - for (const rel of tracked) { - let src - try { - src = fs.readFileSync(path.join(root, rel), 'utf8') - } catch { - continue - } - if (hasTsNoCheck(src)) { - entries.push(rel) - } - } - - return entries.sort() -} - -function printAddedFailure(added) { - for (const entry of added) { - console.error(`::error::New @ts-nocheck not allowed: ${entry}`) - } - console.error('') - console.error('╭────────────────────────────────────────────────────────────────────────────╮') - console.error('│ ❌ ts-nocheck ratchet failed — a NEW file adds a @ts-nocheck directive. │') - console.error('╰────────────────────────────────────────────────────────────────────────────╯') - console.error('') - console.error(` ${added.length} file(s) newly add a \`@ts-nocheck\` header:`) - console.error('') - for (const entry of added) { - console.error(` • ${entry}`) - } - console.error('') - console.error(' `@ts-nocheck` disables ALL type checking for the whole file, not just one line.') - console.error( - ' The grandfathered entries exist only because the split runtime mixin chain cannot' + const sources = readTrackedSources( + root, + ['*.ts', '*.tsx', '*.mts', '*.cts'], + (rel) => !SELF_FILES.has(rel) ) - console.error(' express forward references yet — that is not a general license to suppress.') - console.error('') - console.error(' ✅ Fix it: fix the types instead of suppressing the whole file.') - console.error('') - console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable') - console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`) - console.error('') + return [...sources] + .filter(([, src]) => hasTsNoCheck(src)) + .map(([rel]) => rel) + .sort() } -function printStaleFailure(stale) { - for (const entry of stale) { - console.error(`::error::Stale ts-nocheck baseline entry (prune it): ${entry}`) - } - console.error('') - console.error('╭────────────────────────────────────────────────────────────────────────────╮') - console.error('│ ⚠️ ts-nocheck baseline is out of date — nice work removing a suppression! │') - console.error('╰────────────────────────────────────────────────────────────────────────────╯') - console.error('') - console.error(` ${stale.length} baseline entr(y/ies) no longer have a @ts-nocheck directive.`) - console.error( - ' The baseline may only shrink, so these must be removed to keep re-adding blocked:' - ) - console.error('') - for (const entry of stale) { - console.error(` • ${entry}`) - } - console.error('') - console.error(` ✅ Fix it (one command): pnpm check:ts-nocheck-ratchet --prune`) - console.error('') -} - -export function main(root = process.cwd()) { - const baselineFile = path.join(root, BASELINE_PATH) - if (!fs.existsSync(baselineFile)) { - console.error( - `::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-ts-nocheck-ratchet.mjs --init` - ) - return 1 - } - const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const current = collectCurrentTsNoCheckFiles(root) - const { added, stale } = diffBaseline(current, baseline) - - if (added.length > 0) { - printAddedFailure(added) - if (stale.length > 0) { - console.error( - ` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)` - ) - printStaleFailure(stale) - } - return 1 - } - if (stale.length > 0) { - printStaleFailure(stale) - return 1 - } - console.log( - `ts-nocheck ratchet OK — ${current.length} grandfathered file(s), no new suppressions.` - ) - return 0 -} - -function writeBaseline(root, entries) { - const header = [ +const RATCHET = { + name: 'ts-nocheck', + baselinePath: BASELINE_PATH, + header: [ '# Files currently allowed to carry a `@ts-nocheck` header.', '# This is a RATCHET: the list may only SHRINK. These exist only because the split', '# runtime mixin chain cannot express forward references yet — do NOT add entries to', '# get CI green; fix the types instead.', - '# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)', - '' - ].join('\n') - fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`) + '# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)' + ], + collect: collectCurrentTsNoCheckFiles, + fix: [ + '`@ts-nocheck` disables ALL type checking for the whole file, not just one line. The', + 'grandfathered entries exist only because the split runtime mixin chain cannot express', + 'forward references yet — that is not a general license to suppress.', + 'Fix it: fix the types instead of suppressing the whole file.' + ] +} + +export function main(root = process.cwd(), arg = undefined) { + return runEntryRatchet(RATCHET, root, arg) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const root = process.cwd() - const arg = process.argv[2] - if (arg === '--init') { - // One-time bootstrap: capture the current @ts-nocheck set as the baseline. - const entries = collectCurrentTsNoCheckFiles(root) - writeBaseline(root, entries) - console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`) - process.exit(0) - } - if (arg === '--prune') { - // Remove baseline entries whose @ts-nocheck is gone (shrink only; never adds). - const current = new Set(collectCurrentTsNoCheckFiles(root)) - const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8')) - const kept = [...baseline].filter((e) => current.has(e)).sort() - const newlyAdded = [...current].filter((e) => !baseline.has(e)) - writeBaseline(root, kept) - console.log( - `Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).` - ) - if (newlyAdded.length > 0) { - console.error( - `::error::--prune does not add entries; ${newlyAdded.length} new suppression(s) remain — fix those files' types.` - ) - process.exit(1) - } - process.exit(0) - } - process.exit(main(root)) + process.exit(main(process.cwd(), process.argv[2])) } diff --git a/config/scripts/check-ts-nocheck-ratchet.test.mjs b/config/scripts/check-ts-nocheck-ratchet.test.mjs index f886a6849d3..c34b235510b 100644 --- a/config/scripts/check-ts-nocheck-ratchet.test.mjs +++ b/config/scripts/check-ts-nocheck-ratchet.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { diffBaseline, hasTsNoCheck, parseBaseline } from './check-ts-nocheck-ratchet.mjs' +import { hasTsNoCheck } from './check-ts-nocheck-ratchet.mjs' describe('hasTsNoCheck', () => { it('detects a line-comment form', () => { @@ -43,27 +43,3 @@ describe('hasTsNoCheck', () => { expect(hasTsNoCheck('export function f() {\n return 42\n}\n')).toBe(false) }) }) - -describe('parseBaseline', () => { - it('drops comments and blank lines', () => { - const b = parseBaseline('# header\n\nsrc/a.ts\nsrc/b.ts\n') - expect(b).toEqual(new Set(['src/a.ts', 'src/b.ts'])) - }) -}) - -describe('diffBaseline', () => { - it('reports added and stale entries', () => { - const { added, stale } = diffBaseline( - ['src/b.ts', 'src/c.ts'], - new Set(['src/a.ts', 'src/b.ts']) - ) - expect(added).toEqual(['src/c.ts']) // new suppression - expect(stale).toEqual(['src/a.ts']) // suppression removed - }) - - it('is clean when current matches baseline', () => { - const { added, stale } = diffBaseline(['src/a.ts'], new Set(['src/a.ts'])) - expect(added).toEqual([]) - expect(stale).toEqual([]) - }) -}) diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 51942d7c7fd..daf33c7b7a4 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -5,67 +5,33 @@ export const NODE_NETWORK_E2E_SPEC = export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts' // Needs the packaged orcad slot, which only its own job builds. export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts' -// Needs the orcad template for its host's target, which only its own job builds. -export const ORCAD_AUTO_CONVERT_E2E_SPEC = 'tests/e2e/ssh-orcad-auto-convert.spec.ts' // Windows-only; its own job runs it on a Windows runner. export const WINDOWS_MISSING_APPDATA_E2E_SPEC = 'tests/e2e/windows-missing-appdata-startup.spec.ts' // Needs out/orcad, which only the mode-switch job builds; it runs there beside that spec. export const LAYOUT_ORACLE_HEADLESS_E2E_SPEC = 'tests/e2e/workspace-layout-oracle-headless.spec.ts' -// Runs in the auto-convert job, which builds the template it needs. -export const ORCAD_IDLE_EXIT_E2E_SPEC = 'tests/e2e/ssh-orcad-idle-exit.spec.ts' -export const ORCAD_BROWSER_CAPABILITIES_E2E_SPEC = - 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts' -export const ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC = - 'tests/e2e/ssh-orcad-browser-service-status.spec.ts' -export const ORCAD_BROWSER_ROUTING_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-routing.spec.ts' -export const ORCAD_EDITOR_OWNERSHIP_E2E_SPEC = 'tests/e2e/ssh-orcad-editor-ownership.spec.ts' -export const ORCAD_MARKDOWN_CONVERSION_E2E_SPEC = 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts' -export const ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC = - 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts' -export const ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC = - 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts' -export const ORCAD_BROWSER_DROP_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts' -export const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts' -export const ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC = - 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts' -export const ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC = - 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts' -export const ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC = - 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts' -export const ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-terminal-root-owner.spec.ts' -// Run in their own job, which builds the template they need. -export const ORCAD_JOURNEY_E2E_SPECS = [ - 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts', - 'tests/e2e/ssh-orcad-workspace-tools.spec.ts', - 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts' -] +// One Docker job runs every managed-SSH spec; it builds the orcad template they need. +export const MANAGED_SSH_E2E_SPEC_PREFIX = 'tests/e2e/ssh-orcad-' export const DEDICATED_E2E_SPECS = [ NODE_NETWORK_E2E_SPEC, NATIVE_IME_E2E_SPEC, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, LAYOUT_ORACLE_HEADLESS_E2E_SPEC, - ORCAD_AUTO_CONVERT_E2E_SPEC, - WINDOWS_MISSING_APPDATA_E2E_SPEC, - ORCAD_IDLE_EXIT_E2E_SPEC, - ORCAD_BROWSER_CAPABILITIES_E2E_SPEC, - ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC, - ORCAD_BROWSER_ROUTING_E2E_SPEC, - ORCAD_EDITOR_OWNERSHIP_E2E_SPEC, - ORCAD_MARKDOWN_CONVERSION_E2E_SPEC, - ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC, - ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC, - ORCAD_OPEN_IN_OWNER_E2E_SPEC, - ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC, - ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC, - ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC, - ORCAD_BROWSER_DROP_OWNER_E2E_SPEC, - ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC, - ...ORCAD_JOURNEY_E2E_SPECS + WINDOWS_MISSING_APPDATA_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) +// Their jobs build their own app or none, so they never need the shared e2e build. +const OWN_BUILD_E2E_SPECS = new Set([ + NODE_NETWORK_E2E_SPEC, + NATIVE_IME_E2E_SPEC, + WINDOWS_MISSING_APPDATA_E2E_SPEC +]) + +export function isDedicatedE2eSpec(spec) { + return dedicatedSpecs.has(spec) || spec.startsWith(MANAGED_SSH_E2E_SPEC_PREFIX) +} export function selectGeneralE2eSpecs(specs) { - return specs.filter((spec) => !dedicatedSpecs.has(spec)) + return specs.filter((spec) => !isDedicatedE2eSpec(spec)) } function parseSpecs(input) { @@ -88,32 +54,9 @@ export function classifyE2eJobs(input) { if (specs.length === 0) { return conservative } - const runChanged = selectGeneralE2eSpecs(specs).length > 0 return { - e2e_run_changed: runChanged, - e2e_needs_build: - runChanged || - specs.some( - (spec) => - spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC || - spec === LAYOUT_ORACLE_HEADLESS_E2E_SPEC || - spec === ORCAD_AUTO_CONVERT_E2E_SPEC || - spec === ORCAD_IDLE_EXIT_E2E_SPEC || - spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC || - spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC || - spec === ORCAD_BROWSER_ROUTING_E2E_SPEC || - spec === ORCAD_EDITOR_OWNERSHIP_E2E_SPEC || - spec === ORCAD_MARKDOWN_CONVERSION_E2E_SPEC || - spec === ORCAD_MARKDOWN_LINK_REFRESH_E2E_SPEC || - spec === ORCAD_MARKDOWN_LIVE_DOCUMENTS_E2E_SPEC || - spec === ORCAD_OPEN_IN_OWNER_E2E_SPEC || - spec === ORCAD_EDITOR_WATCH_RECOVERY_E2E_SPEC || - spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC || - spec === ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC || - spec === ORCAD_BROWSER_DROP_OWNER_E2E_SPEC || - spec === ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC || - ORCAD_JOURNEY_E2E_SPECS.includes(spec) - ) + e2e_run_changed: selectGeneralE2eSpecs(specs).length > 0, + e2e_needs_build: specs.some((spec) => !OWN_BUILD_E2E_SPECS.has(spec)) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 3d293c66c8a..29d7b4c0a7a 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -5,6 +5,7 @@ import { runProcess } from '@orca/process-host' import { classifyE2eJobs, DEDICATED_E2E_SPECS, + MANAGED_SSH_E2E_SPEC_PREFIX, NATIVE_IME_E2E_SPEC, NODE_NETWORK_E2E_SPEC, selectGeneralE2eSpecs @@ -45,14 +46,23 @@ const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs) => classifyE2eJobs(JSON.stringify(specs)) +function expectManagedSshLane(spec) { + expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) + expect(spec.startsWith(MANAGED_SSH_E2E_SPEC_PREFIX)).toBe(true) + const lane = workflow.jobs['orcad-ssh-docker'] + expect(lane.if).toContain(`contains(inputs.test_files, '${MANAGED_SSH_E2E_SPEC_PREFIX}')`) + expect( + lane.steps.some( + (step) => + step.run?.includes(` ${MANAGED_SSH_E2E_SPEC_PREFIX} `) && + step.env?.ORCA_E2E_SSH_DOCKER === '1' + ) + ).toBe(true) +} + it('runs real browser file-drop ownership in the template-building Docker job', () => { const spec = 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts' - expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) - const job = workflow.jobs['orcad-auto-convert-docker'] - expect(job.if).toContain(spec) - expect( - job.steps.some((step) => step.run?.includes(spec) && step.env?.ORCA_E2E_SSH_DOCKER === '1') - ).toBe(true) + expectManagedSshLane(spec) for (const source of [ 'src/renderer/src/lib/file-preview.ts', 'src/renderer/src/lib/workspace-file-drag.ts', @@ -135,6 +145,7 @@ it('applies allocation hints only to PRs and retains other callers and full refe expect(command).not.toContain('mapfile -t TEST_FILES < <(') const fallback = [...command.matchAll(/\. != "([^"]+)"/g)].map((match) => match[1]) expect(fallback).toEqual(DEDICATED_E2E_SPECS) + expect(command).toContain(`(startswith("${MANAGED_SSH_E2E_SPEC_PREFIX}") | not)`) }) it('publishes conservative hints for malformed evidence and refuses a malformed consumer list', async () => { @@ -182,15 +193,7 @@ it.each([ ])('routes %s to the template-building editor ownership lane', (file) => { const spec = 'tests/e2e/ssh-orcad-editor-ownership.spec.ts' expect(selectPrE2eSpecs([file])).toContain(spec) - expect(classify([spec])).toEqual({ - e2e_run_changed: false, - e2e_needs_build: true - }) - const job = workflow.jobs['orcad-auto-convert-docker'] - expect(job.if).toContain(spec) - expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( - spec - ) + expectManagedSshLane(spec) }) const MARKDOWN_CONVERSION_SPEC = 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts' @@ -223,15 +226,7 @@ it.each([ ] ])('routes %s to the template-building Markdown lane %s', (file, spec) => { expect(selectPrE2eSpecs([file])).toContain(spec) - expect(classify([spec])).toEqual({ - e2e_run_changed: false, - e2e_needs_build: true - }) - const job = workflow.jobs['orcad-auto-convert-docker'] - expect(job.if).toContain(spec) - expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( - spec - ) + expectManagedSshLane(spec) }) it.each([ @@ -240,12 +235,7 @@ it.each([ ])('routes %s to the template-building editor watch recovery lane', (file) => { const spec = 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts' expect(selectPrE2eSpecs([file])).toContain(spec) - expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) - const job = workflow.jobs['orcad-auto-convert-docker'] - expect(job.if).toContain(spec) - expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( - spec - ) + expectManagedSshLane(spec) }) it.each([ @@ -254,12 +244,7 @@ it.each([ ])('routes %s to the template-building explorer watch recovery lane', (file) => { const spec = 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts' expect(selectPrE2eSpecs([file])).toContain(spec) - expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) - const job = workflow.jobs['orcad-auto-convert-docker'] - expect(job.if).toContain(spec) - expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( - spec - ) + expectManagedSshLane(spec) }) it.each([ @@ -272,22 +257,12 @@ it.each([ ])('routes %s to the template-building selected-host explorer lane', (file) => { const spec = 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts' expect(selectPrE2eSpecs([file])).toContain(spec) - expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) - const job = workflow.jobs['orcad-auto-convert-docker'] - expect(job.if).toContain(spec) - expect(job.steps.find((step) => step.name === 'Convert a relay-era Docker host').run).toContain( - spec - ) + expectManagedSshLane(spec) }) it('runs managed terminal root ownership in the Docker conversion lane', () => { const spec = 'tests/e2e/ssh-orcad-terminal-root-owner.spec.ts' - expect(classify([spec])).toEqual({ e2e_run_changed: false, e2e_needs_build: true }) - const lane = workflow.jobs['orcad-auto-convert-docker'] - expect(lane.if).toContain(spec) - expect( - lane.steps.some((step) => step.run?.includes(spec) && step.env?.ORCA_E2E_SSH_DOCKER === '1') - ).toBe(true) + expectManagedSshLane(spec) for (const source of [ 'src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts', 'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts', diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index a40748fb10c..b2b86ec33d0 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -8583,7 +8583,6 @@ "src/renderer/src/components/terminal-pane/remote-layout-scrollback-retention.test.ts": 64, "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 71, "src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts": 988, - "src/renderer/src/components/terminal-pane/remote-reattach-input-buffer.test.ts": 84, "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1519, "src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts": 2324, "src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace.test.ts": 43, diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index a84923e762d..e2322bc85ba 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -1,27 +1,16 @@ import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' -import { constants } from 'node:fs' -import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' -import { isDeepStrictEqual } from 'node:util' -// Version artifacts independently to preserve the registry's schema-gated append-only guard. -const CURRENT_MANIFEST_SCHEMA_VERSION = 2 -const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1 -const RELEASE_MAPPING_SCHEMA_VERSION = 1 +// Why: schema 3 dropped release revisions and git tree identity along with the snapshot +// registry. Freshness compares installed copies against these bytes only. +const CURRENT_MANIFEST_SCHEMA_VERSION = 3 const SCRIPT_DIR = import.meta.dirname const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills') const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json') -const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json') -const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json') -// Why: the manifest and registry are content-addressed — they describe skill -// bytes. The mapping is provenance: which already-committed revision a tag -// shipped. A release cut may append the second without regenerating the first. -const CONTENT_ADDRESSED_PATHS = [CURRENT_MANIFEST_PATH, SNAPSHOT_REGISTRY_PATH] -const ALL_ARTIFACT_PATHS = [...CONTENT_ADDRESSED_PATHS, RELEASE_MAPPING_PATH] function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex') @@ -31,10 +20,6 @@ function compareCodeUnits(left, right) { return left === right ? 0 : left < right ? -1 : 1 } -function gitObjectSha(kind, bytes) { - return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() -} - function normalizeText(bytes) { const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') @@ -74,56 +59,10 @@ function describeFile(manifestPath, bytes, executable) { classification: normalized === null ? 'binary' : 'text', exactSha256, textNormalizedSha256, - identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256, - gitBlobSha: gitObjectSha('blob', bytes).toString('hex') + identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256 } } -function gitTreeSha(entries) { - const root = { directories: new Map(), files: [] } - for (const entry of entries) { - const parts = entry.path.split('/') - const filename = parts.pop() - let directory = root - for (const part of parts) { - let child = directory.directories.get(part) - if (!child) { - child = { directories: new Map(), files: [] } - directory.directories.set(part, child) - } - directory = child - } - directory.files.push({ filename, ...entry }) - } - - function hashDirectory(directory) { - const children = [ - ...[...directory.directories].map(([name, child]) => ({ - mode: '40000', - name, - hash: hashDirectory(child) - })), - ...directory.files.map((file) => ({ - mode: file.executable ? '100755' : '100644', - name: file.filename, - hash: Buffer.from(file.gitBlobSha, 'hex') - })) - ].sort((left, right) => { - const leftName = left.mode === '40000' ? `${left.name}/` : left.name - const rightName = right.mode === '40000' ? `${right.name}/` : right.name - return Buffer.from(leftName).compare(Buffer.from(rightName)) - }) - const body = Buffer.concat( - children.map(({ mode, name, hash }) => - Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) - ) - ) - return gitObjectSha('tree', body) - } - - return hashDirectory(root).toString('hex') -} - // Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts. // The scanner ignores these because the OS writes them into a live install; the generator // ignores them so a stray one in a working tree cannot be committed into the manifest as @@ -175,7 +114,7 @@ async function collectPackageFiles(packageRoot) { throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`) } // Why: Windows observation cannot see execute bits, so an executable file in - // a shipped skill would misclassify every pristine Windows install as unrecognized. + // a shipped skill would misclassify every pristine Windows install as outdated. if ((fileStat.mode & 0o111) !== 0) { throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`) } @@ -187,92 +126,8 @@ async function collectPackageFiles(packageRoot) { return sortManifestFiles(files) } -function collectGitSkillTreeEntries(treeSha) { - const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha]) - .toString('utf8') - .split('\0') - .filter(Boolean) - const packages = new Map() - for (const line of output) { - const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line) - if (!match) { - throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`) - } - const [, mode, type, objectSha, sourcePath] = match - const separator = sourcePath.indexOf('/') - if (separator <= 0 || separator === sourcePath.length - 1) { - throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`) - } - const name = sourcePath.slice(0, separator) - const manifestPath = sourcePath.slice(separator + 1) - const entries = packages.get(name) ?? [] - entries.push({ mode, type, objectSha, manifestPath }) - packages.set(name, entries) - } - return packages -} - -function readGitBlobs(objectShas) { - const uniqueShas = [...new Set(objectShas)] - if (uniqueShas.length === 0) { - return new Map() - } - // Why: released history spans hundreds of tags. Batch mode avoids a Git - // subprocess per historical file while remaining available on Git 2.25. - const output = execFileSync('git', ['cat-file', '--batch'], { - input: `${uniqueShas.join('\n')}\n`, - maxBuffer: 64 * 1024 * 1024 - }) - const blobs = new Map() - let offset = 0 - for (const requestedSha of uniqueShas) { - const headerEnd = output.indexOf(10, offset) - if (headerEnd === -1) { - throw new Error(`Missing git cat-file header for ${requestedSha}`) - } - const header = output.subarray(offset, headerEnd).toString('utf8') - const match = /^([a-f0-9]+) blob (\d+)$/.exec(header) - if (!match || match[1] !== requestedSha) { - throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`) - } - const size = Number(match[2]) - const contentStart = headerEnd + 1 - const contentEnd = contentStart + size - if (contentEnd >= output.length || output[contentEnd] !== 10) { - throw new Error(`Truncated git blob for ${requestedSha}`) - } - blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd))) - offset = contentEnd + 1 - } - return blobs -} - -function collectGitPackageFiles(treeSha, name, entries, blobs) { - const caseFoldedPaths = new Map() - const files = entries.map(({ mode, type, objectSha, manifestPath }) => { - if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { - throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`) - } - assertSafeRelativePath(manifestPath) - const foldedPath = manifestPath.toLocaleLowerCase('en-US') - const collision = caseFoldedPaths.get(foldedPath) - if (collision && collision !== manifestPath) { - throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`) - } - caseFoldedPaths.set(foldedPath, manifestPath) - const bytes = blobs.get(objectSha) - if (!bytes) { - throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`) - } - return describeFile(manifestPath, bytes, mode === '100755') - }) - // Why: git ls-tree emits git byte-order, not the canonical walk order. - return sortManifestFiles(files) -} - -// Why: snapshot matching compares files by array index, so every producer — -// working-tree walk, git history, and runtime observation — must emit one -// canonical order. This mirrors the sorted depth-first filesystem walk. +// Why: the package digest hashes files in array order, so the generator and runtime +// observation must emit one canonical order: the sorted depth-first filesystem walk. function compareManifestPaths(left, right) { const leftParts = left.split('/') const rightParts = right.split('/') @@ -306,380 +161,44 @@ function packageDigest(files) { ) } -function releaseTags() { - return execFileSync( - 'git', - ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'], - { encoding: 'utf8' } - ) - .split('\n') - .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag)) -} - -function skillsTreeShasAtRefs(refs) { - if (refs.length === 0) { - return [] - } - const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { - input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`, - encoding: 'utf8' - }) - const lines = output.trimEnd().split('\n') - if (lines.length !== refs.length) { - throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`) - } - return lines.map((line, index) => { - if (line.endsWith(' missing')) { - return null - } - const match = /^([a-f0-9]+) tree$/.exec(line) - if (!match) { - throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`) - } - return match[1] - }) -} - -function buildReleasedHistory() { - const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} } - const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] } - const tags = releaseTags() - const treeShas = skillsTreeShasAtRefs(tags) - const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] - const packagesByTree = new Map( - distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)]) - ) - const blobs = readGitBlobs( - [...packagesByTree.values()].flatMap((packages) => - [...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha)) - ) - ) - let previousSkillsTreeSha = null - for (const [index, tag] of tags.entries()) { - const skillsTreeSha = treeShas[index] - if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) { - continue - } - previousSkillsTreeSha = skillsTreeSha - const revisions = {} - const packages = packagesByTree.get(skillsTreeSha) - if (!packages) { - throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`) - } - for (const name of [...packages.keys()].sort(compareCodeUnits)) { - const entries = packages.get(name) - const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs) - if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { - continue - } - const digest = packageDigest(filesWithGitHashes) - const snapshots = registry.skills[name] ?? [] - const latest = snapshots.at(-1) - if (!latest || latest.packageDigest !== digest) { - const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) - snapshots.push({ - releaseRevision: (latest?.releaseRevision ?? 0) + 1, - packageDigest: digest, - gitTreeSha: gitTreeSha(filesWithGitHashes), - files - }) - registry.skills[name] = snapshots - } - revisions[name] = snapshots.at(-1).releaseRevision - } - if (Object.keys(revisions).length > 0) { - mapping.releases.push({ appVersion: tag.slice(1), skills: revisions }) - } - } - return { registry, mapping } -} - -// Why: released history is authoritative committed data, advanced only at -// release cut. Seeding generation from the committed registry + mapping makes -// ordinary verify/regeneration a pure function of working-tree bytes, so it -// never walks git tags — the root cause of recurring lint drift when a clone -// holds stray, deleted, or fork tags the committed artifacts predate. -function releasedHistoryFromCommitted(committedRegistry, committedMapping) { - const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} } - const releasedSnapshotCounts = {} - const mapping = - committedMapping && committedMapping.schemaVersion === RELEASE_MAPPING_SCHEMA_VERSION - ? structuredClone(committedMapping) - : { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] } - if (committedRegistry && committedRegistry.schemaVersion === SNAPSHOT_REGISTRY_SCHEMA_VERSION) { - const mappedCounts = releasedSnapshotCountsFromMapping(mapping) - for (const [name, snapshots] of Object.entries(committedRegistry.skills ?? {})) { - // The committed registry carries at most one unreleased tail beyond the - // revisions named by the mapping; drop it and recompute it from bytes. - const releasedCount = mappedCounts?.[name] ?? Math.max(0, snapshots.length - 1) - registry.skills[name] = snapshots.slice(0, releasedCount) - releasedSnapshotCounts[name] = releasedCount - } - } - return { registry, mapping, releasedSnapshotCounts } -} - -// Why: disaster recovery only. Reconstruct released history from the immutable -// release tags when the committed ledger must be rebuilt from scratch. Kept off -// the verify/regenerate path — walking tags there is what coupled lint to the -// executing clone's tag state and broke it on version bumps, new tags, and -// stray/deleted local tags. -function releasedHistoryFromTags() { - const { registry, mapping } = buildReleasedHistory() - const releasedSnapshotCounts = Object.fromEntries( - Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) - ) - return { registry, mapping, releasedSnapshotCounts } -} - -// Why: release cut is the single authoritative point where working-tree bytes -// become an immutable released revision. Append one mapping row for the version, -// mirroring the historical dedupe where consecutive identical skill trees share -// the earliest release's row. -function appendReleaseRow(artifacts, version) { - const appVersion = version.startsWith('v') ? version.slice(1) : version - const currentRevisions = {} - for (const skill of artifacts.currentManifest.skills) { - currentRevisions[skill.name] = skill.releaseRevision - } - const releases = artifacts.releaseMapping.releases - const last = releases.at(-1) - if (last && isDeepStrictEqual(last.skills, currentRevisions)) { - return - } - // Why: a cut that pushed the version bump to main but died before pushing the - // tag is re-cut at the same version. If skills changed in between, appending - // would leave two rows claiming this version and the stale one would name - // revisions that tag never ships — overwrite, since the tag ships these bytes. - if (last?.appVersion === appVersion) { - releases[releases.length - 1] = { appVersion, skills: currentRevisions } - return - } - // Why: an earlier row means re-cutting an already-shipped version, which the - // cut workflow refuses upstream. Fail rather than corrupt shipped provenance. - if (releases.some((release) => release.appVersion === appVersion)) { - throw new Error(`Release mapping already has a row for ${appVersion}.`) - } - releases.push({ appVersion, skills: currentRevisions }) -} - -async function buildArtifacts(releasedHistory) { - const { registry, mapping, releasedSnapshotCounts } = releasedHistory +async function buildCurrentManifest() { const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(compareCodeUnits) - const currentSkills = [] + const skills = [] for (const name of skillDirectories) { - const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name)) - if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + const files = await collectPackageFiles(path.join(SKILLS_ROOT, name)) + if (!files.some((file) => file.path === 'SKILL.md')) { throw new Error(`Skill package ${name} has no top-level SKILL.md`) } - const digest = packageDigest(filesWithGitHashes) - const snapshots = registry.skills[name] ?? [] - const latest = snapshots.at(-1) - let snapshot = latest - if (!latest || latest.packageDigest !== digest) { - const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) - snapshot = { - releaseRevision: (latest?.releaseRevision ?? 0) + 1, - packageDigest: digest, - gitTreeSha: gitTreeSha(filesWithGitHashes), - files - } - snapshots.push(snapshot) - registry.skills[name] = snapshots - } - currentSkills.push({ - name, - sourcePath: `skills/${name}`, - ...snapshot - }) - } - return { - currentManifest: { - schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, - skills: currentSkills - }, - snapshotRegistry: registry, - releaseMapping: mapping, - releasedSnapshotCounts - } -} - -// Why: released snapshots are the detection ground truth for existing installs, -// so a generation-logic change must not rewrite them silently. Only the one -// unreleased working-tree append per skill may change between runs. -function releasedSnapshotCountsFromMapping(releaseMapping) { - if (!releaseMapping || releaseMapping.schemaVersion !== RELEASE_MAPPING_SCHEMA_VERSION) { - return null - } - const counts = {} - for (const release of releaseMapping.releases ?? []) { - for (const [name, revision] of Object.entries(release.skills ?? {})) { - counts[name] = Math.max(counts[name] ?? 0, revision) - } - } - return counts -} - -function assertReleasedHistoryPreserved(committedRegistry, artifacts, committedReleaseMapping) { - if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) { - return - } - const committedReleasedCounts = releasedSnapshotCountsFromMapping(committedReleaseMapping) - for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { - const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 - const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] - const minimumReleasedCount = - committedReleasedCounts?.[name] ?? Math.max(0, committedSnapshots.length - 1) - if (releasedCount < minimumReleasedCount) { - throw new Error( - `Released snapshot history is incomplete for ${name}. ` + - 'Fetch all release tags before regenerating skill artifacts.' - ) - } - const protectedCount = committedReleasedCounts - ? (committedReleasedCounts[name] ?? 0) - : Math.min(committedSnapshots.length, releasedCount) - for (let index = 0; index < protectedCount; index += 1) { - const committed = committedSnapshots[index] - const rebuilt = regenerated[index] - if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { - throw new Error( - `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + - 'Released snapshots are append-only; a deliberate identity migration must update this check.' - ) - } - } - } -} - -async function readCommittedRegistry() { - try { - return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8')) - } catch { - return null - } -} - -async function readCommittedReleaseMapping() { - try { - return JSON.parse(await readFile(RELEASE_MAPPING_PATH, 'utf8')) - } catch { - return null + skills.push({ name, sourcePath: `skills/${name}`, packageDigest: packageDigest(files), files }) } + return { schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, skills } } function serialized(value) { return `${JSON.stringify(value, null, 2)}\n` } -async function writeArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) { - const values = new Map([ - [CURRENT_MANIFEST_PATH, artifacts.currentManifest], - [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], - [RELEASE_MAPPING_PATH, artifacts.releaseMapping] - ]) - await mkdir(OUTPUT_ROOT, { recursive: true }) - await Promise.all(paths.map((filePath) => writeFile(filePath, serialized(values.get(filePath))))) -} - -// Why: cutting a release tag adds a trailing mapping row on every checkout at -// once, before any branch can regenerate. A trailing row whose revisions all -// equal the current manifest is provably redundant until the next real -// regeneration (installs of those bytes classify as current and are labeled by -// the running build), so verify must not fail branches over it. -function isToleratedReleaseMappingPrefix(committedText, artifacts) { - let committed - try { - committed = JSON.parse(committedText) - } catch { - return false - } - const derived = artifacts.releaseMapping - const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1 - if (committedCount < 0 || committedCount >= derived.releases.length) { - return false - } - const prefix = { - schemaVersion: derived.schemaVersion, - releases: derived.releases.slice(0, committedCount) - } - if (committedText !== serialized(prefix)) { - return false - } - const currentRevisions = Object.fromEntries( - artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision]) - ) - return derived.releases - .slice(committedCount) - .every((release) => isDeepStrictEqual(release.skills, currentRevisions)) -} - -async function verifyArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) { - const expected = [ - [CURRENT_MANIFEST_PATH, artifacts.currentManifest, null], - [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null], - [RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix] - ].filter(([filePath]) => paths.includes(filePath)) - const stale = [] - for (const [filePath, value, tolerated] of expected) { - try { - await access(filePath, constants.R_OK) - const committedText = await readFile(filePath, 'utf8') - if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) { - stale.push(filePath) - } - } catch { - stale.push(filePath) - } - } - if (stale.length > 0) { +async function verifyCurrentManifest(manifest) { + const committedText = await readFile(CURRENT_MANIFEST_PATH, 'utf8').catch(() => null) + if (committedText !== serialized(manifest)) { throw new Error( - `Generated skill artifacts are stale:\n${stale - .map((filePath) => path.relative(REPO_ROOT, filePath)) - .join('\n')}\nRun pnpm generate:skill-bundle-manifest.` + `Generated skill manifest is stale:\n${path.relative(REPO_ROOT, CURRENT_MANIFEST_PATH)}\n` + + 'Run pnpm generate:skill-bundle-manifest.' ) } } async function main() { - const argv = process.argv.slice(2) - const rebuildFromTags = argv.includes('--rebuild-from-tags') - const releaseIndex = argv.indexOf('--release') - const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null - if (releaseIndex !== -1 && !releaseVersion) { - throw new Error('--release requires a version argument, e.g. --release 1.4.160') + const manifest = await buildCurrentManifest() + if (process.argv.slice(2).includes('--write')) { + await mkdir(OUTPUT_ROOT, { recursive: true }) + await writeFile(CURRENT_MANIFEST_PATH, serialized(manifest)) + return } - - const committedRegistry = await readCommittedRegistry() - const committedMapping = await readCommittedReleaseMapping() - const releasedHistory = rebuildFromTags - ? releasedHistoryFromTags() - : releasedHistoryFromCommitted(committedRegistry, committedMapping) - const artifacts = await buildArtifacts(releasedHistory) - - if (releaseVersion) { - // Why: the row names revisions the committed registry must already contain, - // so proving those artifacts match this ref is what lets the cut record - // provenance without regenerating them. If regeneration disagrees with what - // is committed, the row would name a revision this tag does not ship. - await verifyArtifacts(artifacts, CONTENT_ADDRESSED_PATHS) - appendReleaseRow(artifacts, releaseVersion) - } - - // Why: released snapshots are append-only. The committed registry/mapping are - // read fresh here so the artifacts (which may have appended a release row) can - // never alias what we validate against. This mapping must stay the PRE-append - // one to match artifacts.releasedSnapshotCounts, which seeding fixed before the - // row existed; the post-append mapping names one more revision than the counts - // do, which reads as incomplete history and would throw on every cut. - assertReleasedHistoryPreserved(committedRegistry, artifacts, committedMapping) - - const shouldWrite = releaseVersion !== null || argv.includes('--write') - const writePaths = releaseVersion ? [RELEASE_MAPPING_PATH] : ALL_ARTIFACT_PATHS - await (shouldWrite ? writeArtifacts(artifacts, writePaths) : verifyArtifacts(artifacts)) + await verifyCurrentManifest(manifest) } if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { @@ -690,19 +209,11 @@ if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { } export { - appendReleaseRow, - assertReleasedHistoryPreserved, - buildArtifacts, - buildReleasedHistory, + buildCurrentManifest, classifyFile, collectPackageFiles, describeFile, - gitTreeSha, - isToleratedReleaseMappingPrefix, normalizeText, packageDigest, - releasedHistoryFromCommitted, - sortManifestFiles, - verifyArtifacts, - writeArtifacts + sortManifestFiles } diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs index ec6d6c17db6..d0b534334e3 100644 --- a/config/scripts/generate-skill-bundle-manifest.test.mjs +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -2,7 +2,6 @@ import { execFileSync } from 'node:child_process' import { chmod, copyFile, - cp, mkdir, mkdtemp, readFile, @@ -17,16 +16,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' import { observeSkillPackage } from '../../src/main/skills/skill-package-identity' import { - appendReleaseRow, - assertReleasedHistoryPreserved, classifyFile, collectPackageFiles, describeFile, - gitTreeSha, - isToleratedReleaseMappingPrefix, normalizeText, packageDigest, - releasedHistoryFromCommitted, sortManifestFiles } from './generate-skill-bundle-manifest.mjs' @@ -40,9 +34,9 @@ async function createPackage() { } // Why: the generator resolves its repo root from its own location, so a copy of -// the script inside a throwaway tree exercises the real CLI — including which -// artifacts each mode is allowed to write — without touching resources/skills. -async function createReleaseSandbox() { +// the script inside a throwaway tree exercises the real CLI without touching +// resources/skills. +async function createGeneratorSandbox() { // Node resolves the entry point through symlinks, so the script's own // repo-root check only matches when the sandbox path is already resolved. const root = await realpath(await createPackage()) @@ -90,7 +84,7 @@ describe('skill bundle manifest generator', () => { expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/) }) - it('orders git-history files identically to the filesystem walk', async () => { + it('orders files identically however they were listed', async () => { const packageRoot = await createPackage() await mkdir(path.join(packageRoot, 'sub')) for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) { @@ -98,8 +92,8 @@ describe('skill bundle manifest generator', () => { } const walked = await collectPackageFiles(packageRoot) - // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based - // snapshot matching requires history and observation to share one order. + // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; the digest + // hashes files in array order, so every producer must share one order. const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) => walked.find((file) => file.path === manifestPath) ) @@ -114,295 +108,18 @@ describe('skill bundle manifest generator', () => { ]) }) - it('rejects rewrites of released snapshots and allows floating-tail replacement', () => { - const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) - const artifacts = { - releasedSnapshotCounts: { 'orca-cli': 2 }, - snapshotRegistry: { - schemaVersion: 1, - skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] } - } - } - - expect(() => - assertReleasedHistoryPreserved( - { schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } }, - artifacts - ) - ).not.toThrow() - expect(() => - assertReleasedHistoryPreserved( - { - schemaVersion: 1, - skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] } - }, - artifacts - ) - ).not.toThrow() - expect(() => - assertReleasedHistoryPreserved( - { - schemaVersion: 1, - skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] } - }, - artifacts - ) - ).toThrow('Released snapshot history changed for orca-cli at revision 2') - expect(() => - assertReleasedHistoryPreserved( - { - schemaVersion: 1, - skills: { - 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] - } - }, - artifacts - ) - ).toThrow('Released snapshot history changed for orca-cli at revision 2') - expect(() => - assertReleasedHistoryPreserved( - { - schemaVersion: 1, - skills: { - 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] - } - }, - { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } - ) - ).toThrow('Released snapshot history is incomplete for orca-cli') - expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() - }) - - it('protects only revisions named by the committed release mapping', () => { - const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) - const committedRegistry = { - schemaVersion: 1, - skills: { - 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'unreleased-tail')] - } - } - const artifacts = { - releasedSnapshotCounts: { 'linear-tickets': 2 }, - snapshotRegistry: { - schemaVersion: 1, - skills: { 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'new-release')] } - } - } - - expect(() => - assertReleasedHistoryPreserved(committedRegistry, artifacts, { - schemaVersion: 1, - releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 1 } }] - }) - ).not.toThrow() - expect(() => - assertReleasedHistoryPreserved(committedRegistry, artifacts, { - schemaVersion: 1, - releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 2 } }] - }) - ).toThrow('Released snapshot history changed for linear-tickets at revision 2') - }) - - it('tolerates only redundant trailing release-mapping rows', () => { - const serialized = (value) => `${JSON.stringify(value, null, 2)}\n` - const rows = [ - { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, - { appVersion: '1.1.0', skills: { 'orca-cli': 2 } } - ] - const artifacts = { - currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] }, - releaseMapping: { schemaVersion: 1, releases: rows } - } - const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] }) - - // A just-cut tag whose bytes equal the working tree may lag in the mapping. - expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true) - // The committed file matching the derived mapping is byte-equality's job, not tolerance. - expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe( - false - ) - // A trailing row for bytes the committed artifacts do not describe is a real gap. - expect( - isToleratedReleaseMappingPrefix(committedPrefix, { - ...artifacts, - currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] } - }) - ).toBe(false) - expect( - isToleratedReleaseMappingPrefix(committedPrefix, { - ...artifacts, - currentManifest: { - skills: [ - { name: 'orca-cli', releaseRevision: 2 }, - { name: 'orca-linear', releaseRevision: 1 } - ] - } - }) - ).toBe(false) - // Rewritten earlier rows never pass, with or without trailing rows. - expect( - isToleratedReleaseMappingPrefix( - serialized({ - schemaVersion: 1, - releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }] - }), - artifacts - ) - ).toBe(false) - expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false) - expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false) - }) - - it('seeds released history from the committed ledger and drops the floating tail', () => { - const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) - const committedRegistry = { - schemaVersion: 1, - skills: { - // released revs 1..2 named by the mapping, plus an unreleased tail at 3 - 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'unreleased')], - // no mapping row -> fall back to all-but-tail - 'orca-linear': [snapshot(1, 'ccc'), snapshot(2, 'tail')] - } - } - const committedMapping = { - schemaVersion: 1, - releases: [{ appVersion: '1.0.0', skills: { 'orca-cli': 2 } }] - } - - const seeded = releasedHistoryFromCommitted(committedRegistry, committedMapping) - - // The unreleased tail is dropped; only mapping-named revisions survive. - expect(seeded.registry.skills['orca-cli']).toEqual([snapshot(1, 'aaa'), snapshot(2, 'bbb')]) - expect(seeded.registry.skills['orca-linear']).toEqual([snapshot(1, 'ccc')]) - expect(seeded.releasedSnapshotCounts).toEqual({ 'orca-cli': 2, 'orca-linear': 1 }) - // The seed clones the mapping so a later release append cannot alias committed state. - expect(seeded.mapping).toEqual(committedMapping) - expect(seeded.mapping).not.toBe(committedMapping) - }) - - it('returns an empty ledger when no committed artifacts exist', () => { - const seeded = releasedHistoryFromCommitted(null, null) - expect(seeded.registry.skills).toEqual({}) - expect(seeded.releasedSnapshotCounts).toEqual({}) - expect(seeded.mapping.releases).toEqual([]) - }) - - it('appends one release row, stripping the v-prefix and deduping identical tails', () => { - const artifacts = { - currentManifest: { - skills: [ - { name: 'orca-cli', releaseRevision: 36 }, - { name: 'orca-linear', releaseRevision: 8 } - ] - }, - releaseMapping: { - schemaVersion: 1, - releases: [{ appVersion: '1.4.151', skills: { 'orca-cli': 35, 'orca-linear': 8 } }] - } - } - - appendReleaseRow(artifacts, 'v1.4.160') - expect(artifacts.releaseMapping.releases.at(-1)).toEqual({ - appVersion: '1.4.160', - skills: { 'orca-cli': 36, 'orca-linear': 8 } - }) - - // A second release over identical revisions adds no row. - appendReleaseRow(artifacts, '1.4.161') - expect(artifacts.releaseMapping.releases).toHaveLength(2) - }) - - it('overwrites the trailing row when a failed cut is re-cut at the same version', () => { - const artifacts = { - currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] }, - releaseMapping: { - schemaVersion: 1, - releases: [ - { appVersion: '1.4.151', skills: { 'orca-cli': 35 } }, - // The failed cut already pushed this row to main at revision 36. - { appVersion: '1.4.160', skills: { 'orca-cli': 36 } } - ] - } - } - - appendReleaseRow(artifacts, '1.4.160') - - // One row per version: the tag ships revision 37, so 36 must not linger. - expect(artifacts.releaseMapping.releases).toEqual([ - { appVersion: '1.4.151', skills: { 'orca-cli': 35 } }, - { appVersion: '1.4.160', skills: { 'orca-cli': 37 } } + it('writes a schema-3 manifest and fails verification once a skill changes', async () => { + const sandbox = await createGeneratorSandbox() + sandbox.generate('--write') + const manifest = JSON.parse(await sandbox.read('current-manifest.json')) + expect(manifest.schemaVersion).toBe(3) + expect(manifest.skills.map((skill) => Object.keys(skill))).toEqual([ + ['name', 'sourcePath', 'packageDigest', 'files'] ]) - }) + expect(() => sandbox.generate()).not.toThrow() - it('refuses to rewrite an already-shipped version behind the trailing row', () => { - const artifacts = { - currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] }, - releaseMapping: { - schemaVersion: 1, - releases: [ - { appVersion: '1.4.151', skills: { 'orca-cli': 35 } }, - { appVersion: '1.4.160', skills: { 'orca-cli': 36 } } - ] - } - } - - expect(() => appendReleaseRow(artifacts, '1.4.151')).toThrow(/already has a row for 1\.4\.151/) - }) - - it('records a release without regenerating the content-addressed artifacts', async () => { - const sandbox = await createReleaseSandbox() - - sandbox.generate('--write') - const [manifest, registry] = await Promise.all([ - sandbox.read('current-manifest.json'), - sandbox.read('snapshot-registry.json') - ]) - sandbox.generate('--release', 'v1.4.156') - - // The cut records provenance for bytes that are already committed, so a - // version-only cut can never rewrite a shipped identity. - expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toEqual([ - { appVersion: '1.4.156', skills: { demo: 1 } } - ]) - expect(await sandbox.read('current-manifest.json')).toBe(manifest) - expect(await sandbox.read('snapshot-registry.json')).toBe(registry) - - // Bytes that changed since the last regeneration would make the row name a - // revision this tag does not ship — refuse rather than record it. - await sandbox.editSkill('edited after the last regeneration\n') - expect(() => sandbox.generate('--release', '1.4.157')).toThrow( - /Generated skill artifacts are stale/ - ) - expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toHaveLength(1) - }) - - it('freezes a revision once a release records it, and only until then', async () => { - const sandbox = await createReleaseSandbox() - const demoSnapshots = async () => - JSON.parse(await sandbox.read('snapshot-registry.json')).skills.demo - - sandbox.generate('--write') - const unreleased = (await demoSnapshots())[0].packageDigest - - // Nothing has shipped revision 1 yet, so re-deriving it over new bytes is - // correct: the tail floats until a release names it. - await sandbox.editSkill('about to ship\n') - sandbox.generate('--write') - const shipped = await demoSnapshots() - expect(shipped).toHaveLength(1) - expect(shipped[0].packageDigest).not.toBe(unreleased) - - sandbox.generate('--release', '1.4.156') - - // The cut named revision 1, so the next change appends revision 2 instead of - // rebuilding revision 1. Installs carrying the shipped digest keep matching a - // known snapshot — without the ledger row they would match nothing. - await sandbox.editSkill('changed again after the cut\n') - sandbox.generate('--write') - const frozen = await demoSnapshots() - expect(frozen).toHaveLength(2) - expect(frozen[0]).toEqual(shipped[0]) - expect(frozen[1].releaseRevision).toBe(2) + await sandbox.editSkill('changed\n') + expect(() => sandbox.generate()).toThrow() }) it.runIf(process.platform !== 'win32')( @@ -522,43 +239,10 @@ describe('skill bundle manifest generator', () => { expect(generated).toEqual(['.DS_Store.md', '.dsstore', 'SKILL.md', '_SKILL.md', 'ds_store.md']) }) - it('computes the same Git tree identity as Git', async () => { - const packageRoot = await createPackage() - await cp(path.join(REPO_ROOT, 'skills', 'orca-cli'), packageRoot, { recursive: true }) - const files = await collectPackageFiles(packageRoot) - // Compare the same bytes even when the skill has uncommitted edits. - execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) - execFileSync('git', ['-c', 'core.autocrlf=false', 'add', '-A'], { cwd: packageRoot }) - const expected = execFileSync('git', ['write-tree'], { - cwd: packageRoot, - encoding: 'utf8' - }).trim() - - expect(gitTreeSha(files)).toBe(expected) - }) - - it('matches Git when a directory and file share a name prefix', async () => { - const packageRoot = await createPackage() - await mkdir(path.join(packageRoot, 'sub')) - await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n') - await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n') - const files = await collectPackageFiles(packageRoot) - execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) - execFileSync('git', ['add', '-A'], { cwd: packageRoot }) - const expected = execFileSync('git', ['write-tree'], { - cwd: packageRoot, - encoding: 'utf8' - }).trim() - - expect(gitTreeSha(files)).toBe(expected) - }) - // Why: every step in the cut job shares one workspace and one index, so any of - // them can stage the content-addressed artifacts and the bump step's own commit - // then carries them into the tag. Grepping the workflow cannot see a path built - // from an env var, a composite action, or concatenation, so the cut asserts its - // own index before committing; this test pins that guard and adds a tripwire - // for the literal spellings. + // them can stage a regenerated manifest and the bump step's own commit then + // carries it into the tag. The cut asserts what its commit carries; this test + // pins that guard and adds a tripwire for the literal spellings. it('keeps the whole release-cut job off skill regeneration', async () => { const workflow = parse( await readFile(path.join(REPO_ROOT, '.github/workflows/release-cut.yml'), 'utf8') @@ -568,28 +252,21 @@ describe('skill bundle manifest generator', () => { .map((step) => ({ name: step.name ?? '(unnamed)', run: step.run.replace(/^\s*#.*$/gm, '') })) const bumpStep = runSteps.find((step) => step.name === 'Bump package.json and tag') - // The load-bearing check: whatever staged it and however the commit was - // spelled, only these two paths may ship. Asserted on the commit rather than - // the index because `git commit -a/-i/--only/` bypasses the index. - // -F is part of the contract; without it `.` admits a path like packageXjson. - // Flags pinned, not just the command: a `--diff-filter` slipped in here would - // silence modifications, and dropping -m makes a merge commit report nothing. + // Asserted on the commit rather than the index because `git commit -a/-i/--only/ + // ` bypasses the index. -F is part of the contract; without it `.` admits a + // path like packageXjson. Dropping -m makes a merge commit report nothing. expect(bumpStep.run).toMatch( - /git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/skills\/release-mapping\.json'/ + /git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json'/ ) expect(bumpStep.run.indexOf('grep -vxF')).toBeLessThan(bumpStep.run.indexOf('git tag')) // ...and that it aborts. A guard degraded to a warning still reads as covered. - // The exit must be inside the guard's own block, not borrowed from a later one. expect(bumpStep.run).toMatch( /if \[\[ -n "\$committed" \]\]; then(?:(?!\bfi\b)[\s\S])*exit 1[\s\S]*?fi/ ) - // Tripwire only. A step that merely READS this directory may be added here; - // one that writes or stages it must not, and the guard above will reject it. - expect(runSteps.filter((s) => /resources[/\\]skills/.test(s.run)).map((s) => s.name)).toEqual([ - 'Bump package.json and tag' - ]) for (const step of runSteps) { - expect(step.run, step.name).not.toMatch(/--write|generate:skill-bundle-manifest/) + expect(step.run, step.name).not.toMatch( + /resources[/\\]skills|--write|generate:skill-bundle-manifest/ + ) } }) }) diff --git a/config/scripts/generated-artifact-line-ending-pin.test.mjs b/config/scripts/generated-artifact-line-ending-pin.test.mjs index 8b628099d35..7720cda1938 100644 --- a/config/scripts/generated-artifact-line-ending-pin.test.mjs +++ b/config/scripts/generated-artifact-line-ending-pin.test.mjs @@ -12,8 +12,6 @@ it('keeps byte-compared artifacts and source LF in an autocrlf checkout', () => const crlf = 'first\r\nsecond\r\n' const paths = new Map([ ['resources/skills/current-manifest.json', lf], - ['resources/skills/snapshot-registry.json', lf], - ['resources/skills/release-mapping.json', lf], ['src/main/__snapshots__/example.test.ts.snap', lf], ['src/renderer/src/example.tsx', lf], ['src/main/example.ts', lf], diff --git a/config/scripts/idle-cpu-process-sampling.mjs b/config/scripts/idle-cpu-process-sampling.mjs index 81f9218e5ad..883b3c566bb 100644 --- a/config/scripts/idle-cpu-process-sampling.mjs +++ b/config/scripts/idle-cpu-process-sampling.mjs @@ -55,9 +55,26 @@ function readUnixProcesses() { return parseUnixProcesses(stdout) } +// `Win32_Process` times are 100 ns counts. Not `Number(value)`: that turns a missing +// field into a real-looking 0, so the CPU delta would silently go kernel-only. +function cimTimeSeconds(value) { + return typeof value === 'number' && Number.isFinite(value) ? value / 10_000_000 : null +} + +export function cimCpuSeconds(entry) { + const userModeSeconds = cimTimeSeconds(entry.UserModeTime) + const kernelModeSeconds = cimTimeSeconds(entry.KernelModeTime) + return userModeSeconds !== null && kernelModeSeconds !== null + ? userModeSeconds + kernelModeSeconds + : null +} + function readWindowsProcesses() { + // Why UTF-8: the default OEM code page best-fits characters such as `•` or `→` in any + // process's command line to raw control bytes (0x07, 0x1A), which JSON.parse rejects. const script = - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' + + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,UserModeTime,KernelModeTime,CommandLine | ConvertTo-Json -Compress' const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 @@ -71,7 +88,7 @@ function readWindowsProcesses() { pid: Number(entry.ProcessId), ppid: Number(entry.ParentProcessId), percentCpu: 0, - cpuTimeSeconds: null, + cpuTimeSeconds: cimCpuSeconds(entry), rssBytes: Number(entry.WorkingSetSize) || 0, command: String(entry.CommandLine || '') })) diff --git a/config/scripts/idle-cpu-process-sampling.test.mjs b/config/scripts/idle-cpu-process-sampling.test.mjs index d452c6e3ce0..2c25187ea9f 100644 --- a/config/scripts/idle-cpu-process-sampling.test.mjs +++ b/config/scripts/idle-cpu-process-sampling.test.mjs @@ -1,5 +1,10 @@ +import { spawn } from 'node:child_process' import { describe, expect, it } from 'vitest' -import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs' +import { + cimCpuSeconds, + readProcessRows, + sampleProcessTreeUntilWorkloadsComplete +} from './idle-cpu-process-sampling.mjs' function createClock(workloadCompletesAt) { let currentMs = 0 @@ -27,6 +32,39 @@ function createClock(workloadCompletesAt) { return { now: () => currentMs, readRows, wait, workloadPromise } } +describe('Windows CIM CPU time mapping', () => { + it('converts the 100ns user and kernel times to a summed second count', () => { + // 10_000_000 and 25_000_000 units of 100ns are 1s and 2.5s. + expect(cimCpuSeconds({ UserModeTime: 10_000_000, KernelModeTime: 25_000_000 })).toBe(3.5) + }) + + it('maps a missing or non-numeric time to null instead of a kernel-only reading', () => { + expect(cimCpuSeconds({ UserModeTime: null, KernelModeTime: 10_000_000 })).toBeNull() + expect(cimCpuSeconds({ KernelModeTime: 10_000_000 })).toBeNull() + expect(cimCpuSeconds({ UserModeTime: '10000000', KernelModeTime: 10_000_000 })).toBeNull() + expect(cimCpuSeconds({ UserModeTime: Number.NaN, KernelModeTime: 10_000_000 })).toBeNull() + expect( + cimCpuSeconds({ UserModeTime: Number.POSITIVE_INFINITY, KernelModeTime: 10_000_000 }) + ).toBeNull() + }) +}) + +describe.skipIf(process.platform !== 'win32')('Windows process table read', () => { + it('parses a command line whose characters the OEM code page maps to control bytes', () => { + const marker = 'idle-cpu-oem • → ♪' + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)', marker], { + stdio: 'ignore' + }) + try { + const row = readProcessRows().find((candidate) => candidate.pid === child.pid) + expect(row?.command).toContain(marker) + expect(row?.cpuTimeSeconds).toEqual(expect.any(Number)) + } finally { + child.kill() + } + }, 30_000) +}) + describe('idle CPU process sampling window', () => { it('extends through a slow workload and captures a final CPU delta', async () => { const clock = createClock(40) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index b1195ea2064..031ce7b5844 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -3642,7 +3642,7 @@ "ko": "클라이언트" }, "auto.components.settings.settingOwnership.agentLaunchDefaults": { - "ko": "기본 agent, 명령 재정의, CLI 인수, 실행 환경은 클라이언트 환경설정입니다. SSH 및 원격 서버 실행은 실행 시점에 여전히 호스트 사용 가능 여부를 확인합니다." + "ko": "기본 agent, 명령 재정의, CLI 인수 및 실행 환경은 선택한 호스트에 저장됩니다." }, "auto.components.settings.settingOwnership.clientDefault": { "ko": "클라이언트 기본값" diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index ab7e68a89c9..f351394e47d 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -110,7 +110,7 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti expect(step.run).toContain('git diff --name-only --no-renames -z "$DIFF_BASE" HEAD') expect(step.run).not.toContain('--diff-filter') expect(step.run).toContain('&& [ "$scope" = false ]; then') - expect(step.run).toMatch(/else\s+pnpm run verify:localization-extraction\s+fi/) + expect(step.run).toMatch(/else\s+bun config\/scripts\/verify-localization-extraction\.mjs\s+fi/) expect( affectsLocalizationExtraction(['src/old-translations.ts', 'docs/moved-translations.ts']) ).toBe(true) diff --git a/config/scripts/native-chat-design-tokens-plugin.test.mjs b/config/scripts/native-chat-design-tokens-plugin.test.mjs new file mode 100644 index 00000000000..718390c5b93 --- /dev/null +++ b/config/scripts/native-chat-design-tokens-plugin.test.mjs @@ -0,0 +1,66 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/native-chat-design-tokens.mjs') + +function lintSource(source) { + return runOxlintPluginOnSource({ + pluginName: 'native-chat-design-tokens', + pluginPath, + source, + rules: { + 'native-chat-design-tokens/no-arbitrary-font-size': 'warn', + 'native-chat-design-tokens/no-softened-foreground': 'warn', + 'native-chat-design-tokens/no-weakened-focus-ring': 'warn' + } + }) +} + +const violations = [ + ['arbitrary px size', 'export const X = () =>

'], + ['arbitrary rem size', 'export const X = () =>

'], + ['arbitrary size behind a variant', 'export const X = () =>

'], + ['arbitrary size in a class constant', "const ROW = 'flex text-[10px]'"], + [ + 'arbitrary size in a composer', + "export const X = () =>

" + ], + ['softened foreground', 'export const X = () =>

'], + [ + 'weakened focus ring', + 'export const X = () =>