mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/conv-c1-host-planner
# Conflicts: # src/main/runtime/runtime-terminal-contracts.ts
This commit is contained in:
@@ -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_<version>_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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\"}"
|
||||
;;
|
||||
|
||||
+18
-89
@@ -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/<platform>/.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+16
-1
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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}$/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
)
|
||||
}
|
||||
@@ -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 <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
+#include <mutex>
|
||||
+#include <atomic>
|
||||
#include <vector>
|
||||
#include <Windows.h>
|
||||
#include <strsafe.h>
|
||||
@@ -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<std::unique_ptr<pty_baton>> 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<uint32_t> 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<Napi::Boolean>().Value();
|
||||
Napi::Function exitCallback = info[5].As<Napi::Function>();
|
||||
|
||||
@@ -855,7 +858,7 @@ index 7b286d3d64..5239ba4e40 100644
|
||||
|
||||
// Prepare command line
|
||||
std::unique_ptr<wchar_t[]> mutableCommandline = std::make_unique<wchar_t[]>(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<SHORT>(info[2].As<Napi::Number>().Uint32Value());
|
||||
const bool useConptyDll = info[3].As<Napi::Boolean>().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<Napi::Number>().Int32Value();
|
||||
const bool useConptyDll = info[1].As<Napi::Boolean>().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<Napi::Number>().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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** `<count> <path> [# 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) {
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<pathspec>` 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/
|
||||
// <pathspec>` 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/
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 || '')
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3642,7 +3642,7 @@
|
||||
"ko": "클라이언트"
|
||||
},
|
||||
"auto.components.settings.settingOwnership.agentLaunchDefaults": {
|
||||
"ko": "기본 agent, 명령 재정의, CLI 인수, 실행 환경은 클라이언트 환경설정입니다. SSH 및 원격 서버 실행은 실행 시점에 여전히 호스트 사용 가능 여부를 확인합니다."
|
||||
"ko": "기본 agent, 명령 재정의, CLI 인수 및 실행 환경은 선택한 호스트에 저장됩니다."
|
||||
},
|
||||
"auto.components.settings.settingOwnership.clientDefault": {
|
||||
"ko": "클라이언트 기본값"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = () => <p className="truncate text-[13px]" />'],
|
||||
['arbitrary rem size', 'export const X = () => <p className="text-[0.8125rem]" />'],
|
||||
['arbitrary size behind a variant', 'export const X = () => <p className="sm:text-[11px]" />'],
|
||||
['arbitrary size in a class constant', "const ROW = 'flex text-[10px]'"],
|
||||
[
|
||||
'arbitrary size in a composer',
|
||||
"export const X = () => <p className={cn(a && 'text-[12px]')} />"
|
||||
],
|
||||
['softened foreground', 'export const X = () => <p className="text-foreground/85" />'],
|
||||
[
|
||||
'weakened focus ring',
|
||||
'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring/70" />'
|
||||
]
|
||||
]
|
||||
|
||||
const accepted = [
|
||||
['named steps', 'export const X = () => <p className="text-sm text-xs text-2xs text-3xs" />'],
|
||||
['code step', 'export const X = () => <pre className="font-mono text-chat-code" />'],
|
||||
[
|
||||
'arbitrary colour',
|
||||
'export const X = () => <p className="text-[var(--git-decoration-added)]" />'
|
||||
],
|
||||
[
|
||||
'arbitrary non-font length',
|
||||
'export const X = () => <p className="min-h-[26px] max-w-[80%]" />'
|
||||
],
|
||||
[
|
||||
'chat and surface text colours',
|
||||
'export const X = () => <p className="text-chat-foreground text-foreground" />'
|
||||
],
|
||||
['softened state colour', 'export const X = () => <p className="text-destructive/80" />'],
|
||||
[
|
||||
'full focus ring',
|
||||
'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring" />'
|
||||
]
|
||||
]
|
||||
|
||||
describe('native-chat-design-tokens oxlint plugin', () => {
|
||||
it.each(violations)('reports %s', (_name, source) => {
|
||||
expect(lintSource(source)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(accepted)('accepts %s', (_name, source) => {
|
||||
expect(lintSource(source)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,7 @@ import { expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
classifyE2eJobs,
|
||||
ORCAD_AUTO_CONVERT_E2E_SPEC,
|
||||
ORCAD_IDLE_EXIT_E2E_SPEC,
|
||||
ORCAD_JOURNEY_E2E_SPECS,
|
||||
ORCAD_OPEN_IN_OWNER_E2E_SPEC,
|
||||
MANAGED_SSH_E2E_SPEC_PREFIX,
|
||||
ORCAD_SERVE_MODE_SWITCH_E2E_SPEC,
|
||||
WINDOWS_MISSING_APPDATA_E2E_SPEC
|
||||
} from './ci-e2e-job-selection.mjs'
|
||||
@@ -15,6 +12,14 @@ import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routin
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const jobs = parse(readFileSync(resolve(root, '.github/workflows/e2e.yml'), 'utf8')).jobs
|
||||
const ORCAD_AUTO_CONVERT_E2E_SPEC = 'tests/e2e/ssh-orcad-auto-convert.spec.ts'
|
||||
const ORCAD_IDLE_EXIT_E2E_SPEC = 'tests/e2e/ssh-orcad-idle-exit.spec.ts'
|
||||
const ORCAD_OPEN_IN_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-open-in-owner.spec.ts'
|
||||
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'
|
||||
]
|
||||
|
||||
function expectRouted(files, spec) {
|
||||
for (const file of files) {
|
||||
@@ -85,19 +90,27 @@ it('builds the e2e app when only a build-dependent orcad spec is requested', ()
|
||||
e2e_needs_build: true
|
||||
})
|
||||
}
|
||||
expect(jobs['orcad-auto-convert-docker'].needs).toEqual(['build', 'prepare-native-cache'])
|
||||
expect(jobs['orcad-ssh-docker'].needs).toEqual(['build', 'prepare-native-cache'])
|
||||
})
|
||||
|
||||
it('runs the auto-convert lane only when routed, not on every SSH source change', () => {
|
||||
const condition = jobs['orcad-auto-convert-docker'].if
|
||||
it('runs every managed-SSH spec in one sharded lane, only when one is routed', () => {
|
||||
const lane = jobs['orcad-ssh-docker']
|
||||
expect(lane.if).toBe(
|
||||
`inputs.test_files == '' || contains(inputs.test_files, '${MANAGED_SSH_E2E_SPEC_PREFIX}')`
|
||||
)
|
||||
expect(lane.strategy.matrix.shard).toEqual(['1/2', '2/2'])
|
||||
const run = lane.steps.find((step) => step.env?.ORCA_E2E_SSH_DOCKER === '1').run
|
||||
expect(run).toContain(` ${MANAGED_SSH_E2E_SPEC_PREFIX} `)
|
||||
expect(run).toContain('--shard="$SHARD"')
|
||||
for (const spec of [
|
||||
ORCAD_AUTO_CONVERT_E2E_SPEC,
|
||||
ORCAD_IDLE_EXIT_E2E_SPEC,
|
||||
ORCAD_OPEN_IN_OWNER_E2E_SPEC
|
||||
ORCAD_OPEN_IN_OWNER_E2E_SPEC,
|
||||
...ORCAD_JOURNEY_E2E_SPECS
|
||||
]) {
|
||||
expect(condition).toContain(`contains(inputs.test_files, '${spec}')`)
|
||||
expect(existsSync(resolve(root, spec)), spec).toBe(true)
|
||||
expect(spec.startsWith(MANAGED_SSH_E2E_SPEC_PREFIX), spec).toBe(true)
|
||||
}
|
||||
expect(condition).not.toContain('ssh_source_changed')
|
||||
})
|
||||
|
||||
it('runs both Windows serve specs on one runner, each only when requested', () => {
|
||||
@@ -132,16 +145,9 @@ it('routes managed target-owner guards and menus to the real launch regression',
|
||||
expect(selectPrE2eSpecs(['src/renderer/src/lib/local-path-open-guard.test.ts'])).not.toContain(
|
||||
ORCAD_OPEN_IN_OWNER_E2E_SPEC
|
||||
)
|
||||
expect(
|
||||
jobs['orcad-auto-convert-docker'].steps.some((step) =>
|
||||
step.run?.includes(ORCAD_OPEN_IN_OWNER_E2E_SPEC)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('runs the managed-host journey specs in their own job when routed', () => {
|
||||
const job = jobs['orcad-journeys-docker']
|
||||
expect(job.needs).toEqual(['build', 'prepare-native-cache'])
|
||||
it('routes the managed-host journey specs from their harness and sources', () => {
|
||||
for (const spec of ORCAD_JOURNEY_E2E_SPECS) {
|
||||
expectRouted(
|
||||
[
|
||||
@@ -152,11 +158,6 @@ it('runs the managed-host journey specs in their own job when routed', () => {
|
||||
],
|
||||
spec
|
||||
)
|
||||
expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`)
|
||||
expect(
|
||||
job.steps.some((step) => step.run?.includes(spec)),
|
||||
spec
|
||||
).toBe(true)
|
||||
expect(classifyE2eJobs(JSON.stringify([spec])), spec).toEqual({
|
||||
e2e_run_changed: false,
|
||||
e2e_needs_build: true
|
||||
|
||||
@@ -125,7 +125,6 @@ async function finalizePackagedOrcadTemplate(resourcesDir, options) {
|
||||
if (isOrcadTemplateRequired(env)) {
|
||||
throw new Error(`Packaged app is missing the orcad deployment template: ${templateDir}`)
|
||||
}
|
||||
// SSH relays then keep the legacy host-Node path (ssh-relay-pinned-node.ts).
|
||||
console.log('[packaged-orcad-template] skipped: this build ships no orcad template')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ const SHELL_PREFIXES = [
|
||||
'src/main/zsh-',
|
||||
'src/main/runtime/structured-session-cli-login-shell',
|
||||
'src/main/runtime/structured-session-login-shell-test-harness',
|
||||
'src/main/runtime/structured-session-child-identity-env',
|
||||
'src/main/runtime/structured-session-child-env',
|
||||
'src/renderer/src/components/terminal-pane/fish-color-scheme',
|
||||
'src/shared/fish-',
|
||||
'src/shared/pty-reply-echo-shapes',
|
||||
@@ -372,6 +372,7 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs',
|
||||
'config/scripts/rebuild-native-deps-node-pty.test.mjs',
|
||||
'config/scripts/nsis-process-check.test.mjs',
|
||||
'config/scripts/idle-cpu-process-sampling.test.mjs',
|
||||
'config/scripts/ensure-native-runtime-job-ownership.test.mjs',
|
||||
'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs',
|
||||
'config/scripts/windows-pe-machine.test.mjs',
|
||||
|
||||
@@ -294,7 +294,8 @@ describe('per-job path classification', () => {
|
||||
)
|
||||
for (const file of [
|
||||
'src/main/runtime/structured-session-login-shell-test-harness.ts',
|
||||
'src/main/runtime/structured-session-child-identity-env.ts'
|
||||
'src/main/runtime/structured-session-child-env.ts',
|
||||
'src/main/runtime/structured-session-child-env.test-support.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
shell_contracts: true,
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('PR E2E gate contract', () => {
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts'
|
||||
])
|
||||
).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain(
|
||||
expect(selectPrE2eSpecs(['src/main/ipc/pty/register-handlers.ts'])).not.toContain(
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,10 @@ const NATIVE_IME_PRODUCT_SOURCE =
|
||||
const NATIVE_IME_HARNESS =
|
||||
/^(?:config\/scripts\/focus-nested-wayland-terminal\.sh$|config\/scripts\/(?:run-terminal-ibus-hangul-e2e|terminal-ime-engagement-receipt)\.mjs$|tests\/e2e\/terminal-ime-(?:boundary-probe|byte-reader|engagement-receipt)\.ts$|tests\/e2e\/terminal-(?:ibus-hangul|hangul-terminating-digit|macos-2set-korean)-native\.spec\.ts$)/
|
||||
|
||||
/** Managed-SSH harness helpers; every managed-SSH spec runs in one job, so any route may claim them. */
|
||||
const MANAGED_SSH_HARNESS =
|
||||
/^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-template-variant|orcad-upgrade-profile|docker-ssh-host-target)\.ts$/
|
||||
|
||||
export const PR_E2E_SOURCE_ROUTES = [
|
||||
{
|
||||
id: 'ssh.orcad-editor-ownership',
|
||||
@@ -108,9 +112,7 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
id: 'ssh.orcad-auto-convert',
|
||||
specs: ['tests/e2e/ssh-orcad-auto-convert.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-template-variant|orcad-upgrade-profile)\.ts$/.test(
|
||||
file
|
||||
) ||
|
||||
MANAGED_SSH_HARNESS.test(file) ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/main\/(?:ipc\/ssh-host-server-|ssh\/(?:ssh-host-server-|orcad-runtime-conversion|orcad-migration-|orcad-retained-source|orcad-runtime-deployment))/.test(
|
||||
file
|
||||
@@ -147,9 +149,8 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
id: 'ssh.orcad-browser-drop-owner',
|
||||
specs: ['tests/e2e/ssh-orcad-browser-drop-owner.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-host-target|browser-split-(?:guest-probes|page-server))\.ts$/.test(
|
||||
file
|
||||
) ||
|
||||
MANAGED_SSH_HARNESS.test(file) ||
|
||||
/^tests\/e2e\/helpers\/browser-split-(?:guest-probes|page-server)\.ts$/.test(file) ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/renderer\/src\/(?:lib\/(?:file-preview|workspace-file-drag|worktree-runtime-owner|connection-context)\.ts|components\/(?:right-sidebar\/FileExplorer(?:Row|VirtualRows)\.tsx|browser-pane\/(?:navigate\/use-browser-page-navigation-downloads\.ts|host-guest\/attach-browser-page-webview\.ts)))$/.test(
|
||||
file
|
||||
@@ -159,9 +160,7 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
id: 'ssh.orcad-open-in-owner',
|
||||
specs: ['tests/e2e/ssh-orcad-open-in-owner.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-host-target)\.ts$/.test(
|
||||
file
|
||||
) ||
|
||||
MANAGED_SSH_HARNESS.test(file) ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/renderer\/src\/(?:lib\/(?:local-path-open-guard|external-editor-open-capability|worktree-runtime-owner)\.ts|components\/(?:sidebar\/(?:WorktreeOpenInMenu|WorktreeContextMenuView)\.tsx|right-sidebar\/(?:FileExplorer(?:Toolbar)?\.tsx|source-control\/listing\/entry-context-menu\.tsx)))$/.test(
|
||||
file
|
||||
@@ -171,9 +170,8 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
id: 'ssh.orcad-terminal-root-owner',
|
||||
specs: ['tests/e2e/ssh-orcad-terminal-root-owner.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^tests\/e2e\/helpers\/(?:terminal-workspace-root-link|orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-host-target)\.ts$/.test(
|
||||
file
|
||||
) ||
|
||||
MANAGED_SSH_HARNESS.test(file) ||
|
||||
file === 'tests/e2e/helpers/terminal-workspace-root-link.ts' ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/renderer\/src\/(?:components\/terminal-pane\/terminal-(?:worktree-path-link|file-link-actions|file-link-hit-testing|file-open-routing|link-handlers)\.ts|lib\/(?:workspace-file-host-routing|worktree-owner-route|worktree-operation-route|worktree-operation-catalog-route)\.ts)$/.test(
|
||||
file
|
||||
@@ -183,7 +181,7 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
id: 'ssh.orcad-idle-exit',
|
||||
specs: ['tests/e2e/ssh-orcad-idle-exit.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^tests\/e2e\/helpers\/orcad-convert-(?:flow|host)\.ts$/.test(file) ||
|
||||
MANAGED_SSH_HARNESS.test(file) ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/(?:main\/(?:orcad\/orcad-(?:idle-|managed-idle-)|ssh\/orcad-(?:managed-serving|managed-wake|managed-tunnel|recovery-slot|remote-launch|remote-runtime-control))|shared\/orcad-idle-exit)/.test(
|
||||
file
|
||||
@@ -198,7 +196,8 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
/^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|paired-host-terminal|terminal-workspace-root-link)\.ts$/.test(
|
||||
MANAGED_SSH_HARNESS.test(file) ||
|
||||
/^tests\/e2e\/helpers\/(?:paired-host-terminal|terminal-workspace-root-link)\.ts$/.test(
|
||||
file
|
||||
) ||
|
||||
(isProductSource(file) &&
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
// Baselines shared by the ratchet gates. An entry baseline lists one allowed entry per line; a
|
||||
// count baseline holds `<count> <path> [# note]` rows. `#` lines and blanks are ignored in both.
|
||||
|
||||
/** Tracked files under `pathspecs`, so build output and untracked scratch are never scanned. */
|
||||
export function listTrackedFiles(root, pathspecs) {
|
||||
return execFileSync('git', ['ls-files', ...pathspecs], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Tracked files with their text; one deleted in the working tree is skipped. */
|
||||
export function readTrackedSources(root, pathspecs, include = () => true) {
|
||||
const sources = new Map()
|
||||
for (const rel of listTrackedFiles(root, pathspecs).filter(include)) {
|
||||
try {
|
||||
sources.set(rel, fs.readFileSync(path.join(root, rel), 'utf8'))
|
||||
} catch {
|
||||
// Deleted in the working tree but still tracked.
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
export function parseEntryBaseline(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
}
|
||||
|
||||
/** `added`: not in the baseline (fails). `stale`: gone, must be pruned so it cannot come back. */
|
||||
export function diffEntries(current, baseline) {
|
||||
const cur = new Set(current)
|
||||
const base = new Set(baseline)
|
||||
return {
|
||||
added: [...cur].filter((entry) => !base.has(entry)).sort(),
|
||||
stale: [...base].filter((entry) => !cur.has(entry)).sort()
|
||||
}
|
||||
}
|
||||
|
||||
function writeEntryBaseline(root, ratchet, entries) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, ratchet.baselinePath),
|
||||
`${[...ratchet.header, ''].join('\n')}${entries.join('\n')}\n`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for an entry ratchet `{ name, baselinePath, header, collect(root), fix }`: a new entry
|
||||
* fails with `fix`, a stale one fails until pruned. `--prune` drops stale entries only.
|
||||
*/
|
||||
export function runEntryRatchet(ratchet, root, arg) {
|
||||
const baselineFile = path.join(root, ratchet.baselinePath)
|
||||
const prune = `pnpm check:${ratchet.name}-ratchet --prune`
|
||||
if (arg === '--init') {
|
||||
const entries = ratchet.collect(root)
|
||||
writeEntryBaseline(root, ratchet, entries)
|
||||
console.log(`Wrote ${ratchet.baselinePath} with ${entries.length} entries.`)
|
||||
return 0
|
||||
}
|
||||
if (!fs.existsSync(baselineFile)) {
|
||||
console.error(`::error::Missing ${ratchet.baselinePath}. Generate it with: --init`)
|
||||
return 1
|
||||
}
|
||||
const baseline = parseEntryBaseline(fs.readFileSync(baselineFile, 'utf8'))
|
||||
const current = ratchet.collect(root)
|
||||
const { added, stale } = diffEntries(current, baseline)
|
||||
if (arg === '--prune') {
|
||||
const kept = baseline.filter((entry) => current.includes(entry)).sort()
|
||||
writeEntryBaseline(root, ratchet, kept)
|
||||
console.log(
|
||||
`Pruned baseline to ${kept.length} entries (removed ${baseline.length - kept.length}).`
|
||||
)
|
||||
if (added.length > 0) {
|
||||
console.error(`::error::--prune does not add entries; ${added.length} new one(s) remain.`)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
for (const entry of added) {
|
||||
console.error(`::error::New ${ratchet.name} entry not allowed: ${entry}`)
|
||||
}
|
||||
if (added.length > 0) {
|
||||
console.error(`\n${ratchet.fix.join('\n')}`)
|
||||
console.error(
|
||||
`With reviewer sign-off for an unavoidable exception, add the line(s) to ${ratchet.baselinePath}.\n`
|
||||
)
|
||||
}
|
||||
for (const entry of stale) {
|
||||
console.error(`::error::Stale ${ratchet.name} baseline entry (prune it): ${entry}`)
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
console.error(`The baseline may only shrink. Fix it: ${prune}`)
|
||||
}
|
||||
if (added.length + stale.length > 0) {
|
||||
return 1
|
||||
}
|
||||
console.log(`${ratchet.name} ratchet OK — ${current.length} grandfathered entr(y/ies).`)
|
||||
return 0
|
||||
}
|
||||
|
||||
export function parseCountBaseline(text) {
|
||||
return new Map(
|
||||
parseEntryBaseline(text).map((line) => {
|
||||
const [count, file] = line.split(/\s+/, 2)
|
||||
return [file, Number(count)]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Trailing `# note` per row, so a pruned baseline keeps why an entry is still allowed. */
|
||||
export function parseCountNotes(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 formatCountBaseline(counts, 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) }
|
||||
}
|
||||
|
||||
export function totalCount(counts) {
|
||||
return [...counts.values()].reduce((sum, count) => sum + count, 0)
|
||||
}
|
||||
|
||||
/** Lowers baseline rows to `current` and keeps their notes; growth still has to be fixed in code. */
|
||||
export function pruneCountBaseline(root, baselinePath, header, current) {
|
||||
const file = path.join(root, baselinePath)
|
||||
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
||||
const pruned = new Map(
|
||||
[...parseCountBaseline(text)].map(([rel, allowed]) => [
|
||||
rel,
|
||||
Math.min(allowed, current.get(rel) ?? 0)
|
||||
])
|
||||
)
|
||||
fs.writeFileSync(file, formatCountBaseline(pruned, header, parseCountNotes(text)))
|
||||
return totalCount(pruned)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
diffCounts,
|
||||
diffEntries,
|
||||
formatCountBaseline,
|
||||
parseCountBaseline,
|
||||
parseCountNotes,
|
||||
parseEntryBaseline,
|
||||
runEntryRatchet
|
||||
} from './ratchet-baseline.mjs'
|
||||
|
||||
describe('entry baseline', () => {
|
||||
it('drops comments and blank lines and trims, so formatting cannot invent entries', () => {
|
||||
expect(parseEntryBaseline('# header\n\n inline a.ts \nmobile-config x/*.tsx\n')).toEqual([
|
||||
'inline a.ts',
|
||||
'mobile-config x/*.tsx'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports added and stale entries', () => {
|
||||
expect(diffEntries(['b.ts', 'c.ts'], ['a.ts', 'b.ts'])).toEqual({
|
||||
added: ['c.ts'],
|
||||
stale: ['a.ts']
|
||||
})
|
||||
expect(diffEntries(['a.ts'], ['a.ts'])).toEqual({ added: [], stale: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runEntryRatchet', () => {
|
||||
let root
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function ratchet(current) {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-ratchet-'))
|
||||
writeFileSync(join(root, 'baseline.txt'), '# header\na.ts\nb.ts\n')
|
||||
return {
|
||||
name: 'demo',
|
||||
baselinePath: 'baseline.txt',
|
||||
header: ['# header'],
|
||||
collect: () => current,
|
||||
fix: ['Fix the file.']
|
||||
}
|
||||
}
|
||||
|
||||
it('fails a new entry and a stale one, and passes the baseline as-is', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
expect(runEntryRatchet(ratchet(['a.ts', 'b.ts', 'c.ts']), root)).toBe(1)
|
||||
expect(error.mock.calls.join('\n')).toContain('New demo entry not allowed: c.ts')
|
||||
expect(runEntryRatchet(ratchet(['a.ts']), root)).toBe(1)
|
||||
expect(error.mock.calls.join('\n')).toContain('Stale demo baseline entry (prune it): b.ts')
|
||||
expect(runEntryRatchet(ratchet(['b.ts', 'a.ts']), root)).toBe(0)
|
||||
})
|
||||
|
||||
it('prunes stale entries only and still fails on a new one', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
expect(runEntryRatchet(ratchet(['a.ts', 'c.ts']), root, '--prune')).toBe(1)
|
||||
expect(readFileSync(join(root, 'baseline.txt'), 'utf8')).toBe('# header\na.ts\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('count 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(parseCountBaseline(formatCountBaseline(counts, ['# header']))).toEqual(
|
||||
new Map([
|
||||
['src/a.ts', 1],
|
||||
['src/b.ts', 2]
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a row note through a prune', () => {
|
||||
const text = formatCountBaseline(
|
||||
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(parseCountBaseline(text)).toEqual(new Map([['src/a.ts', 1]]))
|
||||
expect(parseCountNotes(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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
@@ -131,6 +133,18 @@ function main() {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Elevated POSIX users can pass W_OK even when the install is deliberately read-only.
|
||||
if (process.platform !== 'win32' && (statSync(electronPackageDir).mode & 0o222) === 0) {
|
||||
throw new Error('Electron package directory has no write permission bits')
|
||||
}
|
||||
accessSync(electronPackageDir, constants.W_OK)
|
||||
} catch {
|
||||
console.log(`skip ${worktree} (Electron package directory is not writable)`)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const size = measure(distPath)
|
||||
if (!apply) {
|
||||
console.log(`would share ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB (${version})`)
|
||||
@@ -145,6 +159,9 @@ function main() {
|
||||
recordAdoptedSharedElectronDist(entry, writeFileSync)
|
||||
console.log(`seeded ${worktree} -> ${entry.entryPath}`)
|
||||
converted += 1
|
||||
} else {
|
||||
console.warn(`skip ${worktree} (shared Electron cache could not be prepared)`)
|
||||
skipped += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -153,6 +170,9 @@ function main() {
|
||||
reclaimed += size
|
||||
converted += 1
|
||||
console.log(`shared ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB logical file data`)
|
||||
} else {
|
||||
console.warn(`skip ${worktree} (shared Electron copy could not be prepared)`)
|
||||
skipped += 1
|
||||
}
|
||||
} catch (error) {
|
||||
// A worktree that fails is left exactly as it was; it still has its own working dist.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { getElectronPlatformPath } from './electron-platform-path.mjs'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-reclaim-electron-'))
|
||||
roots.push(root)
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args: ['init', '--quiet'],
|
||||
cwd: root,
|
||||
timeoutMs: 10_000,
|
||||
terminationBarrier: true
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
const pkg = join(root, 'node_modules', 'electron')
|
||||
const executable = join(pkg, 'dist', getElectronPlatformPath(process.platform))
|
||||
await mkdir(join(executable, '..'), { recursive: true })
|
||||
await writeFile(join(pkg, 'package.json'), JSON.stringify({ version: '99.1.0' }))
|
||||
await writeFile(join(pkg, 'dist', 'version'), '99.1.0\n')
|
||||
await writeFile(executable, 'fake electron executable\n')
|
||||
const run = (apply = false) =>
|
||||
runProcess({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
resolve(import.meta.dirname, 'reclaim-electron-dists.mjs'),
|
||||
'--repo',
|
||||
root,
|
||||
...(apply ? ['--apply'] : [])
|
||||
],
|
||||
cwd: root,
|
||||
env: { ...process.env, CI: '', ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 20_000,
|
||||
terminationBarrier: true
|
||||
})
|
||||
return { root, pkg, executable, run }
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'reports a read-only package as protected in preview and apply',
|
||||
async () => {
|
||||
const { pkg, executable, run } = await fixture()
|
||||
await chmod(pkg, 0o555)
|
||||
try {
|
||||
for (const apply of [false, true]) {
|
||||
const result = await run(apply)
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Electron package directory is not writable')
|
||||
expect(result.stdout).toContain('0 worktree(s)')
|
||||
expect(result.stdout).toContain('skipped 1')
|
||||
expect(await readFile(executable, 'utf8')).toBe('fake electron executable\n')
|
||||
await expect(readFile(join(pkg, '.orca-shared-dist'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
await chmod(pkg, 0o755)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('shares a writable install and preserves its executable bytes', async () => {
|
||||
const { pkg, executable, run } = await fixture()
|
||||
const preview = await run()
|
||||
expect(preview.code, preview.stderr).toBe(0)
|
||||
expect(preview.stdout).toContain('Would share 1 worktree(s)')
|
||||
const applied = await run(true)
|
||||
expect(applied.code, applied.stderr).toBe(0)
|
||||
expect(applied.stdout).toContain('Shared 1 worktree(s)')
|
||||
expect(await readFile(executable, 'utf8')).toBe('fake electron executable\n')
|
||||
expect(await readFile(join(pkg, '.orca-shared-dist'), 'utf8')).toBe(
|
||||
`99.1.0-${process.platform}-${process.arch}`
|
||||
)
|
||||
const repeated = await run()
|
||||
expect(repeated.stdout).toContain('Would share 0 worktree(s)')
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat, readFile, readdir, realpath, rm } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { lock } from 'proper-lockfile'
|
||||
import { decodeGitCQuotedPath } from '../../src/shared/git-cquoted-path.ts'
|
||||
import { CHECKOUT_FORMAT } from '../../tests/e2e/cross-version-wire/release-checkout-publication.ts'
|
||||
import { withReleaseSourceFormatLease } from '../../tests/e2e/cross-version-wire/release-source-retention.ts'
|
||||
|
||||
const COMMIT = '(?:[\\da-f]{40}|[\\da-f]{64})'
|
||||
const LOCAL_ENTRY = new RegExp(`^(${COMMIT})-format-([1-9]\\d*)$`)
|
||||
const SHARED_ENTRY = new RegExp(`^(${COMMIT})$`)
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
async function directory(path) {
|
||||
try {
|
||||
const info = await lstat(path)
|
||||
return info.isDirectory() && !info.isSymbolicLink() ? info : null
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function descend(root, parts) {
|
||||
let path = root
|
||||
for (const part of parts) {
|
||||
path = join(path, part)
|
||||
if (!(await directory(path))) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function matchingStamp(path, commit, format) {
|
||||
try {
|
||||
const marker = join(path, 'checkout-stamp.json')
|
||||
const info = await lstat(marker)
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size > 4096) {
|
||||
return false
|
||||
}
|
||||
const value = JSON.parse(await readFile(marker, 'utf8'))
|
||||
return value?.commit === commit && value?.format === format
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || error instanceof SyntaxError) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function logicalBytes(root) {
|
||||
let bytes = 0
|
||||
const pending = [root]
|
||||
while (pending.length) {
|
||||
const path = pending.pop()
|
||||
for (const entry of await readdir(path, { withFileTypes: true })) {
|
||||
const child = join(path, entry.name)
|
||||
const info = await lstat(child)
|
||||
if (info.isDirectory() && !info.isSymbolicLink()) {
|
||||
pending.push(child)
|
||||
} else if (info.isFile() && !info.isSymbolicLink()) {
|
||||
bytes += info.size
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
async function git(repoRoot, args) {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 30_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
terminationBarrier: true
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error('Could not read the local Git repository')
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
async function repositoryTargets(repoRoot) {
|
||||
const common = (await git(repoRoot, ['rev-parse', '--git-common-dir'])).replace(
|
||||
process.platform === 'win32' ? /\r?\n$/ : /\n$/,
|
||||
''
|
||||
)
|
||||
if (!common || common.includes('\n')) {
|
||||
throw new Error('Unrecognised Git common directory')
|
||||
}
|
||||
const output = await git(repoRoot, [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'worktree',
|
||||
'list',
|
||||
'--porcelain'
|
||||
])
|
||||
const worktrees = []
|
||||
for (const block of output.split(/\r?\n\r?\n/)) {
|
||||
const lines = block.split(/\r?\n/).filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
lines.some(
|
||||
(line) =>
|
||||
!/^(worktree |HEAD |branch |bare$|detached$|locked(?: |$)|prunable(?: |$))/.test(line)
|
||||
)
|
||||
) {
|
||||
throw new Error('Ambiguous worktree listing; refusing cleanup')
|
||||
}
|
||||
const field = lines.find((line) => line.startsWith('worktree '))
|
||||
if (field && !lines.includes('bare')) {
|
||||
worktrees.push(decodeGitCQuotedPath(field.slice(9)))
|
||||
}
|
||||
}
|
||||
return { commonDirectory: await realpath(resolve(repoRoot, common)), worktrees }
|
||||
}
|
||||
|
||||
export async function reclaimReleaseTestCaches({
|
||||
repoRoot = process.cwd(),
|
||||
apply = false,
|
||||
testsStopped = false,
|
||||
minAgeDays = 30,
|
||||
nowMs = Date.now(),
|
||||
targets,
|
||||
report = console.log
|
||||
} = {}) {
|
||||
if (!Number.isFinite(minAgeDays) || minAgeDays < 0) {
|
||||
throw new Error('--min-age-days requires a non-negative number')
|
||||
}
|
||||
if (apply && !testsStopped) {
|
||||
throw new Error(
|
||||
'Stop cross-version tests in this repository, then pass --tests-stopped with --apply'
|
||||
)
|
||||
}
|
||||
const { commonDirectory, worktrees } = targets ?? (await repositoryTargets(repoRoot))
|
||||
const cutoff = nowMs - minAgeDays * DAY_MS
|
||||
const result = { entries: 0, logicalBytes: 0, protected: 0 }
|
||||
|
||||
async function processEntry(path, commit, format, recheckParents, verifyLease = () => {}) {
|
||||
const info = await directory(path)
|
||||
if (!info || info.mtimeMs > cutoff || !(await matchingStamp(path, commit, format))) {
|
||||
return
|
||||
}
|
||||
let release
|
||||
try {
|
||||
if (apply) {
|
||||
release = await lock(path, { realpath: false, stale: 60_000, retries: 0 })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ELOCKED') {
|
||||
throw error
|
||||
}
|
||||
result.protected += 1
|
||||
report(`skip locked cache ${JSON.stringify(path)}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const bytes = await logicalBytes(path)
|
||||
if (apply) {
|
||||
verifyLease()
|
||||
if (!(await recheckParents())) {
|
||||
return
|
||||
}
|
||||
const current = await directory(path)
|
||||
if (
|
||||
!current ||
|
||||
current.dev !== info.dev ||
|
||||
current.ino !== info.ino ||
|
||||
current.mtimeMs > cutoff ||
|
||||
!(await matchingStamp(path, commit, format))
|
||||
) {
|
||||
return
|
||||
}
|
||||
verifyLease()
|
||||
await rm(path, { recursive: true, force: true })
|
||||
}
|
||||
result.entries += 1
|
||||
result.logicalBytes += bytes
|
||||
report(
|
||||
`${apply ? 'removed' : 'would remove'} ${JSON.stringify(path)} ${(bytes / 1024 ** 2).toFixed(1)} MiB logical`
|
||||
)
|
||||
} finally {
|
||||
await release?.()
|
||||
}
|
||||
}
|
||||
|
||||
for (const worktree of new Set(worktrees)) {
|
||||
const cache = await descend(worktree, ['tests', 'e2e', '.cross-version-checkouts'])
|
||||
if (!cache) {
|
||||
continue
|
||||
}
|
||||
for (const label of await readdir(cache, { withFileTypes: true })) {
|
||||
if (!label.isDirectory() || label.isSymbolicLink()) {
|
||||
continue
|
||||
}
|
||||
const labelRoot = join(cache, label.name)
|
||||
for (const entry of await readdir(labelRoot, { withFileTypes: true })) {
|
||||
const match = LOCAL_ENTRY.exec(entry.name)
|
||||
if (!entry.isDirectory() || !match || Number(match[2]) > CHECKOUT_FORMAT) {
|
||||
continue
|
||||
}
|
||||
await processEntry(join(labelRoot, entry.name), match[1], Number(match[2]), async () =>
|
||||
Boolean(await descend(worktree, ['tests', 'e2e', '.cross-version-checkouts', label.name]))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shared = await descend(commonDirectory, ['orca-cache', 'release-source'])
|
||||
if (shared) {
|
||||
for (const entry of await readdir(shared, { withFileTypes: true })) {
|
||||
const match = /^format-([1-9]\d*)$/.exec(entry.name)
|
||||
if (!entry.isDirectory() || !match || Number(match[1]) > CHECKOUT_FORMAT) {
|
||||
continue
|
||||
}
|
||||
if (!(await directory(join(shared, entry.name)))) {
|
||||
continue
|
||||
}
|
||||
const format = Number(match[1])
|
||||
const visit = async (formatRoot, verifyLease) => {
|
||||
for (const seed of await readdir(formatRoot, { withFileTypes: true })) {
|
||||
const key = SHARED_ENTRY.exec(seed.name)
|
||||
if (!seed.isDirectory() || !key) {
|
||||
continue
|
||||
}
|
||||
await processEntry(
|
||||
join(formatRoot, seed.name),
|
||||
key[1],
|
||||
format,
|
||||
async () =>
|
||||
Boolean(await descend(commonDirectory, ['orca-cache', 'release-source', entry.name])),
|
||||
verifyLease
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!apply) {
|
||||
await visit(join(shared, entry.name), () => {})
|
||||
} else {
|
||||
try {
|
||||
await withReleaseSourceFormatLease(shared, format, visit, {
|
||||
realpath: false,
|
||||
stale: 60_000,
|
||||
update: 10_000,
|
||||
retries: {
|
||||
retries: 0,
|
||||
factor: 1,
|
||||
minTimeout: 250,
|
||||
maxTimeout: 250,
|
||||
randomize: false
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code !== 'ELOCKED') {
|
||||
throw error
|
||||
}
|
||||
result.protected += 1
|
||||
report(`skip locked shared format ${entry.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
report(
|
||||
`${apply ? 'Removed' : 'Would remove'} ${result.entries} cache(s); ${(result.logicalBytes / 1024 ** 3).toFixed(2)} GiB logical file data. Shared blocks affect actual savings.`
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const options = {}
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const argument = process.argv[index]
|
||||
if (argument === '--help') {
|
||||
console.log(
|
||||
'Usage: pnpm run reclaim:worktree-space [--repo DIRECTORY] [--release-cache-min-age-days 30] [--apply --tests-stopped]'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
if (argument === '--apply') {
|
||||
options.apply = true
|
||||
} else if (argument === '--tests-stopped') {
|
||||
options.testsStopped = true
|
||||
} else if (argument === '--repo' || argument === '--min-age-days') {
|
||||
const value = process.argv[++index]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${argument} requires a value`)
|
||||
}
|
||||
if (argument === '--repo') {
|
||||
options.repoRoot = resolve(value)
|
||||
} else {
|
||||
options.minAgeDays = Number(value)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`)
|
||||
}
|
||||
}
|
||||
await reclaimReleaseTestCaches(options).catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { lock } from 'proper-lockfile'
|
||||
import { reclaimReleaseTestCaches } from './reclaim-release-test-caches.mjs'
|
||||
import { withReleaseSourceLease } from '../../tests/e2e/cross-version-wire/release-source-retention'
|
||||
import { CHECKOUT_FORMAT } from '../../tests/e2e/cross-version-wire/release-checkout-publication'
|
||||
|
||||
const roots: string[] = []
|
||||
const commit = 'c'.repeat(40)
|
||||
const nowMs = Date.now()
|
||||
const day = 24 * 60 * 60 * 1000
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-release-cache-cleanup-'))
|
||||
roots.push(root)
|
||||
const worktree = join(root, 'worktree')
|
||||
const commonDirectory = join(root, 'git')
|
||||
const cache = join(worktree, 'tests', 'e2e', '.cross-version-checkouts')
|
||||
const shared = join(commonDirectory, 'orca-cache', 'release-source')
|
||||
await mkdir(cache, { recursive: true })
|
||||
await mkdir(shared, { recursive: true })
|
||||
const targets = { commonDirectory, worktrees: [worktree, worktree] }
|
||||
return { root, worktree, cache, shared, targets, nowMs, report: () => {} }
|
||||
}
|
||||
|
||||
async function stamped(path: string, format = CHECKOUT_FORMAT, ageDays = 60) {
|
||||
await mkdir(path, { recursive: true })
|
||||
await writeFile(join(path, 'checkout-stamp.json'), JSON.stringify({ commit, format }))
|
||||
await writeFile(join(path, 'source.ts'), 'export const historical = true\n')
|
||||
const time = new Date(nowMs - ageDays * day)
|
||||
await utimes(path, time, time)
|
||||
return path
|
||||
}
|
||||
|
||||
it('previews old stamped views and seeds without changing them', async () => {
|
||||
const options = await fixture()
|
||||
const local = await stamped(join(options.cache, 'v-old', `${commit}-format-4`), 4)
|
||||
const seed = await stamped(join(options.shared, `format-${CHECKOUT_FORMAT}`, commit))
|
||||
const result = await reclaimReleaseTestCaches(options)
|
||||
expect(result.entries).toBe(2)
|
||||
expect(result.logicalBytes).toBeGreaterThan(0)
|
||||
expect((await lstat(local)).isDirectory()).toBe(true)
|
||||
expect((await lstat(seed)).isDirectory()).toBe(true)
|
||||
await expect(lstat(`${local}.lock`)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('requires stopped-test acknowledgement before deleting anything', async () => {
|
||||
const options = await fixture()
|
||||
const local = await stamped(join(options.cache, 'v-old', `${commit}-format-4`), 4)
|
||||
await expect(reclaimReleaseTestCaches({ ...options, apply: true })).rejects.toThrow(
|
||||
'--tests-stopped'
|
||||
)
|
||||
expect((await lstat(local)).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it('removes old generated trees and retains recent, future, malformed and mutable trees', async () => {
|
||||
const options = await fixture()
|
||||
const old = await stamped(join(options.cache, 'v-old', `${commit}-format-4`), 4)
|
||||
const seed = await stamped(join(options.shared, `format-${CHECKOUT_FORMAT}`, commit))
|
||||
const recent = await stamped(
|
||||
join(options.cache, 'v-new', `${commit}-format-${CHECKOUT_FORMAT}`),
|
||||
CHECKOUT_FORMAT,
|
||||
1
|
||||
)
|
||||
const future = await stamped(
|
||||
join(options.shared, `format-${CHECKOUT_FORMAT + 1}`, commit),
|
||||
CHECKOUT_FORMAT + 1
|
||||
)
|
||||
const malformed = await stamped(join(options.cache, 'invalid', `${commit}-format-4`), 5)
|
||||
const mutable = join(options.cache, 'working-tree', 'src', 'current.ts')
|
||||
await mkdir(join(mutable, '..'), { recursive: true })
|
||||
await writeFile(mutable, 'mutable working tree copy')
|
||||
const result = await reclaimReleaseTestCaches({ ...options, apply: true, testsStopped: true })
|
||||
expect(result.entries).toBe(2)
|
||||
for (const path of [old, seed]) {
|
||||
await expect(lstat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
for (const path of [recent, future, malformed]) {
|
||||
expect((await lstat(path)).isDirectory()).toBe(true)
|
||||
}
|
||||
expect(await readFile(mutable, 'utf8')).toBe('mutable working tree copy')
|
||||
})
|
||||
|
||||
it('does not follow cache, label, entry or shared-format symlinks', async () => {
|
||||
const options = await fixture()
|
||||
const outside = await stamped(join(options.root, 'outside', `${commit}-format-4`), 4)
|
||||
const second = join(options.root, 'linked-worktree')
|
||||
await mkdir(join(second, 'tests', 'e2e'), { recursive: true })
|
||||
await symlink(
|
||||
join(outside, '..'),
|
||||
join(second, 'tests', 'e2e', '.cross-version-checkouts'),
|
||||
'junction'
|
||||
)
|
||||
options.targets.worktrees.push(second)
|
||||
await symlink(join(outside, '..'), join(options.cache, 'linked-label'), 'junction')
|
||||
await mkdir(join(options.cache, 'linked-entry'))
|
||||
await symlink(outside, join(options.cache, 'linked-entry', `${commit}-format-4`), 'junction')
|
||||
await symlink(join(outside, '..'), join(options.shared, 'format-4'), 'junction')
|
||||
const result = await reclaimReleaseTestCaches({
|
||||
...options,
|
||||
apply: true,
|
||||
testsStopped: true,
|
||||
minAgeDays: 0
|
||||
})
|
||||
expect(result.entries).toBe(0)
|
||||
expect(await readFile(join(outside, 'source.ts'), 'utf8')).toContain('historical')
|
||||
})
|
||||
|
||||
it('skips publication locks and a shared source copy lease', async () => {
|
||||
const options = await fixture()
|
||||
const local = await stamped(join(options.cache, 'v-old', `${commit}-format-4`), 4)
|
||||
const seed = await stamped(join(options.shared, `format-${CHECKOUT_FORMAT}`, commit))
|
||||
const release = await lock(local, { realpath: false })
|
||||
try {
|
||||
await withReleaseSourceLease(options.shared, async () => {
|
||||
const result = await reclaimReleaseTestCaches({ ...options, apply: true, testsStopped: true })
|
||||
expect(result.entries).toBe(0)
|
||||
expect(result.protected).toBe(2)
|
||||
})
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
expect((await lstat(local)).isDirectory()).toBe(true)
|
||||
expect((await lstat(seed)).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
'rejects invalid minimum age %s',
|
||||
async (age) => {
|
||||
await expect(reclaimReleaseTestCaches({ minAgeDays: age })).rejects.toThrow('--min-age-days')
|
||||
}
|
||||
)
|
||||
@@ -7,10 +7,19 @@ import { runProcess } from '@orca/process-host'
|
||||
export async function reclaimWorktreeSpace({
|
||||
repoRoot = process.cwd(),
|
||||
apply = false,
|
||||
automatic = false,
|
||||
execute = runProcess,
|
||||
acquireLock = lock,
|
||||
commonDirectory
|
||||
commonDirectory,
|
||||
testsStopped = false,
|
||||
releaseCacheMinAgeDays = 30
|
||||
} = {}) {
|
||||
if (apply && !automatic && !testsStopped) {
|
||||
throw new Error('Stop cross-version tests, then pass --tests-stopped with --apply')
|
||||
}
|
||||
if (!Number.isFinite(releaseCacheMinAgeDays) || releaseCacheMinAgeDays < 0) {
|
||||
throw new Error('--release-cache-min-age-days requires a non-negative number')
|
||||
}
|
||||
if (!commonDirectory) {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
@@ -37,11 +46,21 @@ export async function reclaimWorktreeSpace({
|
||||
let cleanupError
|
||||
try {
|
||||
const scripts = ['reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs']
|
||||
if (!automatic) {
|
||||
scripts.unshift('reclaim-release-test-caches.mjs')
|
||||
}
|
||||
for (const script of scripts) {
|
||||
const args = [resolve(import.meta.dirname, script), '--repo', repoRoot]
|
||||
if (apply) {
|
||||
args.push('--apply')
|
||||
}
|
||||
if (script === 'reclaim-release-test-caches.mjs') {
|
||||
args.unshift('--import', import.meta.resolve('tsx'))
|
||||
args.push('--min-age-days', String(releaseCacheMinAgeDays))
|
||||
if (testsStopped) {
|
||||
args.push('--tests-stopped')
|
||||
}
|
||||
}
|
||||
const result = await execute({
|
||||
program: process.execPath,
|
||||
args,
|
||||
@@ -76,18 +95,21 @@ export async function reclaimWorktreeSpace({
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
if (process.argv.includes('--help')) {
|
||||
console.log('Usage: pnpm run reclaim:worktree-space [--repo DIRECTORY] [--apply]')
|
||||
console.log(
|
||||
'Previews Electron distribution sharing and idle dev bundle removal; --apply performs them.'
|
||||
'Usage: pnpm run reclaim:worktree-space [--repo DIRECTORY] [--release-cache-min-age-days 30] [--apply --tests-stopped]'
|
||||
)
|
||||
console.log(
|
||||
'Previews historical test cache cleanup, Electron sharing, and idle dev bundle removal.'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
const repoIndex = process.argv.indexOf('--repo')
|
||||
const ageIndex = process.argv.indexOf('--release-cache-min-age-days')
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const argument = process.argv[index]
|
||||
if (argument === '--repo') {
|
||||
if (argument === '--repo' || argument === '--release-cache-min-age-days') {
|
||||
index += 1
|
||||
} else if (argument !== '--apply') {
|
||||
} else if (argument !== '--apply' && argument !== '--tests-stopped') {
|
||||
throw new Error(`Unknown argument: ${argument}`)
|
||||
}
|
||||
}
|
||||
@@ -97,9 +119,17 @@ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename
|
||||
) {
|
||||
throw new Error('--repo requires a directory')
|
||||
}
|
||||
if (
|
||||
ageIndex !== -1 &&
|
||||
(!process.argv[ageIndex + 1] || process.argv[ageIndex + 1].startsWith('--'))
|
||||
) {
|
||||
throw new Error('--release-cache-min-age-days requires a number')
|
||||
}
|
||||
await reclaimWorktreeSpace({
|
||||
repoRoot: repoIndex !== -1 ? resolve(process.argv[repoIndex + 1]) : process.cwd(),
|
||||
apply: process.argv.includes('--apply')
|
||||
apply: process.argv.includes('--apply'),
|
||||
testsStopped: process.argv.includes('--tests-stopped'),
|
||||
releaseCacheMinAgeDays: ageIndex === -1 ? 30 : Number(process.argv[ageIndex + 1])
|
||||
}).catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { reclaimWorktreeSpace } from './reclaim-worktree-space.mjs'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
|
||||
const roots = []
|
||||
afterEach(async () => {
|
||||
@@ -17,26 +18,114 @@ async function fixture() {
|
||||
return { commonDirectory: root, repoRoot: root, execute }
|
||||
}
|
||||
|
||||
it('previews both existing cleanup operations without applying either', async () => {
|
||||
it('previews historical test caches and both Electron cleanup operations', async () => {
|
||||
const options = await fixture()
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(2)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply')
|
||||
})
|
||||
|
||||
it('forwards apply and repository selection to both cleanup operations', async () => {
|
||||
const options = { ...(await fixture()), apply: true }
|
||||
it('forwards apply and repository selection to all cleanup operations', async () => {
|
||||
const options = { ...(await fixture()), apply: true, testsStopped: true }
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(2)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
for (const [spec] of options.execute.mock.calls) {
|
||||
expect(spec.args).toContain('--apply')
|
||||
expect(spec.args).toContain(options.repoRoot)
|
||||
expect(spec.terminationBarrier).toBe(true)
|
||||
}
|
||||
const cache = options.execute.mock.calls[0][0]
|
||||
expect(cache.args).toContain('--tests-stopped')
|
||||
expect(cache.args).toContain('--import')
|
||||
for (const [spec] of options.execute.mock.calls.slice(1)) {
|
||||
expect(spec.args).not.toContain('--tests-stopped')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects manual apply before any operation unless cross-version tests are stopped', async () => {
|
||||
const options = { ...(await fixture()), apply: true }
|
||||
await expect(reclaimWorktreeSpace(options)).rejects.toThrow('--tests-stopped')
|
||||
expect(options.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves historical caches out of automatic invocations', async () => {
|
||||
const options = { ...(await fixture()), automatic: true, apply: true }
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain(
|
||||
'reclaim-release-test-caches'
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards an age override only to historical test cache cleanup', async () => {
|
||||
const options = { ...(await fixture()), releaseCacheMinAgeDays: 7 }
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute.mock.calls[0][0].args).toEqual(
|
||||
expect.arrayContaining(['--min-age-days', '7'])
|
||||
)
|
||||
for (const [spec] of options.execute.mock.calls.slice(1)) {
|
||||
expect(spec.args).not.toContain('--min-age-days')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid cache age before starting cleanup', async () => {
|
||||
const options = { ...(await fixture()), releaseCacheMinAgeDays: -1 }
|
||||
await expect(reclaimWorktreeSpace(options)).rejects.toThrow('--release-cache-min-age-days')
|
||||
expect(options.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the combined CLI from outside a disposable repository and requires acknowledgement', async () => {
|
||||
const options = await fixture()
|
||||
const init = await runProcess({
|
||||
program: 'git',
|
||||
args: ['init', '--quiet'],
|
||||
cwd: options.repoRoot,
|
||||
timeoutMs: 10_000,
|
||||
terminationBarrier: true
|
||||
})
|
||||
expect(init.code).toBe(0)
|
||||
const commit = 'd'.repeat(40)
|
||||
const cache = join(
|
||||
options.repoRoot,
|
||||
'tests',
|
||||
'e2e',
|
||||
'.cross-version-checkouts',
|
||||
'old',
|
||||
`${commit}-format-4`
|
||||
)
|
||||
await mkdir(cache, { recursive: true })
|
||||
const marker = join(cache, 'checkout-stamp.json')
|
||||
await writeFile(marker, JSON.stringify({ commit, format: 4 }))
|
||||
await utimes(cache, new Date(0), new Date(0))
|
||||
const run = (flags = []) =>
|
||||
runProcess({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
resolve(import.meta.dirname, 'reclaim-worktree-space.mjs'),
|
||||
'--repo',
|
||||
options.repoRoot,
|
||||
...flags
|
||||
],
|
||||
cwd: tmpdir(),
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 20_000,
|
||||
terminationBarrier: true
|
||||
})
|
||||
const preview = await run()
|
||||
expect(preview.code, preview.stderr).toBe(0)
|
||||
expect(preview.stdout).toContain('Would remove 1 cache(s)')
|
||||
expect(await readFile(marker, 'utf8')).toContain(commit)
|
||||
const blocked = await run(['--apply'])
|
||||
expect(blocked.code).not.toBe(0)
|
||||
expect(blocked.stderr).toContain('--tests-stopped')
|
||||
expect(await readFile(marker, 'utf8')).toContain(commit)
|
||||
const applied = await run(['--apply', '--tests-stopped'])
|
||||
expect(applied.code, applied.stderr).toBe(0)
|
||||
expect(applied.stdout).toContain('Removed 1 cache(s)')
|
||||
await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects competing sweeps and releases the lock after a failed operation', async () => {
|
||||
const options = { ...(await fixture()), apply: true }
|
||||
const options = { ...(await fixture()), apply: true, testsStopped: true }
|
||||
let release
|
||||
const blocked = new Promise((resolve) => {
|
||||
release = resolve
|
||||
@@ -58,7 +147,7 @@ it('rejects competing sweeps and releases the lock after a failed operation', as
|
||||
await rejected
|
||||
options.execute.mockResolvedValue({ code: 0, timedOut: false })
|
||||
await reclaimWorktreeSpace(options)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
expect(options.execute).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => {
|
||||
@@ -85,5 +174,5 @@ it('fails if cleanup succeeds but the repository lock cannot be released', async
|
||||
await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe(
|
||||
failure
|
||||
)
|
||||
expect(options.execute).toHaveBeenCalledTimes(2)
|
||||
expect(options.execute).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copies observations written under ORCA_LAYOUT_COMPAT_REPORT_DIR (<scenario>.<platform>.json)
|
||||
// into tests/e2e/layout-compat-baselines.json. Usage: node <this> <report dir>
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const reportDir = process.argv[2]
|
||||
if (!reportDir) {
|
||||
throw new Error('usage: record-layout-compat-baselines.mjs <report dir>')
|
||||
}
|
||||
const baselineFile = path.join(
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
'..',
|
||||
'tests',
|
||||
'e2e',
|
||||
'layout-compat-baselines.json'
|
||||
)
|
||||
const baselines = JSON.parse(readFileSync(baselineFile, 'utf8'))
|
||||
for (const name of readdirSync(reportDir).filter((file) => file.endsWith('.json'))) {
|
||||
const match = /^(.+)\.(darwin|linux|win32)\.json$/.exec(name)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const [, scenario, platform] = match
|
||||
baselines.scenarios[scenario] = {
|
||||
...baselines.scenarios[scenario],
|
||||
[platform]: JSON.parse(readFileSync(path.join(reportDir, name), 'utf8'))
|
||||
}
|
||||
console.log(`recorded ${scenario} (${platform})`)
|
||||
}
|
||||
baselines.scenarios = Object.fromEntries(
|
||||
Object.entries(baselines.scenarios).sort(([a], [b]) => a.localeCompare(b))
|
||||
)
|
||||
writeFileSync(baselineFile, `${JSON.stringify(baselines, null, 2)}\n`)
|
||||
@@ -11,10 +11,9 @@ const EXPECTED_MATRIX = {
|
||||
'.github/workflows/e2e.yml#build': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-auto-convert-docker': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-journeys-docker': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-ssh-docker': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' },
|
||||
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
|
||||
|
||||
@@ -159,11 +159,17 @@ function buildAppIfNeeded(root, skipBuild) {
|
||||
throw new Error(`--skip-build requested, but ${mainPath} does not exist`)
|
||||
}
|
||||
console.log('[idle-cpu] building Electron app with electron-vite --mode e2e')
|
||||
run('npx', ['electron-vite', 'build', '--mode', 'e2e'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, VITE_EXPOSE_STORE: 'true' }
|
||||
})
|
||||
// Why through the wrapper: `npx` only resolves to an executable on POSIX; on Windows it is
|
||||
// `npx.cmd`, which execFileSync cannot run, and the wrapper also verifies the boot graph.
|
||||
run(
|
||||
process.execPath,
|
||||
[path.join(root, 'config', 'scripts', 'run-electron-vite-build.mjs'), '--mode', 'e2e'],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, VITE_EXPOSE_STORE: 'true' }
|
||||
}
|
||||
)
|
||||
return mainPath
|
||||
}
|
||||
|
||||
@@ -348,7 +354,7 @@ async function main() {
|
||||
return { error: added.error }
|
||||
}
|
||||
const store = window.__store
|
||||
await store?.getState().fetchRepos()
|
||||
await store?.getState().fetchRepos({ runtimeEnvironmentId: null })
|
||||
const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id)
|
||||
if (repo) {
|
||||
const detected = await store
|
||||
|
||||
@@ -15,7 +15,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(
|
||||
@@ -55,19 +55,20 @@ for (const [name, value] of [
|
||||
}
|
||||
|
||||
const { capTerminalScrollbackSessionBuffer } = await import(
|
||||
path.join(ROOT, 'src/shared/workspace-session-terminal-buffers.ts')
|
||||
pathToFileURL(path.join(ROOT, 'src/shared/workspace-session-terminal-buffers.ts')).href
|
||||
)
|
||||
const { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } = await import(
|
||||
path.join(ROOT, 'src/shared/terminal-scrollback-limits.ts')
|
||||
pathToFileURL(path.join(ROOT, 'src/shared/terminal-scrollback-limits.ts')).href
|
||||
)
|
||||
const { remapAcknowledgedAgentPaneKeys } = await import(
|
||||
path.join(ROOT, 'src/main/persistence/restoring-sessions/pane-key-remapping.ts')
|
||||
pathToFileURL(path.join(ROOT, 'src/main/persistence/restoring-sessions/pane-key-remapping.ts'))
|
||||
.href
|
||||
)
|
||||
const { clampUtf8TextTail, measureUtf8ByteLength } = await import(
|
||||
path.join(ROOT, 'src/shared/utf8-byte-limits.ts')
|
||||
pathToFileURL(path.join(ROOT, 'src/shared/utf8-byte-limits.ts')).href
|
||||
)
|
||||
const { isTerminalLeafId, makePaneKey, parsePaneKey } = await import(
|
||||
path.join(ROOT, 'src/shared/stable-pane-id.ts')
|
||||
pathToFileURL(path.join(ROOT, 'src/shared/stable-pane-id.ts')).href
|
||||
)
|
||||
|
||||
function median(samples) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
statSync,
|
||||
symlinkSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { join, relative } from 'node:path'
|
||||
import * as filesystem from 'node:fs/promises'
|
||||
|
||||
// -c asks for clonefile(2). -P keeps Electron.framework's relative symlinks as symlinks; resolving
|
||||
// them breaks Chromium's bundle lookup.
|
||||
@@ -173,3 +174,83 @@ function copyTreeVerbatim(sourcePath, destinationPath) {
|
||||
verbatimSymlinks: true
|
||||
})
|
||||
}
|
||||
|
||||
export async function shareTreeAsync(sourcePath, destinationPath, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
const errors = []
|
||||
for (const mechanism of getShareMechanisms(platform)) {
|
||||
try {
|
||||
await (options[mechanism] ?? asyncShareMechanisms[mechanism])(sourcePath, destinationPath)
|
||||
return mechanism
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
await filesystem.rm(destinationPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
throw new AggregateError(errors, `Could not share storage for ${destinationPath}`)
|
||||
}
|
||||
|
||||
async function runSharingCopy(program, args) {
|
||||
const { runProcess } = await import('@orca/process-host')
|
||||
const result = await runProcess({ program, args, timeoutMs: 45_000, terminationBarrier: true })
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(`${program} could not share source storage`)
|
||||
}
|
||||
}
|
||||
|
||||
const asyncShareMechanisms = {
|
||||
clone: (source, destination) =>
|
||||
runSharingCopy('/bin/cp', [...MACOS_CLONE_ARGS, source, destination]),
|
||||
reflink: (source, destination) =>
|
||||
runSharingCopy('cp', [...LINUX_REFLINK_ARGS, source, destination]),
|
||||
hardlink: async (source, destination) => {
|
||||
await walkTreeAsync(source, async (directory, entries) => {
|
||||
const target = join(destination, relative(source, directory))
|
||||
await filesystem.mkdir(target, { recursive: true })
|
||||
return entries.map((entry) => async () => {
|
||||
const from = join(directory, entry.name)
|
||||
const to = join(target, entry.name)
|
||||
if (entry.isSymbolicLink()) {
|
||||
await filesystem.symlink(await filesystem.readlink(from), to)
|
||||
} else if (!entry.isDirectory()) {
|
||||
await filesystem.link(from, to)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function walkTreeAsync(root, visitDirectory) {
|
||||
const directories = [root]
|
||||
while (directories.length > 0) {
|
||||
const directory = directories.pop()
|
||||
const entries = await filesystem.readdir(directory, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
directories.push(join(directory, entry.name))
|
||||
}
|
||||
}
|
||||
const operations = await visitDirectory(directory, entries)
|
||||
for (let offset = 0; offset < operations.length; offset += 32) {
|
||||
const settled = await Promise.allSettled(
|
||||
operations.slice(offset, offset + 32).map((run) => run())
|
||||
)
|
||||
for (const result of settled) {
|
||||
if (result.status === 'rejected') {
|
||||
throw result.reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeTreeReadOnlyAsync(root) {
|
||||
await walkTreeAsync(root, (directory, entries) =>
|
||||
entries
|
||||
.filter((entry) => !entry.isDirectory() && !entry.isSymbolicLink())
|
||||
.map((entry) => async () => {
|
||||
const file = join(directory, entry.name)
|
||||
await filesystem.chmod(file, (await filesystem.stat(file)).mode & ~0o222)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,12 +19,66 @@ import {
|
||||
copyPrivateTree,
|
||||
hardlinkTree,
|
||||
makeTreeReadOnly,
|
||||
makeTreeReadOnlyAsync,
|
||||
makeTreeWritable,
|
||||
shareTree
|
||||
shareTree,
|
||||
shareTreeAsync
|
||||
} from './space-sharing-copy.mjs'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
describe('asynchronous immutable sharing', () => {
|
||||
it('preserves read-only source and relative symlinks on the execution host', async () => {
|
||||
const { root, source } = makeTree()
|
||||
await makeTreeReadOnlyAsync(source)
|
||||
const destination = path.join(root, 'async-copy')
|
||||
await shareTreeAsync(source, destination)
|
||||
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
|
||||
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
|
||||
expect(statSync(path.join(destination, 'nested', 'file')).mode & 0o222).toBe(0)
|
||||
})
|
||||
|
||||
it('removes partial copies before its Linux hardlink fallback', async () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'async-copy')
|
||||
const reflink = async (_source: string, target: string) => {
|
||||
mkdirSync(target)
|
||||
writeFileSync(path.join(target, 'partial'), 'partial')
|
||||
throw new Error('unsupported reflink')
|
||||
}
|
||||
expect(await shareTreeAsync(source, destination, { platform: 'linux', reflink })).toBe(
|
||||
'hardlink'
|
||||
)
|
||||
expect(existsSync(path.join(destination, 'partial'))).toBe(false)
|
||||
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
|
||||
statSync(path.join(source, 'nested', 'file')).ino
|
||||
)
|
||||
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
|
||||
})
|
||||
|
||||
it('keeps the event loop available while a sharing process is pending', async () => {
|
||||
const { root, source } = makeTree()
|
||||
let finish!: () => void
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
let completed = false
|
||||
const sharing = shareTreeAsync(source, path.join(root, 'async-copy'), {
|
||||
platform: 'darwin',
|
||||
clone: async () => {
|
||||
await pending
|
||||
}
|
||||
}).then(() => {
|
||||
completed = true
|
||||
})
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(completed).toBe(false)
|
||||
finish()
|
||||
await sharing
|
||||
expect(completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) {
|
||||
rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
|
||||
@@ -4,11 +4,13 @@ import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolveBuildSourceCommit } from './build-source-commit.mjs'
|
||||
|
||||
// Electron packaging restamps the channel-specific version after compilation.
|
||||
function buildOutPackageJson(version) {
|
||||
// Electron packaging restamps the channel-specific version after compilation; `commit`
|
||||
// is what the tsc-compiled CLI pins official skill installs to.
|
||||
function buildOutPackageJson(version, commit) {
|
||||
return `${JSON.stringify(
|
||||
{ name: 'orca-compiled-output', type: 'commonjs', private: true, version },
|
||||
{ name: 'orca-compiled-output', type: 'commonjs', private: true, version, commit },
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
@@ -22,6 +24,7 @@ export function verifyPackageCliBin({
|
||||
projectDir = path.resolve(import.meta.dirname, '..', '..'),
|
||||
fixExecutable = false,
|
||||
fixPackageJson = false,
|
||||
sourceCommit,
|
||||
runHelp = false
|
||||
} = {}) {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json')
|
||||
@@ -48,7 +51,11 @@ export function verifyPackageCliBin({
|
||||
const outPackageJsonPath = path.join(projectDir, 'out', 'package.json')
|
||||
if (fixPackageJson) {
|
||||
mkdirSync(path.dirname(outPackageJsonPath), { recursive: true })
|
||||
writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8')
|
||||
writeFileSync(
|
||||
outPackageJsonPath,
|
||||
buildOutPackageJson(packageJson.version, sourceCommit),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
let outPackageJson
|
||||
try {
|
||||
@@ -93,6 +100,7 @@ function main() {
|
||||
const result = verifyPackageCliBin({
|
||||
fixExecutable: args.has('--fix-executable'),
|
||||
fixPackageJson: args.has('--fix-package-json'),
|
||||
sourceCommit: args.has('--fix-package-json') ? resolveBuildSourceCommit() : undefined,
|
||||
runHelp: args.has('--run-help')
|
||||
})
|
||||
console.log(
|
||||
|
||||
@@ -77,6 +77,18 @@ describe('verifyPackageCliBin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps the build commit the CLI pins official skill installs to', () => {
|
||||
const { projectDir, outPackageJsonPath } = makeProjectWithCli(
|
||||
'#!/usr/bin/env node\nconsole.log("orca")\n',
|
||||
{ writeOutPackageJson: false }
|
||||
)
|
||||
|
||||
verifyPackageCliBin({ projectDir, fixPackageJson: true, sourceCommit: 'c'.repeat(40) })
|
||||
|
||||
expect(JSON.parse(readFileSync(outPackageJsonPath, 'utf8')).commit).toBe('c'.repeat(40))
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects a CLI package boundary that is not CommonJS', () => {
|
||||
const { projectDir, outPackageJsonPath } = makeProjectWithCli(
|
||||
'#!/usr/bin/env node\nconsole.log("orca")\n'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmod,
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
@@ -20,230 +19,162 @@ function option(name) {
|
||||
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
|
||||
}
|
||||
|
||||
const cliVersion = option('cli')
|
||||
const autocrlf = option('autocrlf')
|
||||
const placement = option('shape')
|
||||
// Why: PR branch names are untrusted workflow input. Keep them out of the
|
||||
// generated shell command and pass them to Node through the environment.
|
||||
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
|
||||
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
|
||||
if (
|
||||
!cliVersion ||
|
||||
(autocrlf !== 'true' && autocrlf !== 'false') ||
|
||||
(placement !== 'symlink' && placement !== 'copy') ||
|
||||
!source ||
|
||||
!ref ||
|
||||
!/^[^/\s]+\/[^/\s]+$/.test(source)
|
||||
) {
|
||||
throw new Error(
|
||||
'Usage: verify-skill-update-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<git-ref>'
|
||||
)
|
||||
// Why: the exact rail Orca runs for both install and update, pinned to this PR's commit.
|
||||
// A unit test holds it, and this pinned skills CLI, equal to the shared builder's argv.
|
||||
const PINNED_SKILLS_CLI_VERSION = '1.7.2'
|
||||
|
||||
export function roundTripAddArgs(source, ref, skillName, cliVersion = PINNED_SKILLS_CLI_VERSION) {
|
||||
return [
|
||||
`skills@${cliVersion}`,
|
||||
'add',
|
||||
`https://github.com/${source}/tree/${ref}`,
|
||||
'--skill',
|
||||
skillName,
|
||||
'--global',
|
||||
'--agent',
|
||||
'claude-code',
|
||||
'--agent',
|
||||
'universal',
|
||||
'-y'
|
||||
]
|
||||
}
|
||||
|
||||
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
|
||||
const home = path.join(sandbox, 'home')
|
||||
const stateHome = path.join(home, '.state')
|
||||
const fakeBin = path.join(sandbox, 'bin')
|
||||
const targetName = 'orca-cli'
|
||||
const controlName = 'orchestration'
|
||||
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
|
||||
const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8'))
|
||||
const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8'))
|
||||
|
||||
function currentSkill(name) {
|
||||
const skill = manifest.skills.find((entry) => entry.name === name)
|
||||
if (!skill) {
|
||||
throw new Error(`Current manifest is missing ${name}`)
|
||||
}
|
||||
return skill
|
||||
}
|
||||
|
||||
function historicalRelease(name) {
|
||||
const current = currentSkill(name)
|
||||
for (const release of releaseMapping.releases.toReversed()) {
|
||||
const revision = release.skills[name]
|
||||
if (typeof revision !== 'number' || revision >= current.releaseRevision) {
|
||||
continue
|
||||
}
|
||||
const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision)
|
||||
if (snapshot) {
|
||||
return { tag: `v${release.appVersion}`, snapshot }
|
||||
}
|
||||
}
|
||||
throw new Error(`No historical released snapshot is available for ${name}`)
|
||||
}
|
||||
|
||||
async function materializePackage(name, tag, destination) {
|
||||
const prefix = `skills/${name}/`
|
||||
const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`])
|
||||
.toString('utf8')
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
if (entries.length === 0) {
|
||||
throw new Error(`${tag} does not contain ${name}`)
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry)
|
||||
if (!match || match[2] !== 'blob') {
|
||||
throw new Error(`Unsupported historical tree entry: ${entry}`)
|
||||
}
|
||||
const relativePath = match[4].slice(prefix.length)
|
||||
const destinationPath = path.join(destination, ...relativePath.split('/'))
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true })
|
||||
await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]]))
|
||||
if (process.platform !== 'win32' && match[1] === '100755') {
|
||||
await chmod(destinationPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedPlacement(name, tag) {
|
||||
const canonical = path.join(home, '.agents', 'skills', name)
|
||||
await materializePackage(name, tag, canonical)
|
||||
const providerRoot = path.join(home, '.claude', 'skills')
|
||||
const provider = path.join(providerRoot, name)
|
||||
await mkdir(providerRoot, { recursive: true })
|
||||
await (placement === 'copy'
|
||||
? cp(canonical, provider, { recursive: true })
|
||||
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
|
||||
}
|
||||
|
||||
async function installFakeAgentCommands() {
|
||||
await mkdir(fakeBin, { recursive: true })
|
||||
for (const name of ['codex', 'claude']) {
|
||||
const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name)
|
||||
await writeFile(
|
||||
executable,
|
||||
process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n'
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(executable, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function packageDigestAt(pathValue) {
|
||||
return packageDigest(await collectPackageFiles(pathValue))
|
||||
}
|
||||
|
||||
async function assertCurrentCanonical(name) {
|
||||
const expected = currentSkill(name).packageDigest
|
||||
const canonical = path.join(home, '.agents', 'skills', name)
|
||||
if ((await packageDigestAt(canonical)) !== expected) {
|
||||
throw new Error(`${name} canonical placement did not update to the PR content`)
|
||||
}
|
||||
}
|
||||
|
||||
function execSkills(args) {
|
||||
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
|
||||
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
|
||||
execFileSync(
|
||||
executable,
|
||||
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CODEX_HOME: path.join(home, '.codex'),
|
||||
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
||||
XDG_STATE_HOME: stateHome,
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.autocrlf',
|
||||
GIT_CONFIG_VALUE_0: autocrlf,
|
||||
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
CI: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const targetHistorical = historicalRelease(targetName)
|
||||
const controlHistorical = historicalRelease(controlName)
|
||||
await installFakeAgentCommands()
|
||||
await mkdir(path.join(home, '.codex'), { recursive: true })
|
||||
await mkdir(path.join(home, '.claude'), { recursive: true })
|
||||
await seedPlacement(targetName, targetHistorical.tag)
|
||||
await seedPlacement(controlName, controlHistorical.tag)
|
||||
const targetProvider = path.join(home, '.claude', 'skills', targetName)
|
||||
const controlCanonical = path.join(home, '.agents', 'skills', controlName)
|
||||
const controlProvider = path.join(home, '.claude', 'skills', controlName)
|
||||
const targetProviderBefore = await packageDigestAt(await realpath(targetProvider))
|
||||
const controlBefore = await packageDigestAt(controlCanonical)
|
||||
const controlProviderBefore = await packageDigestAt(await realpath(controlProvider))
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const lock = {
|
||||
version: 3,
|
||||
skills: {
|
||||
[targetName]: {
|
||||
source,
|
||||
sourceType: 'github',
|
||||
sourceUrl: `https://github.com/${source}.git`,
|
||||
ref,
|
||||
skillPath: `skills/${targetName}/SKILL.md`,
|
||||
skillFolderHash: targetHistorical.snapshot.gitTreeSha,
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
},
|
||||
[controlName]: {
|
||||
source,
|
||||
sourceType: 'github',
|
||||
sourceUrl: `https://github.com/${source}.git`,
|
||||
ref,
|
||||
skillPath: `skills/${controlName}/SKILL.md`,
|
||||
skillFolderHash: controlHistorical.snapshot.gitTreeSha,
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
|
||||
await mkdir(path.dirname(lockPath), { recursive: true })
|
||||
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
|
||||
|
||||
// Why: this is the exact user-visible rail. A bare update would include
|
||||
// unrelated vendors, while this command must leave the control skill alone.
|
||||
execSkills(['update', targetName, '--global'])
|
||||
await assertCurrentCanonical(targetName)
|
||||
const targetProviderAfter = await packageDigestAt(await realpath(targetProvider))
|
||||
const targetProviderStat = await lstat(targetProvider)
|
||||
if (placement === 'symlink' && !targetProviderStat.isSymbolicLink()) {
|
||||
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
|
||||
}
|
||||
if (placement === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) {
|
||||
throw new Error(`${targetName} provider alias did not converge with the canonical update`)
|
||||
}
|
||||
async function main() {
|
||||
// Why: defaults to the version Orca pins; `--cli=latest` previews the next release.
|
||||
const cliVersion = option('cli') ?? PINNED_SKILLS_CLI_VERSION
|
||||
const autocrlf = option('autocrlf')
|
||||
const placement = option('shape')
|
||||
// Why: PR refs are untrusted workflow input. Keep them out of the generated shell
|
||||
// command and pass them to Node through the environment.
|
||||
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
|
||||
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
|
||||
if (
|
||||
placement === 'copy' &&
|
||||
targetProviderAfter !== targetProviderBefore &&
|
||||
targetProviderAfter !== currentSkill(targetName).packageDigest
|
||||
(autocrlf !== 'true' && autocrlf !== 'false') ||
|
||||
(placement !== 'symlink' && placement !== 'copy') ||
|
||||
!source ||
|
||||
!ref ||
|
||||
!/^[^/\s]+\/[^/\s]+$/.test(source) ||
|
||||
// Why: Orca pins to a full commit, never a branch or tag.
|
||||
!/^[0-9a-f]{40}$/.test(ref)
|
||||
) {
|
||||
throw new Error('Independent provider copy changed to an unexpected package identity')
|
||||
throw new Error(
|
||||
'Usage: verify-skill-update-roundtrip.mjs [--cli=<version>] --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<commit>'
|
||||
)
|
||||
}
|
||||
if (placement === 'copy') {
|
||||
// Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs
|
||||
// retain the copy. Both prove this input topology must remain ineligible.
|
||||
const outcome = targetProviderStat.isSymbolicLink()
|
||||
? 'converged to an alias'
|
||||
: targetProviderAfter === targetProviderBefore
|
||||
? 'remained a historical copy'
|
||||
: 'converged as a copy'
|
||||
console.log(`[skill-update-roundtrip] independent copy ${outcome}`)
|
||||
|
||||
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
|
||||
const home = path.join(sandbox, 'home')
|
||||
const stateHome = path.join(home, '.state')
|
||||
const targetName = 'orca-cli'
|
||||
const controlName = 'orchestration'
|
||||
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
|
||||
|
||||
function currentDigest(name) {
|
||||
const skill = manifest.skills.find((entry) => entry.name === name)
|
||||
if (!skill) {
|
||||
throw new Error(`Current manifest is missing ${name}`)
|
||||
}
|
||||
return skill.packageDigest
|
||||
}
|
||||
if ((await packageDigestAt(controlCanonical)) !== controlBefore) {
|
||||
throw new Error('Targeted update changed the non-targeted control skill')
|
||||
|
||||
async function packageDigestAt(directory) {
|
||||
return packageDigest(await collectPackageFiles(await realpath(directory)))
|
||||
}
|
||||
if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) {
|
||||
throw new Error('Targeted update changed the non-targeted control provider placement')
|
||||
|
||||
// Why: the copies #26841 stranded: CRLF bytes from no known release, edited, with a
|
||||
// sha256 lock hash. The pinned add must replace them all the same.
|
||||
async function seedStalePlacement(name) {
|
||||
const canonical = path.join(home, '.agents', 'skills', name)
|
||||
await mkdir(canonical, { recursive: true })
|
||||
const markdown = await readFile(path.join('skills', name, 'SKILL.md'), 'utf8')
|
||||
await writeFile(
|
||||
path.join(canonical, 'SKILL.md'),
|
||||
`${markdown}\nA line no Orca release ever shipped.\n`.replace(/\n/g, '\r\n')
|
||||
)
|
||||
const providerRoot = path.join(home, '.claude', 'skills')
|
||||
const provider = path.join(providerRoot, name)
|
||||
await mkdir(providerRoot, { recursive: true })
|
||||
await (placement === 'copy'
|
||||
? cp(canonical, provider, { recursive: true })
|
||||
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
|
||||
return { canonical, provider }
|
||||
}
|
||||
const controlProviderStat = await lstat(controlProvider)
|
||||
if (placement === 'symlink' && !controlProviderStat.isSymbolicLink()) {
|
||||
throw new Error('Targeted update changed the non-targeted control topology')
|
||||
|
||||
function execSkills(args) {
|
||||
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
|
||||
const cliArgs = ['--yes', ...args]
|
||||
execFileSync(
|
||||
executable,
|
||||
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CODEX_HOME: path.join(home, '.codex'),
|
||||
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
||||
XDG_STATE_HOME: stateHome,
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.autocrlf',
|
||||
GIT_CONFIG_VALUE_0: autocrlf,
|
||||
CI: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const target = await seedStalePlacement(targetName)
|
||||
const control = await seedStalePlacement(controlName)
|
||||
const controlBefore = await packageDigestAt(control.canonical)
|
||||
const lock = {
|
||||
version: 3,
|
||||
skills: Object.fromEntries(
|
||||
[targetName, controlName].map((name) => [
|
||||
name,
|
||||
{
|
||||
source,
|
||||
sourceType: 'github',
|
||||
sourceUrl: `https://github.com/${source}.git`,
|
||||
skillPath: `skills/${name}/SKILL.md`,
|
||||
skillFolderHash: 'f'.repeat(64)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
|
||||
await mkdir(path.dirname(lockPath), { recursive: true })
|
||||
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
|
||||
|
||||
execSkills(roundTripAddArgs(source, ref, targetName, cliVersion))
|
||||
|
||||
const expected = currentDigest(targetName)
|
||||
if ((await packageDigestAt(target.canonical)) !== expected) {
|
||||
throw new Error(`${targetName} canonical placement did not become the pinned content`)
|
||||
}
|
||||
if ((await packageDigestAt(target.provider)) !== expected) {
|
||||
throw new Error(`${targetName} claude placement did not become the pinned content`)
|
||||
}
|
||||
if (placement === 'symlink' && !(await lstat(target.provider)).isSymbolicLink()) {
|
||||
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
|
||||
}
|
||||
const recorded = JSON.parse(await readFile(lockPath, 'utf8')).skills?.[targetName]?.source
|
||||
if (typeof recorded !== 'string' || recorded.toLowerCase() !== source.toLowerCase()) {
|
||||
throw new Error(`${targetName} lock source is ${recorded}, not ${source}`)
|
||||
}
|
||||
if ((await packageDigestAt(control.canonical)) !== controlBefore) {
|
||||
throw new Error('Targeted add changed the non-targeted control skill')
|
||||
}
|
||||
if (placement === 'symlink' && !(await lstat(control.provider)).isSymbolicLink()) {
|
||||
throw new Error('Targeted add changed the non-targeted control topology')
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
await main()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentFeatureSkillInstallArgs } from '../../src/shared/agent-feature-install-commands'
|
||||
import { roundTripAddArgs } from './verify-skill-update-roundtrip.mjs'
|
||||
|
||||
const COMMIT = 'a'.repeat(40)
|
||||
|
||||
describe('skill update round trip', () => {
|
||||
it('runs exactly the shared builder’s unattended add, pinned skills CLI included', () => {
|
||||
expect(roundTripAddArgs('stablyai/orca', COMMIT, 'orca-cli')).toEqual(
|
||||
buildAgentFeatureSkillInstallArgs(['orca-cli'], {
|
||||
sourceCommit: COMMIT,
|
||||
yes: true,
|
||||
agents: ['claude-code', 'universal']
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -120,11 +120,13 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts',
|
||||
@@ -215,6 +217,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
|
||||
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts',
|
||||
'src/main/runtime/orca-runtime.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts',
|
||||
@@ -235,6 +238,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts',
|
||||
'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts',
|
||||
'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts',
|
||||
'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts',
|
||||
'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts',
|
||||
'src/main/runtime/structured-agent-session-integration-replay.test.ts',
|
||||
'src/main/runtime/structured-agent-session-integration.test.ts',
|
||||
@@ -265,6 +269,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts',
|
||||
'tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts',
|
||||
'tests/e2e/folder-upgrade-identity-persistence.unit.test.ts',
|
||||
'tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts',
|
||||
'tests/e2e/structured-agent-session-read-owner.unit.test.ts',
|
||||
'tests/e2e/structured-chat-owner-status-activation.unit.test.ts'
|
||||
]
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseAllDocuments } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('Windows process-tree patch contract', () => {
|
||||
it('keeps the patch LF-only and hash-synced with the lockfile', () => {
|
||||
const patchBytes = readFileSync(
|
||||
join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch')
|
||||
)
|
||||
// pnpm hashes patches CRLF-normalized, so CR bytes cannot affect install
|
||||
// behavior; keep the file LF-only so the bytes match main and diff clean.
|
||||
expect(patchBytes.includes(0x0d)).toBe(false)
|
||||
const patchHash = createHash('sha256')
|
||||
.update(patchBytes.toString('utf8').replaceAll('\r\n', '\n'))
|
||||
.digest('hex')
|
||||
const lockfile = parseAllDocuments(readFileSync(join(projectDir, 'pnpm-lock.yaml'), 'utf8'))
|
||||
.map((document) => document.toJS())
|
||||
.find((document) => document.patchedDependencies)
|
||||
expect(lockfile.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(patchHash)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Minimal `electron` module for wsl-hook-relay-reattach-benchmark.mjs, which loads the real
|
||||
* src/main/ipc/pty.ts under plain node. Only the surfaces that main's PTY graph touches are
|
||||
* src/main/ipc/pty/ handlers under plain node. Only the surfaces that main's PTY graph touches are
|
||||
* implemented; everything else stays undefined so an unexpected dependency fails loudly.
|
||||
*/
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
|
||||
@@ -287,7 +287,8 @@ async function main() {
|
||||
|
||||
// Why: main's PTY graph reads app paths at import time; keep it inside a disposable directory.
|
||||
let userDataDir
|
||||
let ptyIpc
|
||||
let registerPtyHandlers
|
||||
let killAllPty
|
||||
let cleanupPaths = []
|
||||
let manager = null
|
||||
let staller = null
|
||||
@@ -302,8 +303,9 @@ async function main() {
|
||||
}
|
||||
})
|
||||
// Why sequential: concurrent jiti.import calls can each instantiate the module graph, and two
|
||||
// copies of wsl-hook-relay-manager.ts would leave pty.ts refreshing a singleton we never see.
|
||||
ptyIpc = await jiti.import('../../src/main/ipc/pty.ts')
|
||||
// copies of wsl-hook-relay-manager.ts would leave the PTY graph refreshing a singleton we never see.
|
||||
;({ registerPtyHandlers } = await jiti.import('../../src/main/ipc/pty/register-handlers.ts'))
|
||||
;({ killAllPty } = await jiti.import('../../src/main/ipc/pty/kill-all.ts'))
|
||||
const { WslHookRelayManager, wslHookRelayManager } = await jiti.import(
|
||||
'../../src/main/agent-hooks/wsl-hook-relay-manager.ts'
|
||||
)
|
||||
@@ -318,7 +320,7 @@ async function main() {
|
||||
const { FLOATING_TERMINAL_WORKTREE_ID } = await jiti.import('../../src/shared/constants.ts')
|
||||
|
||||
// Why: if jiti ever returns a second manager module, our monkey-patch would miss the singleton
|
||||
// that ensureWslHookRelayForReattach (loaded via pty.ts) closes over — fail closed now.
|
||||
// that ensureWslHookRelayForReattach (loaded via the PTY graph) closes over — fail closed now.
|
||||
const singletonProbe = []
|
||||
const previousEnsure = wslHookRelayManager.ensureForDistro.bind(wslHookRelayManager)
|
||||
wslHookRelayManager.ensureForDistro = (probedDistro) => {
|
||||
@@ -331,7 +333,7 @@ async function main() {
|
||||
wslHookRelayManager.ensureForDistro = previousEnsure
|
||||
if (singletonProbe.length !== 1 || singletonProbe[0] !== '__bench-singleton-probe__') {
|
||||
throw new Error(
|
||||
'jiti duplicated the WSL hook-relay manager graph; reattach patch would not observe pty.ts'
|
||||
'jiti duplicated the WSL hook-relay manager graph; reattach patch would not observe the PTY spawn path'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,9 +356,9 @@ async function main() {
|
||||
const relayRefreshes = []
|
||||
let delivered = 0
|
||||
|
||||
// Why: pty.ts refreshes through the production singleton, so route that singleton at the
|
||||
// Why: the PTY spawn path refreshes through the production singleton, so route that singleton at the
|
||||
// benchmark-scoped manager instead of calling the reattach helper from here — a removed or
|
||||
// mislocated integration call in pty.ts must fail this benchmark.
|
||||
// mislocated integration call in the PTY spawn path must fail this benchmark.
|
||||
wslHookRelayManager.ensureForDistro = (refreshedDistro) => {
|
||||
relayRefreshes.push(refreshedDistro)
|
||||
manager?.ensureForDistro(refreshedDistro)
|
||||
@@ -367,7 +369,7 @@ async function main() {
|
||||
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach);
|
||||
// the manager's own hooks gate lives behind the ensureForDistro patch above, so it stays out
|
||||
// of the measurement — `manager` below resolves its own (enabled) managed-hook settings.
|
||||
ptyIpc.registerPtyHandlers(
|
||||
registerPtyHandlers(
|
||||
createRendererWindowStub(),
|
||||
runtime,
|
||||
undefined,
|
||||
@@ -520,7 +522,7 @@ async function main() {
|
||||
} finally {
|
||||
manager?.disposeAll()
|
||||
await staller?.stop()
|
||||
ptyIpc?.killAllPty?.()
|
||||
killAllPty?.()
|
||||
if (userDataDir) {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
],
|
||||
"exclude": ["../tests/e2e/.cross-version-checkouts/**"],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
// Why: specs run browser code inside page.evaluate, so the DOM lib has to be visible here
|
||||
// even though the harness itself is Node.
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
|
||||
@@ -10,7 +10,11 @@ import { createWorkspaceSourceResolver } from './scripts/workspace-source-export
|
||||
const balancedShards = process.env.ORCA_BALANCE_UNIT_SHARDS === '1'
|
||||
const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts'
|
||||
const transforms = {
|
||||
define: { ORCA_FEATURE_WALL_ENABLED: 'true' },
|
||||
define: {
|
||||
ORCA_FEATURE_WALL_ENABLED: 'true',
|
||||
// Why: tests only interpolate it; they must not depend on the checkout's HEAD.
|
||||
ORCA_BUILD_COMMIT: JSON.stringify('0'.repeat(40))
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
...createWorkspaceSourceResolver().aliases,
|
||||
|
||||
@@ -91,6 +91,29 @@ This keeps light/dark parity automatic.
|
||||
- 12px (sub-text, paths, secondary content)
|
||||
- 13px (sidebar items, dense list rows)
|
||||
- 14px (default body, button text in `default` size)
|
||||
- **Named small steps:** `text-2xs` (11px) and `text-3xs` (10px) are theme steps in `main.css`. Use them instead of `text-[11px]` / `text-[10px]`.
|
||||
|
||||
### Chat type scale
|
||||
|
||||
The native chat uses four steps and no others. Never write an arbitrary size (`text-[13px]`) inside `components/native-chat/`. `pnpm lint` enforces this, along with the softened-foreground and focus-ring rules below (`config/oxlint-plugins/native-chat-design-tokens.mjs`).
|
||||
|
||||
| Step | Default | Use it for |
|
||||
| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `text-sm` | 14px | Anything the user reads: message prose, tool and agent activity rows, card titles and bodies, the composer |
|
||||
| `text-xs` | 12px | Labels, timestamps, counters, secondary lines under a row |
|
||||
| `text-2xs` | 11px | Captions, attribution, uppercase category labels |
|
||||
| `text-3xs` | 10px | Badges and mono pills |
|
||||
| `text-chat-code` | 12px | Code blocks, diffs, commands and tool output |
|
||||
|
||||
The chat root (`.native-chat-appearance`) redefines these steps as ratios of the Text size setting, so every tier scales together; `text-chat-code` follows the Code text size setting. The chat root also ties the spacing unit to the text size (4px at the default 14px), so icons, gaps, padding and row heights grow with the text. Use spacing-scale utilities (`size-3.5`, `min-h-6.5`, `pl-3.25`), not pixel lengths (`min-h-[26px]`), or the element stays fixed while the rest scales. Size an element with one of these utilities only: a CSS rule that sets `font-size` outside a layer silently overrides them.
|
||||
|
||||
### Chat text colour, hover and focus
|
||||
|
||||
- **Primary text on the chat canvas** uses `text-chat-foreground`, and `text-chat-foreground-strong` for titles and emphasis; both follow the Contrast setting. Don't soften `text-foreground` by hand (`text-foreground/85`). `text-foreground` is for text on a lifted surface inside the chat.
|
||||
- **Secondary text on the chat canvas** (transcript rows, notices, labels between messages) uses `text-chat-foreground-faint`. `text-muted-foreground` is for text on a lifted surface inside the chat: cards, menus, popovers and the docks above the composer.
|
||||
- **Row hover** in the transcript is `hover:bg-accent/20`. Buttons, menu items and card controls use `hover:bg-accent`.
|
||||
- **Focus rings:** full-width transcript rows use the inset ring (`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring`) so it isn't clipped by the scroll container; standalone controls use `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`. Inside the chat `--ring` is `--chat-focus-ring`, which clears 3:1 on the canvas; don't lower its opacity.
|
||||
- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row.
|
||||
|
||||
## Radius
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 104m">
|
||||
<title>downloads: 104m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 105m">
|
||||
<title>downloads: 105m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">104m</text>
|
||||
<text x="93.5" y="14">104m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">105m</text>
|
||||
<text x="93.5" y="14">105m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -0,0 +1,107 @@
|
||||
# Shared historical source for cross-version tests (P2)
|
||||
|
||||
Cross-version tests extract historical releases under each worktree's
|
||||
`tests/e2e/.cross-version-checkouts`. Previously, branches testing the same release each
|
||||
retained a full copy. Local tests now prepare one read-only source seed and share its file
|
||||
data with each worktree's independent import directory.
|
||||
|
||||
## How it works
|
||||
|
||||
Seeds live under `<git-common-dir>/orca-cache/release-source/format-8/<commit>`.
|
||||
The resolved commit and extraction format identify the source, including its rewritten imports.
|
||||
The common Git directory lets sibling worktrees reuse the same seed without scanning them.
|
||||
Existing local checkouts remain in place; the new format creates fresh import directories.
|
||||
|
||||
Missing-package stand-ins depend on the current branch's dependencies, so each worktree
|
||||
creates those locally. Mutable copies of current-branch modules also remain local.
|
||||
The existing lock, staging directory, and rename transaction publishes complete seeds and
|
||||
import directories. Invalid stamps, missing protocol source, or invalid package metadata
|
||||
cause rebuilding or ordinary local extraction.
|
||||
|
||||
macOS uses APFS clones. Linux tries reflinks, then read-only hardlinks. Copies preserve the
|
||||
seed's read-only permissions; these historical files are never edited by the harness.
|
||||
Windows uses independent copies because removing read-only hardlinks can change permissions
|
||||
on surviving links. Unsupported sharing falls back to a private copy. If the optional shared
|
||||
cache fails, the harness extracts locally and continues the test.
|
||||
|
||||
The current format retains at most eight seeds, ordered by recent use. A shared lock covers
|
||||
publication, copying, and eviction, so cleanup cannot delete a source while another test
|
||||
copies it. Copying and permission changes are asynchronous so the lock heartbeat keeps running.
|
||||
Interrupted staging directories are removed on the next use. Retention only touches the
|
||||
current format; older implementations may still be using other formats. Eviction leaves
|
||||
already-created import directories usable.
|
||||
|
||||
## Controls and scope
|
||||
|
||||
Sharing is enabled for ordinary local cross-version tests. Set
|
||||
`ORCA_DISABLE_SHARED_RELEASE_CACHE=1` to keep ordinary local extraction. CI and explicit
|
||||
`cacheRoot` options remain isolated unless a caller explicitly supplies `sharedCacheRoot`.
|
||||
|
||||
This only changes test tooling. It adds no development-server task or background worktree
|
||||
scan. Sharing does not migrate or delete existing local caches. Tests running on a remote host use that
|
||||
host's repository and filesystem; no client filesystem or remote protocol is involved.
|
||||
Folder contexts without a Git repository cannot provide a historical release checkout.
|
||||
|
||||
## Cleaning up older historical caches
|
||||
|
||||
Preview cleanup across this repository's registered local worktrees and shared source cache:
|
||||
|
||||
```sh
|
||||
pnpm run reclaim:worktree-space
|
||||
```
|
||||
|
||||
The default selects stamped release checkouts older than 30 days, including previous extraction
|
||||
formats. To apply the preview, first stop cross-version tests in all this repository's worktrees:
|
||||
|
||||
```sh
|
||||
pnpm run reclaim:worktree-space --apply --tests-stopped
|
||||
```
|
||||
|
||||
Use `--release-cache-min-age-days 7` for a shorter retention window, or `--release-cache-min-age-days 0` to select all
|
||||
recognised completed release checkouts. `--repo DIRECTORY` selects another local repository.
|
||||
Each run rescans and rechecks eligibility; it does not apply a saved preview.
|
||||
|
||||
This is manual because older harnesses do not hold a lease while importing source. Locks prevent
|
||||
deletion during publication or shared-source copying, but do not prove that a completed checkout
|
||||
is unused. Local checkout age uses directory modification time, which ordinary reuse does not
|
||||
refresh; shared seed timestamps reflect recent copying. `--tests-stopped` acknowledges that the
|
||||
caller has stopped consumers; it does not detect them.
|
||||
|
||||
The script checks matching commit/format stamps, skips symlinked cache paths and future formats,
|
||||
and leaves mutable `working-tree` copies, source, dependencies, and worktree registrations alone.
|
||||
It reports logical bytes rather than promised disk savings: another clone may retain the same
|
||||
blocks. Deleted historical source rebuilds on the next test run. Run the command on the execution
|
||||
host for SSH repositories. The same manual command also performs P1's Electron maintenance;
|
||||
automatic dev-bundle sweeps exclude historical test cache cleanup.
|
||||
|
||||
## Measurement and validation
|
||||
|
||||
A disposable macOS/APFS run against `v1.4.190` compared ordinary extraction with two separate
|
||||
local caches using one shared seed. Each import tree contained 8,175 files. APFS private file
|
||||
allocation was measured after all three views existed, without following symlinks.
|
||||
|
||||
| Method | Elapsed time | Private file data per import directory |
|
||||
| -------------------------------- | ------------ | -------------------------------------- |
|
||||
| Ordinary extraction | 4.64 s | 68.79 MiB |
|
||||
| Shared source, creating the seed | 22.06 s | 44 KiB |
|
||||
| Shared source, reusing the seed | 1.33 s | 44 KiB |
|
||||
|
||||
Shared blocks still occupy approximately one source copy. Private bytes exclude those blocks
|
||||
and filesystem metadata, so they are not the total footprint. The cold seed costs extra work;
|
||||
one timing sample on a busy machine does not establish a speed guarantee. Deleting the seed
|
||||
left the other import directory readable. No existing worktrees were cleaned up.
|
||||
|
||||
Tests cover concurrent publication, lock heartbeats and eviction, independent branch stand-ins,
|
||||
corrupt seeds, fallback, the opt-out, and preserved read-only files. Run them with:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/release-source-cache.unit.test.ts tests/e2e/cross-version-wire/release-source-retention.unit.test.ts tests/e2e/cross-version-wire/release-checkout.unit.test.ts tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts tests/e2e/cross-version-wire/release-missing-packages.unit.test.ts config/scripts/space-sharing-copy.test.ts
|
||||
```
|
||||
|
||||
## P3 remains an experiment
|
||||
|
||||
P3 shares the tracked source of new Orca worktrees. It requires independent writable files,
|
||||
Git indexes, and matching checkout settings, and has more compatibility risk than P2's
|
||||
immutable test source. The APFS experiment demonstrated substantial storage savings but
|
||||
variable creation time and stays out of this change. Its code, measurements, and design notes
|
||||
are preserved on `nwparker/tracked-source-sharing-experiment`; [the draft PR is closed](https://github.com/stablyai/orca/pull/27618).
|
||||
@@ -6,18 +6,27 @@ Run from the repository to preview maintenance across its locally registered Git
|
||||
pnpm run reclaim:worktree-space
|
||||
```
|
||||
|
||||
Apply the previewed operations with:
|
||||
After stopping cross-version tests in all this repository's worktrees, apply cleanup with:
|
||||
|
||||
```sh
|
||||
pnpm run reclaim:worktree-space --apply
|
||||
pnpm run reclaim:worktree-space --apply --tests-stopped
|
||||
```
|
||||
|
||||
The command reuses the existing Electron distribution migration and dev bundle cleanup scripts.
|
||||
The command reuses the historical test cache, Electron distribution, and dev bundle cleanup scripts.
|
||||
It shares complete Electron distributions through the repository cache and removes idle
|
||||
`out/electron-dev` bundles, which development rebuilds on demand. It leaves source files,
|
||||
`out/electron-dev` bundles, which development rebuilds on demand. It also removes stamped historical
|
||||
test checkouts and shared seeds older than 30 days; tests regenerate them when needed. It leaves source files,
|
||||
dependency installs, worktree registrations, and other build output alone. Reported sizes are
|
||||
logical file data; APFS clones and hardlinks mean actual disk savings can be smaller.
|
||||
|
||||
Use `--release-cache-min-age-days 7` to shorten historical-cache retention, or `0` to select all
|
||||
recognised completed historical checkouts. This age option applies only to test caches. Local
|
||||
checkout age uses directory modification time, which ordinary reuse does not refresh. The
|
||||
`--tests-stopped` flag acknowledges stopped tests; older harnesses cannot prove a completed
|
||||
checkout is unused. Missing acknowledgement rejects manual apply before any cleanup begins.
|
||||
Cache cleanup skips symlinks, malformed stamps, future formats, and publication/copy locks.
|
||||
See [release source sharing](./release-source-sharing.md) for the cache and locking details.
|
||||
|
||||
Use `--repo DIRECTORY` to choose another local repository. Git's worktree list determines scope,
|
||||
including registered worktrees outside Orca's workspace directory. Folder workspaces without Git
|
||||
have no repository-wide sweep. These commands run on the local host: they do not inspect or delete
|
||||
@@ -27,8 +36,11 @@ anything on SSH hosts. Run maintenance on the execution host when maintaining re
|
||||
|
||||
Healthy Electron installs join the shared distribution cache during native dependency setup,
|
||||
without waiting for an Electron upgrade. The manual command migrates older installs immediately.
|
||||
Read-only Electron package directories are retained and reported as skipped. Cache publication
|
||||
or copying failures are also reported as skipped rather than silently omitted.
|
||||
|
||||
Cleanup runs only when explicitly requested. Starting `pnpm dev` does not trigger a sweep.
|
||||
Historical test cache cleanup runs only in this explicit manual command. Automatic invocations
|
||||
exclude it because tests may still be importing source.
|
||||
The repository lock prevents competing cleanup commands. Dev bundle removal checks active
|
||||
processes and recent unfinished builds, then rechecks eligibility immediately before deletion.
|
||||
An unreadable process table prevents removal.
|
||||
|
||||
@@ -53,15 +53,15 @@ Add `--json` when an agent needs deterministic output for automation. `skills sh
|
||||
|
||||
When Orca ships a newer skill package than the one installed globally, the app can:
|
||||
|
||||
- Show an **update available** nudge (and **Needs attention** when a copy is out of date somewhere the updater cannot safely rewrite).
|
||||
- Open **Update skills** — list of placements and skip reasons, then run **`npx --yes skills update <names> --global -y`** headlessly (no embedded terminal).
|
||||
- Show an **update available** nudge for skills Orca installed (and **Needs attention** when a copy is out of date somewhere the updater cannot safely rewrite).
|
||||
- Open **Update skills** — list of placements and skip reasons, with a checkbox per skill. Orca's own skills start ticked; a same-named skill installed from somewhere else is marked **Not from Orca** and starts unticked. The selected skills are reinstalled headlessly (no embedded terminal) with **`npx --yes skills@1.7.2 add https://github.com/stablyai/orca/tree/<commit> --skill <name>… --global -y`**, pinned to the commit the running Orca build was made from (so it works on hourly, daily, RC and stable builds alike) and to a known skills CLI version.
|
||||
- Keep the run in the **background**: closing the dialog does not cancel it. A status-bar segment shows progress (spinner while running, brief success check, persistent failure until you act). Click the segment to reopen the dialog.
|
||||
- Surface freshness on **Settings → Agents** for installed skills (global install rail), including **Details** when a row needs review.
|
||||
|
||||
Manual equivalent (desktop Settings still shows the same `npx` form):
|
||||
Manual equivalent (desktop Settings shows the same pinned form for the running build):
|
||||
|
||||
```bash
|
||||
npx skills update orca-cli orchestration computer-use --global
|
||||
npx skills@1.7.2 add https://github.com/stablyai/orca/tree/<commit> --skill orca-cli --skill orchestration --global
|
||||
```
|
||||
|
||||
On headless hosts (SSH, containers, CI, `orca serve`) with no Settings UI, use the local CLI wrappers — they resolve the same `npx` commands, add non-interactive flags, and do **not** need a running Orca runtime:
|
||||
@@ -77,10 +77,10 @@ orca skills update --skill orca-cli --dry-run
|
||||
|
||||
- Default scope is **global** (`--global`); pass `--local` for the current project only.
|
||||
- `install` targets agents Orca detects on the host (plus the shared `.agents/skills` directory). Use `--agent <name>[,<name>…]` or `--agent universal` to override; if no agent is detected, `--agent` is required.
|
||||
- `update` only refreshes skills that are already installed.
|
||||
- `update` runs the same pinned `skills add` as `install`. With no agent detected it targets only `.agents/skills` instead of refusing.
|
||||
- `--dry-run` prints the resolved command; `--json` is only valid with listing / `--dry-run`.
|
||||
|
||||
Prefer the in-app updater when Orca offers it so the same global placements the app scanned get rewritten. Rows marked **Skipped** explain why that skill cannot be updated automatically (for example missing source registration) — fix the placement, then re-check.
|
||||
Prefer the in-app updater when Orca offers it so the same global placements the app scanned get rewritten. Rows marked **Skipped** explain why that skill cannot be updated automatically (for example a read-only folder or a link pointing outside Orca's skill folders) — fix the placement, then re-check.
|
||||
|
||||
## Install and delete from the Skills page
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user