mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-dashboard-ui-revamp
This commit is contained in:
+1
-1
@@ -2,4 +2,4 @@
|
||||
/src/renderer/src/i18n/locales/ @brennanb2025
|
||||
/config/scripts/*localization*.mjs @brennanb2025
|
||||
/config/scripts/*locale*.mjs @brennanb2025
|
||||
/i18next.config.ts @brennanb2025
|
||||
/config/i18next.config.ts @brennanb2025
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Install SignPath PowerShell module
|
||||
description: >-
|
||||
Installs the SignPath PowerShell module (Get-SignedArtifact) from PSGallery,
|
||||
falling back to a pinned, hash-verified nupkg from the gallery CDN when the
|
||||
gallery's package API is unavailable.
|
||||
|
||||
inputs:
|
||||
fallback-version:
|
||||
description: Module version fetched directly from the CDN when the gallery API is unreachable.
|
||||
required: false
|
||||
default: 4.4.6
|
||||
fallback-sha256:
|
||||
description: >-
|
||||
SHA-256 of the pinned fallback nupkg. The CDN path bypasses the gallery's own
|
||||
package validation, so this hash is the only integrity check on that route.
|
||||
required: false
|
||||
default: 2487357a9a02c7d985baaf9ebd9158b4ce877316a2d9de3a6e9af1b263c0a32d
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install SignPath PowerShell module
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_FALLBACK_VERSION: ${{ inputs.fallback-version }}
|
||||
SIGNPATH_FALLBACK_SHA256: ${{ inputs.fallback-sha256 }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
# Why: on some hosted Windows images `Register-PSRepository -Default`
|
||||
# fails inside the legacy nuget.exe provider with "Missing option value
|
||||
# for: '-source'", so PSGallery is never registered and the install
|
||||
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
|
||||
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
|
||||
# avoids that code path, so prefer it and fall back to PowerShellGet only
|
||||
# when it is absent.
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
|
||||
try {
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
} else {
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
}
|
||||
} catch {
|
||||
# Why: repository registration also talks to the gallery, so a gallery
|
||||
# outage can fail here before a single install is attempted. The CDN
|
||||
# fallback below does not need a registered repository, so keep going.
|
||||
Write-Warning "PSGallery repository registration failed: $_"
|
||||
}
|
||||
|
||||
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
|
||||
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
|
||||
Where-Object {
|
||||
if ([string]::IsNullOrWhiteSpace($_)) {
|
||||
$false
|
||||
} else {
|
||||
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
|
||||
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
} |
|
||||
Select-Object -First 1
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
|
||||
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
|
||||
}
|
||||
|
||||
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
|
||||
|
||||
function Test-SignPathModule {
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Remove-SignPathModuleDirectory {
|
||||
if (Test-Path -LiteralPath $signPathModulePath) {
|
||||
Write-Warning "Removing current-user SignPath module directory: $signPathModulePath"
|
||||
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
$installed = $false
|
||||
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
if ($attempt -eq 2) {
|
||||
Start-Sleep -Seconds 15
|
||||
} elseif ($attempt -eq 3) {
|
||||
Start-Sleep -Seconds 30
|
||||
}
|
||||
|
||||
try {
|
||||
if ($useResourceGet) {
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Test-SignPathModule
|
||||
$installed = $true
|
||||
break
|
||||
} catch {
|
||||
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
|
||||
Remove-SignPathModuleDirectory
|
||||
}
|
||||
}
|
||||
|
||||
# Why: the gallery's package API (OData search + repository metadata) sits
|
||||
# behind Azure Front Door and has returned 403/502/504 for every install
|
||||
# attempt during gallery incidents, which hard-failed the whole Windows
|
||||
# release job. The CDN that serves the nupkg itself is a separate origin
|
||||
# and stays up through those incidents, so fall back to a pinned version
|
||||
# fetched straight from it. The hash pin is mandatory: this route skips the
|
||||
# gallery's package validation, so an unexpected payload must fail loudly.
|
||||
if (-not $installed) {
|
||||
$version = $env:SIGNPATH_FALLBACK_VERSION
|
||||
$expectedHash = $env:SIGNPATH_FALLBACK_SHA256
|
||||
Write-Warning "PSGallery install failed; falling back to pinned SignPath $version from the gallery CDN."
|
||||
|
||||
$nupkg = Join-Path -Path $env:RUNNER_TEMP -ChildPath "signpath-$version.zip"
|
||||
if (Test-Path -LiteralPath $nupkg) {
|
||||
Remove-Item -LiteralPath $nupkg -Force
|
||||
}
|
||||
|
||||
# Why two URLs: the /api/v2/package route 302s to the CDN and can serve
|
||||
# while the OData search endpoint is failing; the CDN URL is the same
|
||||
# redirect target reached directly when the api host is down entirely.
|
||||
$sources = @(
|
||||
"https://www.powershellgallery.com/api/v2/package/SignPath/$version",
|
||||
"https://cdn.powershellgallery.com/packages/signpath.$version.nupkg"
|
||||
)
|
||||
|
||||
$downloaded = $false
|
||||
foreach ($source in $sources) {
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
if ($attempt -gt 1) {
|
||||
Start-Sleep -Seconds (10 * $attempt)
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri $source -OutFile $nupkg -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop
|
||||
$actualHash = (Get-FileHash -LiteralPath $nupkg -Algorithm SHA256).Hash
|
||||
if ($actualHash -ne $expectedHash.ToUpperInvariant()) {
|
||||
throw "SHA-256 mismatch for $source (expected $expectedHash, got $actualHash)."
|
||||
}
|
||||
$downloaded = $true
|
||||
Write-Host "Downloaded and verified SignPath $version from $source"
|
||||
break
|
||||
} catch {
|
||||
Write-Warning "SignPath CDN download attempt $attempt from $source failed: $_"
|
||||
if (Test-Path -LiteralPath $nupkg) {
|
||||
Remove-Item -LiteralPath $nupkg -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($downloaded) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $downloaded) {
|
||||
throw "Unable to install the SignPath PowerShell module: PSGallery installs failed and the pinned $version nupkg could not be downloaded from any source."
|
||||
}
|
||||
|
||||
Remove-SignPathModuleDirectory
|
||||
# Why a version-named subdirectory: PowerShell only treats a nested folder
|
||||
# as a side-by-side module version when the name matches the manifest's
|
||||
# ModuleVersion, which is what makes `Import-Module SignPath` resolve it.
|
||||
$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version
|
||||
New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null
|
||||
Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force
|
||||
|
||||
# Why: strip nupkg packaging entries so only the module files remain.
|
||||
foreach ($entry in @('_rels', 'package', '[Content_Types].xml', 'SignPath.nuspec')) {
|
||||
$path = Join-Path -Path $versionRoot -ChildPath $entry
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
Remove-Item -LiteralPath $path -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = Join-Path -Path $versionRoot -ChildPath 'SignPath.psd1'
|
||||
if (-not (Test-Path -LiteralPath $manifest)) {
|
||||
throw "Pinned SignPath nupkg did not contain SignPath.psd1 at $versionRoot."
|
||||
}
|
||||
|
||||
Test-SignPathModule
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 <base-sha> <head-sha>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base_sha=$1
|
||||
head_sha=$2
|
||||
git rev-parse --verify "${base_sha}^{tree}" >/dev/null
|
||||
git rev-parse --verify "${head_sha}^{tree}" >/dev/null
|
||||
|
||||
declare -A base_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
base_entries["$entry"]=1
|
||||
done < <(git ls-tree -z --name-only "$base_sha")
|
||||
|
||||
blocked_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
if [[ -z "${base_entries[$entry]+present}" ]]; then
|
||||
blocked_entries+=("$entry")
|
||||
fi
|
||||
done < <(git ls-tree -z --name-only "$head_sha")
|
||||
|
||||
if (( ${#blocked_entries[@]} == 0 )); then
|
||||
echo "Root directory guard passed: no new root-level files or folders."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page."
|
||||
echo "Root directory guard failed."
|
||||
echo "New root-level files or folders are not allowed because they bloat the GitHub landing page."
|
||||
echo "Move each new entry under an existing top-level directory."
|
||||
printf 'Blocked entries:\n'
|
||||
printf ' %s\n' "${blocked_entries[@]}"
|
||||
exit 1
|
||||
@@ -9,6 +9,12 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-processes.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
@@ -59,6 +65,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
@@ -85,6 +93,8 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
@@ -130,15 +140,31 @@ jobs:
|
||||
- name: Windows daemon workspace-close repro
|
||||
if: runner.os == 'Windows'
|
||||
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
|
||||
- if: runner.os == 'Linux'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
- if: runner.os == 'Windows'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
|
||||
mac-native-owner-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
|
||||
- name: Authenticated helper owner-loss smoke
|
||||
run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1
|
||||
- name: Swift tests and signed universal helper verification
|
||||
run: pnpm verify:computer-native
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/daemon-relocation-spike/**'
|
||||
- 'tests/tools/daemon-relocation-spike/**'
|
||||
- '.github/workflows/daemon-relocation-spike.yml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
|
||||
# for the artifact upload (the spike otherwise removes work-dir),
|
||||
# which is what diagnoses a tier that fails to reach ready.
|
||||
node tools/daemon-relocation-spike/spike.mjs `
|
||||
node tests/tools/daemon-relocation-spike/spike.mjs `
|
||||
--app-dir dist/win-unpacked `
|
||||
--work-dir "$work" `
|
||||
--tier $tier `
|
||||
|
||||
@@ -13,6 +13,10 @@ on:
|
||||
description: Ref to check out (defaults to the calling workflow's ref)
|
||||
required: false
|
||||
type: string
|
||||
test_files:
|
||||
description: JSON array of changed specs; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -64,11 +68,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: building once avoids five parallel electron-vite builds inside
|
||||
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
|
||||
# runners before the sharded tests even start.
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
npx electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -81,6 +88,7 @@ jobs:
|
||||
e2e:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -180,9 +188,67 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
changed-e2e:
|
||||
name: changed e2e specs
|
||||
needs: build
|
||||
if: inputs.test_files != ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
|
||||
- name: Run changed E2E specs
|
||||
env:
|
||||
TEST_FILES_JSON: ${{ inputs.test_files }}
|
||||
run: |
|
||||
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-changed
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
name: Golden E2E Experiment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/golden-e2e-experiment.yml'
|
||||
- 'package.json'
|
||||
- 'tests/e2e/golden-core-flows.spec.ts'
|
||||
- 'tests/e2e/fixtures/terminal-emoji-table.md'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
|
||||
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
|
||||
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -44,9 +32,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: pull_request merge refs disappear after merge, but experiment
|
||||
# reruns still need a stable commit to checkout.
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
name: Hourly macOS Dev Build
|
||||
|
||||
# Why: gives developers a signed macOS build of main every hour that the in-app
|
||||
# updater can install directly, without waiting for an RC cut.
|
||||
#
|
||||
# Deliberately narrow scope:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
|
||||
# and release-cut remain the gates that matter.
|
||||
# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's
|
||||
# signature, not its notarization, so in-place updates still work while the
|
||||
# ~10min notary round trip is skipped 24x a day.
|
||||
#
|
||||
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
|
||||
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
|
||||
# tags a day would evict every stable/RC entry and break updates for real users.
|
||||
#
|
||||
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
|
||||
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
|
||||
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
|
||||
# credential belongs to the org rather than to whoever created it.
|
||||
#
|
||||
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
|
||||
# HOURLY_RELEASE_APP_ID the App's numeric id
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
|
||||
#
|
||||
# Installation tokens live one hour, which is ample here: this job skips tests,
|
||||
# notarization, and Windows signing entirely, so a run is pack + upload and lands
|
||||
# well inside that. If one ever did overrun, the release is still an unpublished
|
||||
# draft at that point, so nothing user-visible breaks.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Top of every hour. Skipped automatically when main has not moved.
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: Build even if main has not moved since the last hourly
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hourly-mac-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
HOURLY_REPO: stablyai/orca-hourly
|
||||
# Keep ~3 days of history so a regression can be bisected across a weekend.
|
||||
HOURLY_RETAIN_COUNT: 72
|
||||
|
||||
jobs:
|
||||
build-hourly-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 120: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or
|
||||
# the job is killed mid-retry and no cleanup step runs at all. A typical run
|
||||
# is far shorter — there is no notarization, Windows signing, or test phase.
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
# Why: this job only reads stablyai/orca and never pushes; every write
|
||||
# goes to the hourly repo through a minted App token passed by env.
|
||||
# Not persisting the checkout credential shrinks the blast radius if a
|
||||
# build step is compromised (zizmor: artipacked).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Mint hourly repo token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-hourly
|
||||
|
||||
# Why: main is often idle overnight. Rebuilding an unchanged commit burns a
|
||||
# runner hour and adds a redundant tag to the retention window.
|
||||
- name: Check whether main moved since the last hourly
|
||||
id: freshness
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
|
||||
if [[ "$FORCED" == "true" ]]; then
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
echo "Forced dispatch; building $head_sha."
|
||||
exit 0
|
||||
fi
|
||||
# The previous hourly records its source commit in the release body.
|
||||
# Drafts are excluded: an unpublished leftover never shipped, so treating
|
||||
# it as "the last build" would skip a build that never actually happened.
|
||||
last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \
|
||||
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
|
||||
if [[ -z "$last_body" ]]; then
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
echo "No prior hourly release found; building $head_sha."
|
||||
exit 0
|
||||
fi
|
||||
last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \
|
||||
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
|
||||
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
|
||||
echo "should_build=false" >>"$GITHUB_OUTPUT"
|
||||
echo "main is unchanged since $last_body ($last_sha); skipping."
|
||||
else
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
echo "main moved to $head_sha (last hourly built $last_sha); building."
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-mac-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: signing is what makes an hourly installable over an existing Orca, so
|
||||
# a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Compute hourly version
|
||||
id: hourly
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why read the highest number off existing titles rather than counting
|
||||
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
|
||||
# roll backwards after three days and reissue numbers already in use. The
|
||||
# maximum is always among the retained builds, because pruning drops the
|
||||
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
|
||||
# were the raw tag, so the series simply starts at 01.
|
||||
#
|
||||
# Why drafts count here but not in the freshness check: that check asks
|
||||
# "did this commit ship", where a draft is a no. This one asks "is the
|
||||
# number free", where a stranded draft still holds one.
|
||||
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
|
||||
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
|
||||
build_number=$(( last_number + 1 ))
|
||||
echo "Hourly build number $build_number (previous high: $last_number)"
|
||||
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
|
||||
>"$RUNNER_TEMP/hourly-identity.txt"
|
||||
# Why check rather than trust: the checkout above pins `ref: main`, but a
|
||||
# workflow_dispatch runs this file from whatever branch was dispatched. A
|
||||
# branch that edits this step while main still has the old script yields
|
||||
# an empty name and an untitled release — silent, and only visible once
|
||||
# someone opens the releases page. Fail here instead.
|
||||
if ! grep -q '^name=' "$RUNNER_TEMP/hourly-identity.txt"; then
|
||||
echo "::error::hourly-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build app
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Why: hourly builds are not an official channel — telemetry's transport
|
||||
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
|
||||
# silent, which is correct for unvetted dev artifacts.
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
|
||||
- name: Create hourly release
|
||||
id: release
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: v${{ steps.hourly.outputs.version }}
|
||||
NAME: ${{ steps.hourly.outputs.name }}
|
||||
SHA: ${{ steps.freshness.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Kept at 12 even though the title shows 7: the freshness check above
|
||||
# parses this back out of the body to decide whether main has moved.
|
||||
short_sha="${SHA:0:12}"
|
||||
# Why create it up front: electron-builder then uploads into a known tag
|
||||
# rather than inferring one from package.json.
|
||||
#
|
||||
# Why --draft: everything between here and the manifest check is a window
|
||||
# where the release exists but has no installable assets. A draft is
|
||||
# absent from the releases list and from listReleaseBuilds, so a job that
|
||||
# dies in that window — including a hard kill by the job timeout, which
|
||||
# runs no cleanup step at all — leaves something invisible rather than a
|
||||
# tag the picker offers and the download 404s on. It is flipped live only
|
||||
# after the manifest is verified.
|
||||
gh release create "$TAG" \
|
||||
--repo "$HOURLY_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
|
||||
|
||||
**Unvetted.** No tests ran. Signed but not notarized — installable
|
||||
through Orca's in-app updater, but a manual download will be
|
||||
Gatekeeper-quarantined."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
# Why lower than the release pipeline's 3x45: that budget is sized for
|
||||
# notarization and Windows signing, neither of which runs here. An attempt
|
||||
# is pack + upload only.
|
||||
timeout_minutes: 25
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
env:
|
||||
# Why: electron-builder's github publisher targets the repo named in the
|
||||
# config; the token must therefore carry write access to orca-hourly.
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
|
||||
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
|
||||
# release missing that manifest is a tag the picker offers and the download
|
||||
# 404s on, so fail loudly instead of leaving a broken entry.
|
||||
- name: Verify update manifest published
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
|
||||
echo "Published assets:"
|
||||
echo "$assets"
|
||||
# Why exit 1 without deleting here: the release is still a draft, so it is
|
||||
# already invisible to users, and the failure handler below owns cleanup.
|
||||
# Deleting inline under `set -e` would also let the delete's exit code
|
||||
# preempt this explicit failure.
|
||||
for required in latest-mac.yml; do
|
||||
if ! grep -qx "$required" <<<"$assets"; then
|
||||
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -q '\.zip$' <<<"$assets"; then
|
||||
echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Why this is the last mutating step: publishing the draft is what makes the
|
||||
# build visible to listReleaseBuilds. Doing it only after the manifest check
|
||||
# means the picker can never offer a release whose assets are incomplete.
|
||||
- name: Publish the verified release
|
||||
id: publish_live
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
NAME: ${{ steps.hourly.outputs.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# --title again: electron-builder resolves this draft by tag and may
|
||||
# rewrite its title on upload. Re-asserting here means the name the
|
||||
# picker reads is the one composed above, whatever it did in between.
|
||||
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
|
||||
echo "Published $TAG as \"$NAME\""
|
||||
|
||||
# Why: a draft left behind by a failed publish is invisible to users but still
|
||||
# holds its tag name, so the next run for the same minute would collide.
|
||||
#
|
||||
# Why it is gated on publish_live not having succeeded: a later failure (the
|
||||
# prune step) must not delete a release that already went live and that users
|
||||
# may already be installing. A job killed by the outer timeout runs no steps
|
||||
# at all — which is exactly why the release stays a draft until verified.
|
||||
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
|
||||
# so without it a manual cancel mid-publish would strand the draft.
|
||||
- name: Discard the draft release on failure
|
||||
if: >-
|
||||
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
|
||||
steps.publish_live.outcome != 'success'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# No --cleanup-tag: an unpublished draft never created a git tag.
|
||||
echo "Run failed before publish; discarding draft $TAG"
|
||||
gh release delete "$TAG" --repo "$HOURLY_REPO" --yes ||
|
||||
echo "::warning::Could not discard draft $TAG; remove it manually."
|
||||
|
||||
- name: Prune old hourly releases
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
|
||||
# keep showing up in tag lists with no release or assets attached.
|
||||
# Drafts are excluded so retention counts shipped builds only; a stale
|
||||
# draft is handled by the failure path, not by the retention window.
|
||||
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
|
||||
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
|
||||
if [[ -z "$stale" ]]; then
|
||||
echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds."
|
||||
exit 0
|
||||
fi
|
||||
while read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
echo "Pruning $tag"
|
||||
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
@@ -1,20 +1,6 @@
|
||||
name: Linux Wayland GPU Sandbox
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/linux-wayland-gpu-sandbox.yml
|
||||
- config/scripts/linux-wayland-renderer-diagnostics.mjs
|
||||
- config/scripts/linux-wayland-terminal-exercise.mjs
|
||||
- config/scripts/linux-wayland-validation-watchdog.mjs
|
||||
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
|
||||
- src/main/startup/configure-process.ts
|
||||
- src/main/startup/configure-process.test.ts
|
||||
- src/preload/api-types.ts
|
||||
- src/preload/index.ts
|
||||
- src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
|
||||
- src/renderer/src/web/web-preload-api.ts
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -85,34 +71,6 @@ jobs:
|
||||
done
|
||||
test -S "$XDG_RUNTIME_DIR/wayland-1"
|
||||
|
||||
- name: Reproduce terminal input freeze without the workaround
|
||||
id: reproduce_base
|
||||
if: github.event_name == 'pull_request'
|
||||
# Why: still collect fixed-path Wayland evidence when the base repro
|
||||
# stops reproducing; the final gate below fails the job in that case.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
|
||||
# Why: once the PR base includes this workaround, the partial
|
||||
# checkout cannot reconstruct an unfixed Wayland negative control.
|
||||
if git show "${{ github.event.pull_request.base.sha }}:src/main/startup/configure-process.ts" | grep -Eq "appendSwitch\\(['\"]disable-gpu-sandbox['\"]\\)"; then
|
||||
echo "The base build already contains the Linux Wayland GPU sandbox workaround; skipping unfixed-path reproduction."
|
||||
exit 0
|
||||
fi
|
||||
# Why: keep the new verifier scripts, but run them against the
|
||||
# unfixed production terminal/GPU path instead of a hybrid checkout.
|
||||
git checkout "${{ github.event.pull_request.base.sha }}" -- \
|
||||
src/main/startup/configure-process.ts \
|
||||
src/preload/api-types.ts \
|
||||
src/preload/index.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.ts \
|
||||
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
|
||||
src/renderer/src/web/web-preload-api.ts
|
||||
rm -rf out
|
||||
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
|
||||
|
||||
- name: Verify terminal input under Wayland GPU sandbox workaround
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 10
|
||||
@@ -122,12 +80,6 @@ jobs:
|
||||
rm -rf out
|
||||
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
|
||||
|
||||
- name: Require base reproduction
|
||||
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
|
||||
run: |
|
||||
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
|
||||
exit 1
|
||||
|
||||
- name: Upload Weston log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
+90
-16
@@ -93,6 +93,23 @@ jobs:
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
root_directory_guard:
|
||||
name: root directory guard
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reject new root-level files and folders
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA"
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -273,11 +290,62 @@ jobs:
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
# Why: regression specs under tests/e2e/** used to merge green without ever
|
||||
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
|
||||
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
|
||||
- name: Smoke packaged hang watchdog worker
|
||||
run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
# Why: PR E2E is advisory and only validates changed specs; scheduled and
|
||||
# release runs retain full-suite coverage.
|
||||
e2e-paths:
|
||||
name: detect e2e path changes
|
||||
name: detect changed e2e specs
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft != true
|
||||
# Why: detector only needs to read the checkout; do not inherit repo defaults.
|
||||
@@ -285,6 +353,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -292,25 +361,22 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter E2E-relevant paths
|
||||
- name: Filter changed E2E specs
|
||||
id: filter
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
# Why: capture first so a failed git diff does not look like "no matches"
|
||||
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
|
||||
# list to files this PR introduced, not base-branch drift.
|
||||
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
|
||||
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
|
||||
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
|
||||
# file and would silently skip E2E when the runner config changes.
|
||||
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "E2E path changes detected"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E path changes"
|
||||
echo "No changed E2E specs"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
@@ -321,16 +387,20 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- static_analysis
|
||||
- root_directory_guard
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
- package_windows
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -345,19 +415,23 @@ jobs:
|
||||
- name: Require successful checks
|
||||
env:
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
run: |
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$PACKAGE"; do
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -72,6 +72,11 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
|
||||
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
|
||||
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
|
||||
# Why: downstream SignPath Slack pings need the cut source (not only the
|
||||
# new tag) so approvers know what they're signing and who cut it.
|
||||
source_ref: ${{ steps.resolve.outputs.ref }}
|
||||
source_sha: ${{ steps.resolve.outputs.sha }}
|
||||
source_short_sha: ${{ steps.resolve.outputs.short_sha }}
|
||||
steps:
|
||||
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
|
||||
# this job runs with contents:write and secret scope, so avoid executing
|
||||
@@ -122,9 +127,12 @@ jobs:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
input_ref="${INPUT_REF:-main}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
short_sha="$(git rev-parse --short=12 HEAD)"
|
||||
echo "ref=$input_ref" >>"$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
||||
echo "short_sha=$short_sha" >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Why: only push the version-bump commit back to main when the
|
||||
# caller is releasing the exact tip of main. For any older or
|
||||
@@ -142,7 +150,6 @@ jobs:
|
||||
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
|
||||
# inputs it also hides the human-readable names operators need
|
||||
# when auditing RC cuts.
|
||||
input_ref="${INPUT_REF:-main}"
|
||||
repo_url="${SERVER_URL}/${REPO}"
|
||||
|
||||
branches="$(
|
||||
@@ -1008,6 +1015,27 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
|
||||
# ref, so cutting from an older/off-main ref whose tree predates a composite
|
||||
# action would fail the step with "Can't find 'action.yml'". Restore the
|
||||
# actions directory from the commit this workflow file itself came from.
|
||||
- name: Restore composite actions from the workflow ref
|
||||
if: matrix.platform == 'win'
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
action_path=".github/actions/install-signpath-module/action.yml"
|
||||
if [ -f "$action_path" ]; then
|
||||
echo "Composite actions already present at the cut ref."
|
||||
exit 0
|
||||
fi
|
||||
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- .github/actions
|
||||
test -f "$action_path"
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -1176,82 +1204,7 @@ jobs:
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
# Why: on some hosted Windows images `Register-PSRepository -Default`
|
||||
# fails inside the legacy nuget.exe provider with "Missing option value
|
||||
# for: '-source'", so PSGallery is never registered and the install
|
||||
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
|
||||
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
|
||||
# avoids that code path, so prefer it and fall back to PowerShellGet only
|
||||
# when it is absent.
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
} else {
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
}
|
||||
|
||||
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
|
||||
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
|
||||
Where-Object {
|
||||
if ([string]::IsNullOrWhiteSpace($_)) {
|
||||
$false
|
||||
} else {
|
||||
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
|
||||
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
} |
|
||||
Select-Object -First 1
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
|
||||
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
|
||||
}
|
||||
|
||||
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
|
||||
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
if ($attempt -eq 2) {
|
||||
Start-Sleep -Seconds 15
|
||||
} elseif ($attempt -eq 3) {
|
||||
Start-Sleep -Seconds 30
|
||||
}
|
||||
|
||||
try {
|
||||
if ($useResourceGet) {
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) {
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
|
||||
if (Test-Path -LiteralPath $signPathModulePath) {
|
||||
Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath"
|
||||
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
uses: ./.github/actions/install-signpath-module
|
||||
|
||||
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
|
||||
@@ -1339,6 +1292,12 @@ jobs:
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
|
||||
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
|
||||
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
|
||||
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
|
||||
CUT_BY: ${{ github.triggering_actor || github.actor }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||||
@@ -1350,7 +1309,26 @@ jobs:
|
||||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||||
}
|
||||
|
||||
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
|
||||
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
|
||||
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
|
||||
$env:SOURCE_SHORT_SHA
|
||||
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||||
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
|
||||
} else {
|
||||
'unknown'
|
||||
}
|
||||
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||||
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
|
||||
} else {
|
||||
"``$shortSha``"
|
||||
}
|
||||
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
|
||||
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
|
||||
} else {
|
||||
'unknown'
|
||||
}
|
||||
$message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
@@ -1523,6 +1501,12 @@ jobs:
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
|
||||
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
|
||||
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
|
||||
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
|
||||
CUT_BY: ${{ github.triggering_actor || github.actor }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
|
||||
run: |
|
||||
@@ -1537,7 +1521,26 @@ jobs:
|
||||
|
||||
# Why: releases where inner signing fell through have only this one request.
|
||||
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
||||
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
|
||||
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
|
||||
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
|
||||
$env:SOURCE_SHORT_SHA
|
||||
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||||
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
|
||||
} else {
|
||||
'unknown'
|
||||
}
|
||||
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||||
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
|
||||
} else {
|
||||
"``$shortSha``"
|
||||
}
|
||||
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
|
||||
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
|
||||
} else {
|
||||
'unknown'
|
||||
}
|
||||
$message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/terminal-ime-e2e.yml'
|
||||
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
|
||||
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
|
||||
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
|
||||
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
|
||||
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
|
||||
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
- 'tests/e2e/terminal-ime-*.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
@@ -6,56 +6,10 @@ name: Windows Crash-Survival E2E
|
||||
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
|
||||
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
|
||||
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
|
||||
# pull requests keep it as a durable regression gate. A CI runner is the only
|
||||
# manual runs keep it as a durable regression proof. A CI runner is the only
|
||||
# safe place to install — see the relocation post-mortem.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- '.github/workflows/win-crash-survival-e2e.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/verify-packaged-plugin-resources.cjs'
|
||||
- 'native/**'
|
||||
# Byte-hashed at package time, and CRLF-sensitive on Windows.
|
||||
- '.gitattributes'
|
||||
- 'resources/plugins/**'
|
||||
- 'resources/win32/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/index.ts'
|
||||
- 'src/main/ipc/pty*.ts'
|
||||
- 'src/main/persistence.ts'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'src/main/startup/first-window-startup-services.ts'
|
||||
- 'src/main/window/attach-main-window-services.ts'
|
||||
- 'src/preload/**'
|
||||
- 'src/renderer/src/App.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/**'
|
||||
- 'src/renderer/src/hooks/useIpcEvents.ts'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
- 'src/renderer/src/lib/session-write-subscriber.ts'
|
||||
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
|
||||
- 'src/renderer/src/store/slices/terminals.ts'
|
||||
- 'src/shared/pty-session-id-format.ts'
|
||||
- 'tools/win-crash-survival-e2e/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
# Why: source tests and benchmarks do not change the packaged artifact;
|
||||
# their own verify jobs cover them without spending a Windows build slot.
|
||||
- '!src/**/*.test.*'
|
||||
- '!src/**/*.bench.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expect:
|
||||
@@ -181,7 +135,7 @@ jobs:
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$log = "artifacts/crash-survival-output.log"
|
||||
node tools/win-crash-survival-e2e/run.mjs `
|
||||
node tests/tools/win-crash-survival-e2e/run.mjs `
|
||||
--expect "$env:EXPECT" `
|
||||
--exe-path "$env:ORCA_EXE" `
|
||||
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
|
||||
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/win-update-e2e/**'
|
||||
- 'tests/tools/win-update-e2e/**'
|
||||
- '.github/workflows/win-update-e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
$log = "artifacts/harness-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
node tests/tools/win-update-e2e/run.mjs `
|
||||
--from "$env:FROM_EXE" `
|
||||
--to "$env:TO_EXE" `
|
||||
--expect "$env:EXPECT" `
|
||||
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
paths:
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
- 'tests/tools/win-update-e2e/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- '.github/workflows/win-update-survival-e2e.yml'
|
||||
workflow_dispatch:
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
$exe = "dist/orca-windows-setup.exe"
|
||||
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
|
||||
$log = "artifacts/survival-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
node tests/tools/win-update-e2e/run.mjs `
|
||||
--from "$exe" `
|
||||
--to "$exe" `
|
||||
--expect "$env:EXPECT" `
|
||||
|
||||
@@ -141,23 +141,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
uses: ./.github/actions/install-signpath-module
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
name: Windows terminal restart E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- '.github/workflows/windows-terminal-restart-e2e.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'tests/playwright.config.ts'
|
||||
- 'tests/e2e/global-setup.ts'
|
||||
- 'tests/e2e/global-teardown.ts'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
|
||||
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
|
||||
- 'tests/e2e/terminal-probe-input-sequence.ts'
|
||||
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
|
||||
- 'tests/e2e/terminal-restart-persistence.spec.ts'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/ipc/pty*.ts'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'src/main/pty-descendant-termination.ts'
|
||||
- 'src/main/windows-process-tree-kill.ts'
|
||||
- 'src/main/windows-pty-root-identity.ts'
|
||||
- 'src/preload/**'
|
||||
- 'src/renderer/src/components/terminal-pane/**'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
- 'src/shared/process-table-snapshot.ts'
|
||||
- 'src/shared/pty-session-id-format.ts'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
|
||||
+2
-2
@@ -70,7 +70,7 @@ coverage/
|
||||
prod-release-scan-*.md
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
/tests/tools/benchmarks/results/*.json
|
||||
/.bench-fixtures/
|
||||
|
||||
# Temp
|
||||
@@ -132,4 +132,4 @@ src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
tests/tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
|
||||
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
|
||||
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
|
||||
|
||||
export function createBootstrapFatalExitBanner(): string {
|
||||
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
|
||||
// Suppressing that dialog also removes the only account of the failure, so record one first:
|
||||
// a partially copied resources tree otherwise exits silently on every launch, with nothing
|
||||
// for the user or a support log to name the module that was missing.
|
||||
return `
|
||||
;(() => {
|
||||
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
|
||||
if (typeof globalThis[guardKey] === 'function') {
|
||||
return
|
||||
}
|
||||
const describeBootstrapError = (error) => {
|
||||
try {
|
||||
const detail = error && typeof error === 'object' && error.stack ? error.stack : error
|
||||
return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000)
|
||||
} catch {
|
||||
return '<unprintable error>'
|
||||
}
|
||||
}
|
||||
const readBootstrapFatalLogOverride = () => {
|
||||
const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined
|
||||
return typeof override === 'string' && override.length > 0 ? override : undefined
|
||||
}
|
||||
const resolveDefaultBootstrapFatalLogPath = () => {
|
||||
let directory
|
||||
try {
|
||||
directory = require('electron').app.getPath('userData')
|
||||
} catch {
|
||||
// Why: a bootstrap fault can predate a usable app object; temp still outlives the process.
|
||||
directory = require('node:os').tmpdir()
|
||||
}
|
||||
return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)})
|
||||
}
|
||||
const appendBootstrapFatalLine = (fs, logPath, entry) => {
|
||||
try {
|
||||
// Why: an override can name a directory nothing has created yet, and a missing
|
||||
// parent would drop the only account of the failure.
|
||||
fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true })
|
||||
// Why: a broken install repeats this on every relaunch; keep the trail bounded.
|
||||
let flags = 'a'
|
||||
try {
|
||||
flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a'
|
||||
} catch {
|
||||
flags = 'a'
|
||||
}
|
||||
const descriptor = fs.openSync(logPath, flags, 0o600)
|
||||
try {
|
||||
fs.writeSync(descriptor, entry)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const recordBootstrapFailure = (error) => {
|
||||
const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n'
|
||||
let fs
|
||||
try {
|
||||
fs = require('node:fs')
|
||||
} catch {
|
||||
fs = undefined
|
||||
}
|
||||
try {
|
||||
fs.writeSync(2, line)
|
||||
} catch {
|
||||
try {
|
||||
process.stderr.write(line)
|
||||
} catch {
|
||||
// Diagnostics must never replace the exit below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const entry = new Date().toISOString() + ' ' + line
|
||||
const override = readBootstrapFatalLogOverride()
|
||||
// Why: an unwritable override must cost the user a location, not the diagnostic.
|
||||
if (!override || !appendBootstrapFatalLine(fs, override, entry)) {
|
||||
appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry)
|
||||
}
|
||||
} catch {
|
||||
// Diagnostics must never replace the exit below.
|
||||
}
|
||||
}
|
||||
let exitScheduled = false
|
||||
const exitAfterBootstrapFailure = (error) => {
|
||||
if (exitScheduled) {
|
||||
return
|
||||
}
|
||||
exitScheduled = true
|
||||
recordBootstrapFailure(error)
|
||||
process.exitCode = 1
|
||||
setImmediate(() => process.exit(1))
|
||||
}
|
||||
globalThis[guardKey] = () => {
|
||||
process.off('uncaughtException', exitAfterBootstrapFailure)
|
||||
delete globalThis[guardKey]
|
||||
}
|
||||
process.once('uncaughtException', exitAfterBootstrapFailure)
|
||||
})();
|
||||
`
|
||||
}
|
||||
@@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
|
||||
@@ -14,10 +14,16 @@ const {
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
// Why: hourly dev builds must carry the *release* identity — same bundle id and
|
||||
// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed
|
||||
// Orca. Only notarization is skipped, which in-place updates never check.
|
||||
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined
|
||||
const appId = 'com.stablyai.orca'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
@@ -64,7 +70,11 @@ const winSpeechNativeResource = {
|
||||
module.exports = {
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
|
||||
...(hourlyBuildVersion
|
||||
? { extraMetadata: { version: hourlyBuildVersion } }
|
||||
: localBuildVersion
|
||||
? { extraMetadata: { version: localBuildVersion } }
|
||||
: {}),
|
||||
directories: {
|
||||
buildResources: 'resources/build'
|
||||
},
|
||||
@@ -143,21 +153,16 @@ module.exports = {
|
||||
'out/main/claude/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex/**',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/copilot/**',
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
@@ -183,7 +188,7 @@ module.exports = {
|
||||
)
|
||||
: join(context.appOutDir, 'resources')
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
throw new Error(`Missing packaged resources directory: ${resourcesDir}`)
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
|
||||
@@ -213,7 +218,16 @@ module.exports = {
|
||||
// arm64=3, universal=4 (universal contains the host slice, so run it).
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
if (context.arch === hostArchEnum || context.arch === 4) {
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
})
|
||||
if (!canExecuteTargetArch) {
|
||||
console.log(
|
||||
`[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})`
|
||||
)
|
||||
}
|
||||
if (canExecuteTargetArch) {
|
||||
verifyPackagedDaemonEntryBoots(resourcesDir)
|
||||
} else {
|
||||
// Why: a cross-arch slice can't be booted by the host Node, but the
|
||||
@@ -316,7 +330,11 @@ module.exports = {
|
||||
// explicit release path so production artifacts remain strict while dev
|
||||
// artifacts do not fail with broken ad-hoc launch behavior.
|
||||
hardenedRuntime: isMacRelease,
|
||||
notarize: isMacRelease,
|
||||
// Why: Squirrel.Mac validates the replacement bundle's signature, not its
|
||||
// notarization, so hourly builds stay installable while skipping the ~10min
|
||||
// notary round trip 24x a day. A manually-downloaded hourly zip will be
|
||||
// Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel.
|
||||
notarize: isMacRelease && !isMacHourly,
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('darwin'),
|
||||
@@ -456,8 +474,11 @@ module.exports = {
|
||||
publish: {
|
||||
provider: 'github',
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
releaseType: 'release'
|
||||
// Why: hourly tags must never enter the main repo's releases atom feed — it
|
||||
// exposes only the 10 newest entries, so 24 hourly tags a day would evict
|
||||
// every stable/RC entry and strand users on a feed with nothing to install.
|
||||
repo: isMacHourly ? 'orca-hourly' : 'orca',
|
||||
releaseType: isMacHourly ? 'prerelease' : 'release'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
# I18n Translation Source and Automation
|
||||
|
||||
Date: 2026-07-29 (revised 2026-07-30)
|
||||
|
||||
Status: accepted architecture decision; PR A
|
||||
([#8512](https://github.com/stablyai/orca/pull/8512)) merged; PR B not yet
|
||||
implemented
|
||||
|
||||
Revision note: the original decision selected a constrained XLIFF 2.0 profile
|
||||
at 80% confidence, with gettext PO as the named fallback. A tooling,
|
||||
contributor-workflow, and repository-evidence review on 2026-07-30 resolved
|
||||
that uncertainty against XLIFF 2.0 before PR B was built. The architecture is
|
||||
unchanged; the canonical format is now gettext PO. The evidence is recorded
|
||||
under "Alternatives considered".
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce the recurring engineering and human work required to localize Orca
|
||||
without presenting missing, copied-English, or stale values as completed
|
||||
translations.
|
||||
|
||||
The format is not the goal. The intended steady-state workflow is:
|
||||
|
||||
- a feature developer adds a stable message ID and current English copy once;
|
||||
- extraction discovers new and changed messages automatically;
|
||||
- feature work does not wait for every target locale;
|
||||
- translation automation processes only missing and stale units;
|
||||
- humans review a small, risk-prioritized delta rather than whole catalogs;
|
||||
- missing or stale translations safely fall back to current English; and
|
||||
- deterministic, offline runtime bundles ship with the app.
|
||||
|
||||
## Decision
|
||||
|
||||
Use gettext PO as Orca's canonical bilingual translation source, under a
|
||||
constrained profile:
|
||||
|
||||
- `msgctxt` — the stable Orca message ID;
|
||||
- `msgid` — the English source, current as of the entry's last
|
||||
reconciliation;
|
||||
- `msgstr` — the target translation;
|
||||
- `#, fuzzy` — not approved for runtime, whether stale or newly
|
||||
machine-translated; never compiled into runtime bundles;
|
||||
- `#| msgid` — the prior English source associated with the retained
|
||||
translation;
|
||||
- `#.` — translator context and notes;
|
||||
- `#:` — source references (optional, informational); and
|
||||
- `#~` — retired entries, kept only under the retention policy below.
|
||||
|
||||
Product source owns stable message IDs and current English defaults. Maintained
|
||||
extraction produces the current English translation source. Each target locale
|
||||
owns a sparse PO file containing only translations that exist, each carrying
|
||||
the English source it is keyed against.
|
||||
|
||||
Responsibilities are split so target catalogs never change in feature work:
|
||||
|
||||
- **Feature PRs** change English only: source code, inline defaults, and the
|
||||
extracted English source.
|
||||
- **A read-only compiler** joins the current English source with each target
|
||||
PO file by `msgctxt` and generates the existing i18next JSON bundle shape.
|
||||
An entry whose stored `msgid` differs from current English is stale; one
|
||||
whose `msgid` is current but fuzzy is pending approval. Both are omitted so
|
||||
i18next falls back to current English.
|
||||
The compiler never writes to PO files. Generated runtime JSON is disposable
|
||||
and must not be hand-edited.
|
||||
- **Post-merge localization automation** owns all PO mutations. Reconciliation
|
||||
finds entries affected by merged English changes, updates `msgid` to current
|
||||
English, preserves the old source in `#| msgid`, and sets `#, fuzzy` — so
|
||||
translators and tools see the current source, the previous source, and the
|
||||
prior translation in one unit. Reconciliation lands in localization-only
|
||||
changes, never in the originating feature change.
|
||||
|
||||
The reconciler — not gettext's own merge tooling — owns the join. `msgmerge`
|
||||
keys entries on the `(msgctxt, msgid)` pair, so under stable IDs a source
|
||||
change orphans the entry instead of marking it fuzzy. Reconciliation must join
|
||||
by `msgctxt` alone.
|
||||
|
||||
Absence of `#, fuzzy` means approved to ship, whether approval came from a
|
||||
human or an explicitly configured low-risk automation policy. Machine or human
|
||||
provenance is recorded separately in a documented comment or flag; it must not
|
||||
be conflated with runtime eligibility.
|
||||
|
||||
PO is an authoring and translation-workflow boundary, not a runtime
|
||||
dependency. Orca must not require network access or a translation service to
|
||||
display localized UI.
|
||||
|
||||
## Why this solves the work problem
|
||||
|
||||
The system is organized around translating only the delta:
|
||||
|
||||
```text
|
||||
feature change: stable ID + English
|
||||
|
|
||||
v
|
||||
extraction and validation
|
||||
|
|
||||
v
|
||||
missing/changed translation queue
|
||||
|
|
||||
v
|
||||
machine translation + glossary/context
|
||||
|
|
||||
v
|
||||
placeholder and policy validation
|
||||
|
|
||||
v
|
||||
sparse locale bundle or English fallback
|
||||
```
|
||||
|
||||
Feature developers do not synchronize target catalogs, copy English into
|
||||
untranslated locales, run whole-catalog repair, or wait for translation
|
||||
completion. A source change invalidates only the affected target units.
|
||||
Unchanged translations remain byte-stable.
|
||||
|
||||
The bilingual source gives the compiler enough information to distinguish:
|
||||
|
||||
- **missing**: the locale has no entry for the ID;
|
||||
- **stale**: the stored `msgid` differs from current English — true the
|
||||
moment the English change merges, before any reconciliation runs;
|
||||
- **pending approval**: the stored `msgid` is current, a target exists, and
|
||||
`#, fuzzy` is set; and
|
||||
- **current/approved**: the stored `msgid` is current, a target exists, and
|
||||
`#, fuzzy` is absent.
|
||||
|
||||
Missing, stale, and pending-approval entries are omitted from generated
|
||||
target bundles so i18next uses current English. Staleness must not be inferred from Git history,
|
||||
file timestamps, copied-English ratios, or a second hand-maintained database.
|
||||
|
||||
## Constrained PO profile
|
||||
|
||||
Orca will support only the subset needed for its message model. PR B must
|
||||
define and validate that subset rather than accepting arbitrary PO documents.
|
||||
|
||||
Each supported entry must carry:
|
||||
|
||||
- one stable Orca message ID (`msgctxt`), unique within the file;
|
||||
- the English source (`msgid`), current as of the entry's last
|
||||
reconciliation;
|
||||
- an optional target value (`msgstr`) in a target-locale file;
|
||||
- translator notes or UI context when provided (`#.`);
|
||||
- runtime workflow state (`#, fuzzy`), with provenance recorded in the
|
||||
documented separate comment or flag; and
|
||||
- the information needed to validate interpolation placeholders.
|
||||
|
||||
Plurals use Orca's existing per-key convention: each i18next plural-suffixed
|
||||
key (`…_one`, `…_other`) is its own PO entry. The profile must not use
|
||||
`msgid_plural`/`msgstr[n]`, which would collide with i18next's own CLDR plural
|
||||
selection and gettext's `nplurals` model.
|
||||
|
||||
The compiler must reject:
|
||||
|
||||
- duplicate or unstable IDs (including two entries sharing a `msgctxt`);
|
||||
- empty targets represented as completed translations;
|
||||
- unsupported states or constructs;
|
||||
- malformed source/target entries;
|
||||
- nondeterministic ordering or output — the profile must document exact
|
||||
serialization normalization (entry order by `msgctxt`, fixed line folding,
|
||||
LF endings, escaping rules) so every tool in the pipeline emits identical
|
||||
bytes for identical content; and
|
||||
- locale files whose declared language headers are incorrect.
|
||||
|
||||
Placeholder validation is state-dependent (an entry is current only when its
|
||||
stored `msgid` matches current English and it carries no fuzzy flag):
|
||||
|
||||
- **stale or pending-approval entries**: a placeholder mismatch is expected
|
||||
mid-flight; the entry is already omitted from bundles, so validation
|
||||
reports it without failing;
|
||||
- **current/approved entries**: a placeholder mismatch is a hard validation
|
||||
failure; and
|
||||
- **automation and import pipelines**: a produced target with mismatched
|
||||
placeholders is demoted to fuzzy rather than committed as current.
|
||||
|
||||
Source comparison is a semantic comparison of parsed source strings after
|
||||
documented text normalization. Line folding, wrapping, and PO escaping are
|
||||
serialization concerns and must never affect comparison. Reconciliation must
|
||||
never update an entry's stored source without either confirming or replacing
|
||||
the target, or marking the entry fuzzy.
|
||||
|
||||
If translator notes or placeholder semantics can change the required
|
||||
translation without changing visible English, the source-signature contract
|
||||
must include that information or explicitly invalidate the entry.
|
||||
|
||||
Retired entries (`#~`) are kept only while they provide useful translation
|
||||
memory for automation; a deterministic cleanup policy removes them rather than
|
||||
letting them accumulate indefinitely.
|
||||
|
||||
## Message ID and placeholder policy
|
||||
|
||||
The current catalog undermines the stable-ID premise in two ways the format
|
||||
alone cannot fix: 8,981 of 11,564 keys (77.7%) are codemod-generated content
|
||||
hashes, and 825 of 954 interpolated strings use positional placeholders
|
||||
(`{{value0}}`) that carry no meaning for a translator or a machine-translation
|
||||
prompt.
|
||||
|
||||
Policy:
|
||||
|
||||
- hashed IDs already in the desktop catalog as of this decision (2026-07-30)
|
||||
are grandfathered as opaque stable IDs; the generating codemod must not be
|
||||
rerun against existing keys, and no mass rename happens during this
|
||||
migration;
|
||||
- newly minted hash IDs — including in-flight bridge catalogs not yet merged
|
||||
or merged after this decision — are **not** grandfathered: they are renamed
|
||||
to intent-named IDs in a dedicated change adjacent to their landing, before
|
||||
PO becomes canonical for that surface, never buried inside an otherwise
|
||||
reviewed feature PR;
|
||||
- new keys must use intent-named IDs and named interpolation placeholders;
|
||||
- converting positional placeholders to named ones requires per-message
|
||||
semantic judgment (the name is part of the translatable contract), not a
|
||||
mechanical rename; and
|
||||
- legacy IDs and positional placeholders are improved opportunistically when
|
||||
the copy itself is touched, never in bulk.
|
||||
|
||||
## Runtime and source ownership
|
||||
|
||||
The ownership chain is:
|
||||
|
||||
```text
|
||||
product code and explicit dynamic declarations
|
||||
|
|
||||
v
|
||||
extracted current English source
|
||||
|
|
||||
v
|
||||
sparse canonical target-locale PO files
|
||||
|
|
||||
v
|
||||
deterministic i18next JSON bundles
|
||||
|
|
||||
v
|
||||
lazy-loaded offline app resources
|
||||
```
|
||||
|
||||
Inline English defaults remain readable at call sites and provide extraction
|
||||
input. Static extraction and explicit declarations for genuinely dynamic keys
|
||||
must reconcile to one current English translation source. CI must reject
|
||||
missing declarations and incompatible defaults rather than creating a
|
||||
permanent exception database.
|
||||
|
||||
Renderer, Electron main, web, SSH, WSL, and packaged execution continue to
|
||||
consume the same logical runtime bundles. The compiler and build paths must use
|
||||
cross-platform path handling and deterministic line endings.
|
||||
|
||||
Two additional surfaces are in scope:
|
||||
|
||||
- **Mobile.** The mobile app is acquiring its own catalog tree with its own
|
||||
key scheme. Mobile must adopt the same canonical contract, ID policy, and
|
||||
compiler — with its own PO files — rather than a second bespoke pipeline.
|
||||
Mobile keys must not be folded into the desktop catalogs, and in-flight
|
||||
mobile catalog JSON is a bridge the migration replaces, not a canonical
|
||||
source. Mobile requires **two deterministic projections** from the same PO
|
||||
source: the mobile i18next JSON bundle, and the native metadata resources
|
||||
(iOS `InfoPlist.strings`, Android resources) that render before the JS
|
||||
runtime exists. i18next fallback cannot cover pre-JS surfaces, so for a
|
||||
missing, stale, or pending-approval native entry the compiler must either
|
||||
omit the locale-specific native key with proven OS/base-locale fallback on
|
||||
both platforms, or emit current English under a documented platform rule.
|
||||
The intentional locale-ID mapping (JS `zh` vs native `zh-Hans`) is part of
|
||||
the projection contract. The migration covers both projections.
|
||||
- **Plugin language packs.** Language packs remain external overrides
|
||||
validated against compiled core keys. They are consumers of the contract,
|
||||
not another canonical source, and the compiler must leave their runtime
|
||||
path intact.
|
||||
|
||||
## Translation automation
|
||||
|
||||
The architecture is incomplete until translation work is delta-driven.
|
||||
After the compiler and migration are stable, automation should:
|
||||
|
||||
1. reconcile entries affected by merged English changes (update `msgid`,
|
||||
preserve `#| msgid`, set fuzzy) and find target entries that are missing
|
||||
or stale;
|
||||
2. batch only those entries into a translation change;
|
||||
3. supply translator notes, glossary terms, placeholder rules, the previous
|
||||
source from `#| msgid`, and relevant existing translations;
|
||||
4. preserve all unaffected target entries byte-for-byte;
|
||||
5. mark runtime eligibility and provenance honestly;
|
||||
6. run placeholder, terminology, formatting, and catalog validation, demoting
|
||||
invalid output to fuzzy; and
|
||||
7. fall back to English rather than shipping an invalid target.
|
||||
|
||||
Human review should be risk-based. Destructive actions, authentication,
|
||||
billing, privacy, security, legal copy, OS permission prompts (camera,
|
||||
microphone, photos, local network), native app metadata, and other sensitive
|
||||
flows require review. Routine labels and descriptions may use validated machine translation
|
||||
under the adopted release policy. A model-provided confidence score alone is
|
||||
not sufficient release evidence.
|
||||
|
||||
Catalog QA should include an identical-to-English guard implemented as a
|
||||
ratchet with reviewed exemptions, not a blanket prohibition: brand names,
|
||||
commands, and terms that legitimately remain English live in the exemption
|
||||
inputs, and the untranslated ratio for everything else must not rise.
|
||||
|
||||
Glossary and forbidden-translation rules belong in stable QA inputs. The
|
||||
existing repair-script policy data (glossaries, never-translate lists,
|
||||
per-locale overrides) is converted into those inputs. They must replace
|
||||
historical whole-catalog repair scripts rather than coexist with them as
|
||||
another source of product behavior.
|
||||
|
||||
## Rollout
|
||||
|
||||
### PR A: decouple feature copy — merged
|
||||
|
||||
PR #8512:
|
||||
|
||||
- permits sparse target catalogs;
|
||||
- makes English runtime fallback authoritative for missing values;
|
||||
- makes catalog synchronization update English only;
|
||||
- preserves existing target values and rejects placeholder incompatibility;
|
||||
- adds maintained extraction and CI verification; and
|
||||
- removes whole-catalog translation and repair commands from ordinary product
|
||||
workflows.
|
||||
|
||||
PR A is independently useful and format-neutral. It stops new parity work, but
|
||||
it does not permanently model reviewed or stale translation state.
|
||||
|
||||
PR A also created a deletion ratchet: extra target-locale keys hard-fail
|
||||
verification and sync no longer edits targets, so an English key cannot be
|
||||
retired without hand-editing every target catalog. Orphaned English keys
|
||||
(2,055 unreferenced at time of writing) grow with every rename until PR C
|
||||
lands. PR C should not wait long.
|
||||
|
||||
### PR B: define the source and compiler
|
||||
|
||||
PR B must remain a small architecture and implementation proof. It should add:
|
||||
|
||||
1. the constrained PO profile and its serialization normalization;
|
||||
2. extraction into the current English source;
|
||||
3. sparse source/target parsing via maintained PO tooling;
|
||||
4. deterministic, read-only i18next bundle compilation, omitting entries
|
||||
whose stored source mismatches current English or that are fuzzy — with a
|
||||
compiler shape that admits additional per-locale projections (the mobile
|
||||
native-resource output) even though PR B implements only the i18next
|
||||
bundle;
|
||||
5. a separate reconciliation command owning all PO mutations (`msgid`
|
||||
update, `#| msgid` preservation, `#, fuzzy`);
|
||||
6. state-dependent placeholder validation;
|
||||
7. representative fixtures for interpolation, plural-suffixed keys, multiline
|
||||
copy, translator notes, and source changes; and
|
||||
8. packaged-build integration across local and remote execution boundaries.
|
||||
|
||||
Acceptance tests must assert every workflow field — state, previous source,
|
||||
notes, retirement — field-by-field through parse → modify → serialize cycles.
|
||||
Byte-identical round-trip alone is not evidence: a lossy parser faithfully
|
||||
reproduces its own losses, so a round-trip gate can pass while the fields that
|
||||
justify the format are silently destroyed. This failure mode was demonstrated
|
||||
against the dominant XLIFF library during the format review.
|
||||
|
||||
It must not migrate the complete locale inventory. That keeps the format and
|
||||
compiler independently reviewable and makes rejection inexpensive.
|
||||
|
||||
### PR C: migrate existing translations once
|
||||
|
||||
The migration must:
|
||||
|
||||
- export all current stable English IDs and values;
|
||||
- import real target values without retranslating them;
|
||||
- preserve known reviewed corrections;
|
||||
- mark unproven values as pending approval, with imported provenance —
|
||||
bridge catalogs in particular mix reused desktop translations, machine
|
||||
translation, and manual corrections, and must be classified rather than
|
||||
assumed approved;
|
||||
- remove copied-English parity filler unless it is intentionally English,
|
||||
seeding the identical-to-English exemption inputs from the existing policy
|
||||
data;
|
||||
- turn terminology and known mistranslations into glossary and QA inputs;
|
||||
- drain the orphaned-English-key ratchet and delete the retired bootstrap and
|
||||
repair script files; and
|
||||
- explain every difference between current and compiled runtime bundles.
|
||||
|
||||
Every existing target value must be byte-preserved, explicitly retired, or
|
||||
classified as filler. No Git-history inference should remain in the permanent
|
||||
runtime or verification path.
|
||||
|
||||
### PR D: switch ownership and delete the bridge
|
||||
|
||||
After bundle equivalence is proven:
|
||||
|
||||
- PO becomes the only editable target translation source;
|
||||
- generated runtime JSON stops being a canonical input;
|
||||
- development, test, build, and packaging compile the bundles
|
||||
deterministically;
|
||||
- a JSON→PO importer ships for contributors, so existing full-catalog
|
||||
community PRs can be converted instead of abandoned;
|
||||
- legacy bootstrap, repair, override, and migration-only bookkeeping is
|
||||
removed; and
|
||||
- ordinary CI runs one focused extraction and compile verification path.
|
||||
|
||||
### Translation automation
|
||||
|
||||
The first automation follow-up should create or update localization-only
|
||||
changes containing only missing and stale entries. It should not rewrite a
|
||||
whole locale, modify feature code, or block the originating feature change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Constrained XLIFF 2.0 — original choice, reversed 2026-07-30
|
||||
|
||||
XLIFF 2.0 has the best abstract schema fit: stable IDs, source and target in
|
||||
one unit, standardized workflow state, and categorized notes. The decision was
|
||||
reversed when the stated uncertainties resolved against it:
|
||||
|
||||
- the dominant JS XLIFF library silently discards `state`, `subState`, and
|
||||
note categories — the fields that motivate the format — while reporting a
|
||||
clean round-trip, so the planned acceptance gate would have passed
|
||||
vacuously; the only spec-faithful JS alternative has ~90 weekly downloads,
|
||||
a single maintainer, and EPL-1.0 licensing;
|
||||
- XLIFF 2.0 core defines no previous-source mechanism, so the "what changed"
|
||||
payload for translation automation requires a custom extension that breaks
|
||||
the interoperability that justified the format (leaving the old `<source>`
|
||||
in the target file and comparing against a separate current-English file is
|
||||
possible, but then translators and tools see the wrong current source
|
||||
unless another projection is built — PO represents the joined working state
|
||||
natively);
|
||||
- tool and platform support for XLIFF 2.x remains weaker than 1.2 more than a
|
||||
decade after standardization; no verified case of XLIFF 2.0 as an in-repo
|
||||
canonical source was found, and the closest public precedent for this exact
|
||||
compiler design chose 1.2; and
|
||||
- XML is the format most likely to let a hand-edited community PR fail on a
|
||||
malformed entity, and there is no cross-tool XML formatting convention, so
|
||||
external editors produce whole-file diffs; Orca's translation contributions
|
||||
arrive overwhelmingly as direct catalog PRs.
|
||||
|
||||
If a translation provider requires XLIFF, Orca should add a deterministic
|
||||
import/export adapter at the boundary rather than change the canonical
|
||||
representation.
|
||||
|
||||
### XLIFF 1.2
|
||||
|
||||
Broader legacy tool support than 2.0, but more tool-specific dialects and a
|
||||
less coherent data model, and it shares XML's contributor and formatting
|
||||
costs. Adapter-only, as above.
|
||||
|
||||
### Direct JSON or TypeScript catalogs
|
||||
|
||||
These are simple runtime inputs, but they do not standardize source snapshots,
|
||||
translator context, or review state. Meeting Orca's requirements would require
|
||||
custom sidecars or object schemas and synchronization rules. That recreates
|
||||
much of a bilingual standard while retaining the parity and stale-state risks
|
||||
the migration is intended to remove.
|
||||
|
||||
### Bespoke bilingual JSON — the current fallback
|
||||
|
||||
A JSON schema with embedded per-locale state, source snapshots, and notes is a
|
||||
proven model (a major platform vendor ships exactly this shape, with per-key
|
||||
extraction state including staleness and per-locale review state) and would
|
||||
add no parsing dependency. It is second to PO because PO's workflow fields are
|
||||
standard rather than bespoke, existing translation editors and open tooling
|
||||
understand them, and its diffs are the smallest of the three formats. If the
|
||||
PR B PO proof fails, adopt this — not XLIFF — before PR C.
|
||||
|
||||
### Platform-native catalogs
|
||||
|
||||
Platform-native string catalogs provide strong translation state on their
|
||||
own platform but are not a suitable authority for Orca's macOS, Linux,
|
||||
Windows, web, WSL, and SSH surfaces.
|
||||
|
||||
### Proprietary translation platform
|
||||
|
||||
A translation platform may later operate on the PO boundary, but it should
|
||||
not become Orca's sole source of truth or a runtime dependency. Selecting one
|
||||
before the repository contract exists would create premature vendor coupling.
|
||||
|
||||
### Peer practice
|
||||
|
||||
Inspectable applications validate the architecture more strongly than any one
|
||||
format:
|
||||
|
||||
- mature systems use stable keys or source messages, authoritative English,
|
||||
sparse targets, and runtime fallback;
|
||||
- applications that keep canonical translations in-repo with PR-based
|
||||
contribution use PO or stateful JSON catalogs; none inspected uses XLIFF
|
||||
2.0 as a canonical source, and the only XLIFF observed was 1.2 as
|
||||
interchange into an external pipeline;
|
||||
- the delta-driven machine-translation model this document targets is already
|
||||
shipping in peer practice, keyed by per-entry source tracking;
|
||||
- structural key parity is not a completeness signal — peer catalogs exist
|
||||
with perfect parity and majority copied-English content, which is what the
|
||||
identical-to-English ratchet detects; and
|
||||
- gates that are written but not enforced decay — every verification in this
|
||||
plan must run in CI from the PR that introduces it.
|
||||
|
||||
Closed-source applications do not publish enough of their authoring pipelines
|
||||
to establish a format decision. Orca therefore chooses based on its own
|
||||
requirements rather than presumed competitor internals.
|
||||
|
||||
## Confidence and decision gate
|
||||
|
||||
Confidence is:
|
||||
|
||||
- **97%** in the overall sparse-source, source-snapshot, deterministic-compiler,
|
||||
and English-fallback architecture;
|
||||
- **90%** in PO over constrained XLIFF 2.0;
|
||||
- **85%** in PO over bespoke bilingual JSON; and
|
||||
- **95%** that a migration-free PR B prototype on PO is the correct next step.
|
||||
|
||||
The remaining PO uncertainty concerns exact serialization normalization
|
||||
(folding, escaping) across the tools contributors use, the provenance
|
||||
convention recorded alongside `#, fuzzy`, and the `#~` retention policy.
|
||||
|
||||
PR B closes the decision gate only if its representative fixtures preserve
|
||||
every workflow field through parse → modify → serialize verified
|
||||
field-by-field, output is deterministic, diffs remain reviewable, and the
|
||||
compiler/build integration is maintainable. If that proof fails, adopt bespoke
|
||||
bilingual JSON before PR C. Do not fall back to XLIFF 2.0, add compatibility
|
||||
layers, or create custom sidecar state merely to preserve a format choice.
|
||||
|
||||
This is a deliberate, reversible senior-engineering decision — and it has
|
||||
already been exercised once: the original XLIFF 2.0 selection was reversed
|
||||
when evidence resolved its stated uncertainties, at zero migration cost
|
||||
because the staged rollout deferred format commitment until PR B.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
The completed system must not:
|
||||
|
||||
- require every target locale to contain every English key;
|
||||
- copy English into target catalogs to simulate coverage;
|
||||
- infer permanent translation state from Git history;
|
||||
- retranslate or rewrite complete catalogs for ordinary copy changes;
|
||||
- make generated runtime JSON another hand-edited source;
|
||||
- rename existing hashed message IDs in bulk;
|
||||
- treat plugin language packs as a canonical source;
|
||||
- block feature PRs while translations catch up;
|
||||
- require runtime network access; or
|
||||
- maintain multiple canonical translation databases.
|
||||
@@ -4,6 +4,9 @@ This is the pre-work artifact for migrating Orca to a localized UI. The goal is
|
||||
to make coverage repeatable: every detected user-facing string is either moved
|
||||
behind the localization layer or explicitly excluded with a reason.
|
||||
|
||||
The accepted translation-source architecture and staged migration are recorded
|
||||
in [`i18n-translation-source.md`](./i18n-translation-source.md).
|
||||
|
||||
## Coverage Contract
|
||||
|
||||
Coverage means all strings matching the audit scope below are accounted for:
|
||||
@@ -75,9 +78,11 @@ have no package-script entry points. Ordinary product and localization work must
|
||||
not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
new candidates fail the check and must be localized or added with a reviewed
|
||||
reason in the same change.
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is
|
||||
small (10 reviewed entries — one test fixture title, five non-English
|
||||
language-name search keywords, and four reviewed product-name search
|
||||
keywords): new candidates fail the check and must be localized or added with
|
||||
a reviewed reason in the same change.
|
||||
|
||||
The script scans `src/renderer/src` by default. That is the primary UI surface.
|
||||
Use `--source-root src` for a wider audit when checking renderer-adjacent shared
|
||||
|
||||
@@ -251,7 +251,6 @@ inline src/renderer/src/components/tab-bar/TabBar.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
inline src/renderer/src/components/task-page-cache-selectors.ts
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
|
||||
+640
-45
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-29",
|
||||
"updatedAt": "2026-07-31",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,6 +10,156 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "ssh-relay.staged-upload-recovery",
|
||||
"title": "SSH relay uploads remain retryable before the shared install lock",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "ssh-relay-install",
|
||||
"layer": "ssh-transfer-install-contract",
|
||||
"surfaces": [
|
||||
"SSH relay first install",
|
||||
"split shell and SFTP namespaces",
|
||||
"system SSH transfer fallback",
|
||||
"relay install retry after cancellation"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["ssh2", "system-ssh"],
|
||||
"coveredPlatforms": ["macos", "linux"],
|
||||
"coveredProviders": ["ssh2", "system-ssh"],
|
||||
"coverageNotes": "Deterministic unit, exact POSIX shell, native ARM macOS PowerShell 7.6.4, and real ssh2 SFTP-wire tests cover lock ordering, concurrent-install loss, fixed-slot ownership identity, payload-only promotion, bounded stale-stage reclamation, installed-fast-path draining, joined cancellation teardown, cross-version isolation, split-SFTP redirection, and system-SSH bypass. A throwaway linux-arm64 Docker sshd reached through a non-loopback LAN address covers live bytes-in-flight SFTP cancellation, injected unconfirmed cancellation, immediate retry against a real Git repository, fixed-slot recovery behind unclaimable entries, and real version-GC filtering with 15,197 unrelated names.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/9828",
|
||||
"https://github.com/stablyai/orca/pull/10207"
|
||||
],
|
||||
"invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.",
|
||||
"oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.",
|
||||
"commands": [
|
||||
"node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell <PowerShell-7.6.4-executable> src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
|
||||
"ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST=<non-loopback-host> ORCA_REVIEW_SSH_IMAGE=<throwaway-sshd-image> ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ssh/sftp-upload.test.ts",
|
||||
"src/main/ssh/ssh-file-transfer-abort.test.ts",
|
||||
"src/main/ssh/ssh-relay-deploy-staged-upload.test.ts",
|
||||
"src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts",
|
||||
"src/main/ssh/ssh-relay-sftp-namespace-install.test.ts",
|
||||
"src/main/ssh/ssh-relay-install-namespace.test.ts",
|
||||
"src/main/ssh/ssh-relay-upload-stage-commands.test.ts",
|
||||
"src/main/ssh/sftp-namespace-resolution.test.ts",
|
||||
"src/main/ssh/ssh-connection-sftp-wire.test.ts",
|
||||
"src/main/ssh/ssh-remote-commands.test.ts",
|
||||
"src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
|
||||
"src/main/ssh/ssh-relay-upload-cancel.docker.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ssh/sftp-upload.test.ts",
|
||||
"assertions": ["joins local file-descriptor teardown when a live upload is aborted"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-file-transfer-abort.test.ts",
|
||||
"assertions": [
|
||||
"joins confirmed SFTP close and transfer teardown before rejecting an abort",
|
||||
"marks transfer teardown unconfirmed when close wins but the transfer never settles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-upload-stage-commands.test.ts",
|
||||
"assertions": [
|
||||
"bounds reservation at 0, 1, 7, 8, and 9+ entries on POSIX and native PowerShell",
|
||||
"rejects same-path replacement, symlink, reparse, and identity substitution before promotion or deletion",
|
||||
"reclaims at most one valid stale fixed slot and progresses across repeated deployments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts",
|
||||
"assertions": [
|
||||
"waits for a deferred SFTP upload before acquiring the install lock",
|
||||
"drops only its stage when a sibling finishes before the locked re-probe",
|
||||
"recovers one fixed stale stage before a fresh upload",
|
||||
"launches before bounded installed-path recovery",
|
||||
"never enumerates arbitrary stage paths during installation",
|
||||
"retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-remote-commands.test.ts",
|
||||
"assertions": [
|
||||
"uses encoded PowerShell for Windows deploy commands",
|
||||
"enumerates Windows staging children before copying",
|
||||
"lets only one PowerShell caller acquire a legacy-visible lock"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
|
||||
"assertions": ["a v2 deploy never references the v1 install dir or v1 socket path"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts",
|
||||
"assertions": [
|
||||
"redirects every first-install artifact transfer while shell commands stay canonical",
|
||||
"leaves system-SSH connections unmapped and unprobed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts",
|
||||
"assertions": [
|
||||
"aborts a live SFTP upload after remote bytes arrive without creating the shared lock",
|
||||
"recovers cancellation with bounded safe reclamation and bounded real version GC"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell <PowerShell-7.6.4-executable> src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 57.9,
|
||||
"summary": "Eleven focused files passed 152 tests with 3 platform skips using exact POSIX sh, native ARM macOS PowerShell 7.6.4, real ssh2 split-SFTP wire sessions, fixed-slot identity races, bounded recovery, joined cancellation teardown, and cross-version isolation."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "linux",
|
||||
"command": "ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST=<non-loopback-host> ORCA_REVIEW_SSH_IMAGE=<throwaway-sshd-image> ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12.58,
|
||||
"summary": "A throwaway linux-arm64 Docker sshd acknowledged 65,536 of 837,401 relay.js bytes before live abort with no shared lock and preserved the foreign sleep sentinel. Two injected unconfirmed stages remained pre-lock; retry launched the relay, answered RPC, and read a real Git HEAD. Installed recovery preserved a same-owner identity-mismatched replacement, its original, a POSIX symlink, and its foreign target while draining one valid stale slot behind them. Real version GC retained all 15,197 unrelated names, emitted 25 bytes, and completed in 25 ms."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 35,
|
||||
"scope": "focused unit, SFTP-wire, and local Docker SSH contracts"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Focused deterministic and Docker runs pass locally; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "A byte-identical local Docker oracle run on latest main left a fresh shared .install-lock and blocked retry; the staged candidate left no shared lock and recovered immediately, and disabling staged ordering restored the blocked result. No committed baseline artifact or baseline installed-fast-path cleanup oracle is retained, so this evidence is intentionally not marked complete."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Stage recovery examines only eight fixed slot/claim/delete paths and reclaims at most one stale valid stage per invocation; installed reconnects launch before asynchronous recovery. Full quota produces an explicit error instead of unbounded cleanup. Version GC still scans the relay base directory, but remote filtering caps stdout and local candidate work at 64. Cancellation adds one bounded five-second join of channel and transfer settlement."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history.",
|
||||
"Run live first-install cancellation and retry on Windows OpenSSH and a split-SFTP Synology-class host.",
|
||||
"Keep exact lock-order, no-follow identity, promotion, bounded reclamation, and teardown assertions in the gate command."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live Docker target is Linux ARM64 with a unified namespace; split-SFTP behavior is covered by real ssh2 wire and deterministic deploy fixtures.",
|
||||
"Native PowerShell coverage runs on ARM macOS with POSIX filesystem paths; Windows OpenSSH, Windows PowerShell 5.1, and system-SSH behavior remain command and transfer-contract coverage rather than a live target.",
|
||||
"The fixed pool retains up to eight relay bundles; eight foreign or otherwise unclaimable fixed states require manual inspection instead of automatic deletion.",
|
||||
"Version GC remotely filters and caps output but still scans the base .orca-remote directory; it does not promise constant remote enumeration time.",
|
||||
"The Docker oracle is opt-in because it requires a local image and a reachable non-loopback host address."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if cancellation creates the shared install lock before transfer settlement, a split-SFTP transfer loses identity proof, recovery deletes a replacement/symlink/reparse/foreign stage, fixed-path work exceeds its eight-slot bound, cancellation leaks a local descriptor, or the focused gate flakes without a product or harness bug."
|
||||
},
|
||||
{
|
||||
"id": "mobile-ui.drawer-close-continuity",
|
||||
"title": "Mobile drawers finish closing despite parent rerenders",
|
||||
@@ -2224,6 +2374,7 @@
|
||||
"Advanced Active Server setting",
|
||||
"saved server Connect and Disconnect",
|
||||
"remote workspace navigation",
|
||||
"Add Project host selection, scan, import, and catalog refresh",
|
||||
"terminal reveal and create",
|
||||
"browser and mobile handoff",
|
||||
"app restart"
|
||||
@@ -2232,12 +2383,16 @@
|
||||
"providers": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, and restart reset of transient routing are covered. Live desktop UI runs remain uncollected.",
|
||||
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project owner capture and stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus a separate paired Electron client proves real Git, plain-folder, Clone, Create, nested import, runtime-switch, disconnect, reconnect, concurrently visible same-ID local/runtime repo/worktree/group/folder rows, reversed catalog order, reversed refresh completion, local-after-runtime catalog refresh routing, and first paired-terminal activation without local fallback; hidden-window parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/9687"],
|
||||
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Generic settings mutation cannot bypass the dedicated preference IPC.",
|
||||
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
|
||||
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project scan, add, import, catalog refresh, and worktree refresh must stay on the host selected when that operation began, even if the durable preference changes or the repo catalog is not hydrated. Generic settings mutation cannot bypass the dedicated preference IPC.",
|
||||
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. In a separate paired Electron client, select a non-default headed runtime and add a real Git repo, plain folder, clone, created project, and nested repos while switching the preference and disconnecting/reconnecting. Before Git Add, plain-folder Add, Clone, and Create completion, insert a same-ID local repo/worktree decoy; require final activation to remain on the captured runtime. Merge same-ID local/runtime repo, worktree, group, and folder rows, reverse same-host refresh completions, overlap refresh with reconnect, issue ordinary unqualified local refreshes, then create the first paired runtime terminal while the same-ID local/runtime worktrees remain visible. Require only the selected server inventory to change, exact runtime ownership in the client store, preserved host-qualified catalogs, authoritative direct-SSH pruning without runtime sibling loss, visible projects, and zero direct SSH fallback. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
|
||||
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
|
||||
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/settings.test.ts",
|
||||
@@ -2249,7 +2404,28 @@
|
||||
"src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
|
||||
"src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
|
||||
"src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"src/renderer/src/web/web-preload-api.test.ts"
|
||||
"src/renderer/src/web/web-preload-api.test.ts",
|
||||
"src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx",
|
||||
"src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx",
|
||||
"src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts",
|
||||
"src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts",
|
||||
"src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
|
||||
"src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts",
|
||||
"src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts",
|
||||
"src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts",
|
||||
"src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts",
|
||||
"src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts",
|
||||
"src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts",
|
||||
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
|
||||
"src/renderer/src/lib/worktree-runtime-owner.test.ts",
|
||||
"src/renderer/src/store/selectors.test.ts",
|
||||
"src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts",
|
||||
"src/renderer/src/store/slices/repos-project-groups.test.ts",
|
||||
"src/renderer/src/store/slices/repos-selected-owner-routing.test.ts",
|
||||
"src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts",
|
||||
"src/renderer/src/store/slices/worktrees.test.ts",
|
||||
"src/renderer/src/runtime/web-runtime-session.test.ts",
|
||||
"tests/e2e/pr11346-selected-runtime-add.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -2292,6 +2468,73 @@
|
||||
"generic web settings writes cannot mutate Active Server",
|
||||
"the dedicated web preference setter rejects unknown server identities without corrupting the saved choice"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts",
|
||||
"assertions": [
|
||||
"stamps a Clone response with the captured runtime or SSH owner before store upsert and worktree refresh",
|
||||
"preserves same-ID repository siblings that belong to different execution hosts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts",
|
||||
"assertions": [
|
||||
"stamps Create responses with the captured runtime or SSH owner before store upsert and Git or folder worktree refresh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts",
|
||||
"assertions": [
|
||||
"keeps same-ID local, direct-SSH, and runtime group/folder rows partitioned by execution host",
|
||||
"drops reversed same-host and pre-reconnect group/folder responses without pruning newer catalogs",
|
||||
"prunes deleted direct-SSH rows from the desktop-owned catalog without erasing same-ID runtime siblings",
|
||||
"keeps explicit runtime groups and folders after a later ordinary local refresh",
|
||||
"keeps a selected-runtime import refresh across an overlapping local refresh",
|
||||
"pins selected SSH scans and cancellation to local IPC over an ambient runtime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
|
||||
"assertions": [
|
||||
"filters loaded, detected, refreshed, and activated default checkouts by the captured execution host when repo IDs collide"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/worktrees.test.ts",
|
||||
"assertions": [
|
||||
"honors an explicit runtime owner before the repo catalog is hydrated",
|
||||
"honors an explicit SSH owner before the repo catalog is hydrated",
|
||||
"rejects a missing-owner SSH result after the repo catalog changes",
|
||||
"rejects a missing-owner SSH result after the provider reconnects"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts",
|
||||
"assertions": [
|
||||
"selects the runtime worktree when exact repo/worktree IDs collide in local-first or reversed order",
|
||||
"keeps exact-ID folder/group activation on the explicitly selected runtime without local fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/selectors.test.ts",
|
||||
"assertions": [
|
||||
"does not fall back to a same-ID local repo when the selected runtime row is unavailable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/web-runtime-session.test.ts",
|
||||
"assertions": [
|
||||
"terminal, browser, and staged-browser selection preserve the explicit runtime execution host when local and runtime worktree IDs collide"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/pr11346-selected-runtime-add.spec.ts",
|
||||
"assertions": [
|
||||
"routes Git Add, plain-folder Add, Clone, Create, and nested import to a selected non-default headed runtime while exact-ID local siblings remain visible and cannot capture final activation @headful",
|
||||
"preserves same-ID runtime group/folder/worktree catalogs across reversed order, local refresh, switch, disconnect, and reconnect-overlap completion",
|
||||
"keeps every expanded selected-runtime Add Project path in hidden-window desktop parity",
|
||||
"creates the first paired runtime terminal while same-ID local/runtime worktrees remain visible without clearing the selected runtime owner"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -2303,34 +2546,91 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.55,
|
||||
"summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.77,
|
||||
"summary": "Nineteen files and 386 tests passed after rebasing onto 94cf2f1422f30fc309cb47c5e864a831d516fa8b, including exact-ID repo/worktree/group/folder host identity in both catalog orders, fail-closed active-repo selection when a runtime row is unavailable, selected runtime and SSH owner capture through final Add/Clone/Create/folder activation, direct-SSH authoritative pruning, reversed catalog responses, reconnect generation fencing, local-after-runtime isolation, and missing-catalog worktree routing."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
|
||||
"result": "passed",
|
||||
"durationSeconds": 23,
|
||||
"summary": "A visible isolated Orca desktop server and separate paired Electron client added one real Git repo, one plain folder, one clone, one created project, and two nested repos to the selected non-default runtime across preference switches and disconnect/reconnect. Exact-ID local/runtime repo, worktree, group, and folder rows remained concurrently visible in local-first and reversed order without capturing final activation. Server inventory, active host identity, rendered rows, local-client exclusion, preserved post-reconnect catalogs, and zero direct SSH fallback agreed. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 307/1667/2932/1247/1321/259/1319 ms."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
|
||||
"result": "passed",
|
||||
"durationSeconds": 66,
|
||||
"summary": "The hidden-window desktop-server parity journey passed with exact-ID Add/Clone/Create activation, selected-runtime inventory, reversed catalog order, reconnect overlap, ownership, local exclusion, and host-qualified catalog-preservation assertions. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 59/1770/4330/1375/2137/279/1945 ms."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 7.1,
|
||||
"summary": "Twenty files and 430 tests passed on current main integration, including explicit runtime-owner preservation for terminal and browser session activation."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
|
||||
"result": "passed",
|
||||
"durationSeconds": 19.9,
|
||||
"summary": "The visible headed server and separate paired client passed the strengthened same-ID terminal-activation oracle with a disposable runtime Git identity. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 43/1513/3841/1200/790/202/1312 ms."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
|
||||
"result": "passed",
|
||||
"durationSeconds": 19.7,
|
||||
"summary": "The hidden-window server and separate paired client passed the strengthened same-ID terminal-activation oracle with a disposable runtime Git identity. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 51/1502/3843/880/939/200/1359 ms."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "focused persistence and routing contract tests"
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused persistence/routing contracts plus isolated paired Electron journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic gate with no soak history."
|
||||
"evidence": "Deterministic contracts and the final visible plus hidden-window paired Electron runs passed locally. During expansion, attempts exposed three harness-only assumptions: exact project labels did not allow path disambiguation, clicking the inner host label raced cmdk layout, and a disposable CI runtime had no Git author identity for Create. The oracle now targets the host command item, accepts the rendered disambiguated label, seeds only its isolated runtime home with a test Git identity, and explicitly creates a first paired terminal while same-ID local/runtime worktrees remain present. CI soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The user-observed local terminal reveal failure and unintended Active Server switch are encoded by deterministic routing and persistence tests; a saved intentional-break artifact is not yet attached."
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Preference writes are explicit user actions; transient routing adds no polling and uses existing host/worktree indexes."
|
||||
"required": true,
|
||||
"evidence": "Preference writes are explicit user actions; transient routing adds no polling or provider fanout and retains one authoritative worktree refresh per completed add. Host-qualified owner indexes are WeakMap-cached and do not add subprocesses or network calls. The final headed journey measured 43 ms for three preference switches, 1,513 ms for Git add, 3,841 ms for folder add, 1,200 ms for Clone, 790 ms for Create with controlled exact-ID completion gates, 202 ms for disconnect/reconnect with overlapping catalog refreshes, and 1,312 ms for nested import. The final session-activation fix adds one execution-host string conversion per explicit terminal/browser activation and no provider call, scan, timer, or retry. Existing Zustand selector fan-out evidence remained 0 render invalidations across 5,000,000 selector runs."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the focused gate in soak across macOS, Linux, and Windows.",
|
||||
"Attach a live Windows Local -> Windows 2 -> local reveal -> restart artifact.",
|
||||
"Attach saved red/green evidence for generic settings mutation and transient connection routing."
|
||||
"Attach saved red/green evidence for generic settings mutation and transient connection routing.",
|
||||
"Run the paired Add Project journey on Linux and native Windows, plus live SSH and WSL hosts."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The exact journey is deterministic contract coverage, not a packaged Windows UI automation run.",
|
||||
"Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session."
|
||||
"Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session.",
|
||||
"The paired Add Project journey is macOS-only; native Windows was unavailable.",
|
||||
"SSH and WSL Add Project ownership is deterministic store/controller coverage, not a live host journey."
|
||||
],
|
||||
"demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal depends on the durable default instead of workspace ownership, or if transient host state survives restart."
|
||||
"demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal or Add Project depends on the durable default instead of captured workspace/host ownership, if an Add Project operation reaches a different host after it begins, or if transient host state survives restart."
|
||||
},
|
||||
{
|
||||
"id": "terminal-geometry.visible-convergence",
|
||||
@@ -2806,11 +3106,11 @@
|
||||
"oracle": "Resolve the launched instance's real main PID from inside Electron, force-kill only that PID, require it to die, require one command-line-scoped daemon PID and the stamped interactive shell PID to remain live, relaunch with persisted state, require the daemon PID to remain identical, read the exact shell PID and a per-shell environment sentinel back through the exact restored tab, then require a successful Windows Application event-log query with zero matching pwsh FailFast events across the full crash-to-input window.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts config/scripts/win-crash-survival-e2e.test.mjs",
|
||||
"node tools/win-crash-survival-e2e/run.mjs --expect survival --exe-path \"$env:ORCA_EXE\" --soak-seconds 8"
|
||||
"node tests/tools/win-crash-survival-e2e/run.mjs --expect survival --exe-path \"$env:ORCA_EXE\" --soak-seconds 8"
|
||||
],
|
||||
"testFiles": [
|
||||
"config/scripts/win-crash-survival-e2e.test.mjs",
|
||||
"tools/win-crash-survival-e2e/run.mjs"
|
||||
"tests/tools/win-crash-survival-e2e/run.mjs"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -2824,7 +3124,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tools/win-crash-survival-e2e/run.mjs",
|
||||
"file": "tests/tools/win-crash-survival-e2e/run.mjs",
|
||||
"assertions": [
|
||||
"force-killing only the real Electron main leaves the exact scoped daemon and stamped interactive shell alive",
|
||||
"packaged relaunch adopts the unchanged daemon and reads the survivor shell's environment sentinel through the restored terminal"
|
||||
@@ -2836,7 +3136,7 @@
|
||||
"date": "2026-07-18",
|
||||
"runner": "ci",
|
||||
"platform": "windows",
|
||||
"command": "node tools/win-crash-survival-e2e/run.mjs --expect survival --exe-path \"$env:ORCA_EXE\" --soak-seconds 8",
|
||||
"command": "node tests/tools/win-crash-survival-e2e/run.mjs --expect survival --exe-path \"$env:ORCA_EXE\" --soak-seconds 8",
|
||||
"result": "passed",
|
||||
"durationSeconds": 61,
|
||||
"summary": "The packaged branch build's real main died; the same daemon and shell PIDs survived; the event-log scan found zero FailFast events; relaunch adopted the unchanged daemon; and terminal input read the survivor shell sentinel back."
|
||||
@@ -3079,6 +3379,7 @@
|
||||
"paired remote terminal ordinary parking",
|
||||
"paired remote terminal bounded scrollback restore",
|
||||
"stalled paired terminal stream recovery",
|
||||
"snapshot-probe sequence gap recovery",
|
||||
"hidden remote worktree retention",
|
||||
"remote terminal reveal and input",
|
||||
"manual server disconnect"
|
||||
@@ -3087,13 +3388,13 @@
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
@@ -3101,9 +3402,10 @@
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -3128,12 +3430,17 @@
|
||||
"src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/terminal-parked-memory.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts",
|
||||
"assertions": ["replaces a stale paired stream when the PTY snapshot advanced"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"assertions": [
|
||||
@@ -3155,12 +3462,17 @@
|
||||
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback",
|
||||
"warm-mounted hidden paired streams schedule zero renderer output work under sustained host output",
|
||||
"client recovery completes while every target terminal remains unmounted in the host renderer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": ["ordinary-parks paired terminals against an isolated headless Orca host"]
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals against an isolated headless Orca host",
|
||||
"cold-activates only visible paired terminals against an isolated headless host",
|
||||
"the headless host shares the zero-hidden-renderer-work and exact-restoration contract"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-parked-memory.spec.ts",
|
||||
@@ -3231,6 +3543,9 @@
|
||||
"restarts only the stream whose renderer delivery credit never settles",
|
||||
"probes then restarts a stream when an entered command receives no frames",
|
||||
"keeps a silent responsive stream after its authoritative snapshot probe",
|
||||
"restarts a stream when the authoritative snapshot advanced without live output",
|
||||
"establishes a probe baseline before recovering an unsequenced stream",
|
||||
"keeps a responsive stream when its probe confirms zero output high-water",
|
||||
"classifies a capacity rejection followed by end as recoverable transport pressure"
|
||||
]
|
||||
},
|
||||
@@ -3245,6 +3560,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 17.3,
|
||||
"summary": "The headed paired oracle dropped and acknowledged output only for the original client stream, proved the fixture consumed input while the host model advanced and the client stayed stale, then replaced the stream, repainted exact fixture output, preserved authoritative PTY identity and target-tab cardinality, and resumed live I/O."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
@@ -3291,13 +3615,13 @@
|
||||
"summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.2,
|
||||
"summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle."
|
||||
"durationSeconds": 23,
|
||||
"summary": "The isolated headless `orca serve` host passed ordinary parking plus cold activation: deferred tabs stayed unmounted until activation, preserved PTY identity and parked output, and resumed live input."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3316,6 +3640,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.16,
|
||||
"summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 17.5,
|
||||
"summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -3328,25 +3661,25 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases."
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Capable paired hosts use ordinary parking: the host PTY and bounded 5,000-row provider model remain, while five hidden client xterms and their raw per-PTY multiplex streams are destroyed. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
|
||||
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
|
||||
"Run the paired topology on Windows and Linux and add one live SSH retention run.",
|
||||
"Run the stream-stall oracle with a v1.4.160-rc.3 host and v1.4.160-rc.4 client.",
|
||||
"Add a production-scale paired run with dozens of worktrees and explicit event-loop and renderer-memory budgets.",
|
||||
"Keep the budget-off control, exact manager count, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs."
|
||||
"Keep the budget-off control, exact manager count, final bounded-tail flood marker, parked-time output, and post-reveal PTY input in both topology runs."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Paired reveal restores at most the requested 5,000 rows; older history is intentionally unavailable.",
|
||||
"The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.",
|
||||
"Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.",
|
||||
"Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.",
|
||||
"The live windows-issues incident proves a healthy host PTY and dead existing client stream, but lacks per-stream ACK counters; ACK starvation is reproduced, not yet proven as that field incident's unique boundary."
|
||||
"The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
},
|
||||
@@ -4288,6 +4621,101 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if the gate flakes without a product or harness bug, if any local shutdown reaches fallback before startup settles, or if shutdown adds inventory scans or retry loops."
|
||||
},
|
||||
{
|
||||
"id": "terminal-provider.snapshot-capability-renderer-responsiveness",
|
||||
"title": "PTY snapshot capability discovery never blocks renderer JavaScript",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-provider",
|
||||
"layer": "renderer-ipc",
|
||||
"surfaces": [
|
||||
"renderer startup",
|
||||
"cold terminal restoration",
|
||||
"hidden terminal parking",
|
||||
"SSH terminal restoration"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["daemon", "ssh"],
|
||||
"coverageNotes": "A preload contract and Electron main-stall oracle prove capability lookup is asynchronous. Startup prefetch covers restored primary and split-pane PTY identities before cold activation, while unknown providers remain eager. Docker SSH journeys prove remote terminals still remount eagerly and reclaim their authenticated PTY owner after restart.",
|
||||
"motivatingLinks": ["https://stablygroup.slack.com/archives/C0BD60A5J85/p1785524559818629"],
|
||||
"invariant": "PTY snapshot capability discovery must never synchronously block renderer JavaScript. Restored daemon capability must be known before workspace readiness enables cold activation; unknown or legacy capability must remain eager. A healthy SSH provider must return definitive false without polling. One unresponsive capability batch must fail open within one second regardless of PTY count, and stale async responses must not update current bindings.",
|
||||
"oracle": "The preload test rejects sendSync and requires ipcRenderer.invoke. Unit contracts assert 512-ID batching, one-second fail-open, unknown retry, definitive SSH false, and generation-fenced stale responses. During an injected 1.5-second Electron main-thread stall, a renderer-owned 50ms interval must keep a maximum gap below 500ms and each API call must return within 100ms. The production cold-activation journey must still mount at most three of eight daemon tabs after reload, while Docker SSH restoration remains eager.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/app-startup-routing.test.ts --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/preload/pty-snapshot-capability-ipc.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts",
|
||||
"src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx",
|
||||
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
|
||||
"src/renderer/src/app-startup-routing.test.ts",
|
||||
"tests/e2e/pty-snapshot-capability-main-stall.spec.ts",
|
||||
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
|
||||
"tests/e2e/ssh-cold-activation-restore.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/pty-snapshot-capability-main-stall.spec.ts",
|
||||
"assertions": [
|
||||
"a 1.5-second main stall leaves renderer interval gaps below 500ms",
|
||||
"capability calls return to renderer JavaScript within 100ms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-cold-activation-deferral.spec.ts",
|
||||
"assertions": [
|
||||
"cold reload mounts at most three of eight daemon tabs and parked watchers cover the rest"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
|
||||
"result": "passed",
|
||||
"durationSeconds": 16.8,
|
||||
"summary": "Three 1.5-second main stalls produced 32 renderer calls each; maximum interval gaps were 70.3ms, 70.1ms, and 71.6ms, and maximum call-return durations were 0.1ms, 0.1ms, and 0.2ms. The synchronous baseline produced a 1465.1ms interval gap and 1464.2ms call-return duration."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "focused Electron responsiveness and cold-restore journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic Electron oracle passed three consecutive local runs; CI soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical Electron oracle failed with synchronous sendSync at a 1465.1ms renderer interval gap and 1464.2ms call-return duration, then passed three times with async invoke at no more than 71.6ms and 0.2ms respectively. The preload test also fails on the synchronous baseline."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Capability requests are deduplicated by PTY ID, sent in bounded 512-ID batches, cached after a definitive result, and retried only for unknown results. SSH now returns definitive false. One unresponsive batch fails open after one second without scanning further batches. No subprocesses or provider inventories are added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the Electron main-stall oracle for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Collect Windows and Linux desktop cold-restore coverage.",
|
||||
"Add a headed paired-desktop cold-restore journey if provider capability semantics move into the remote runtime."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The main-stall and local daemon cold-restore Electron journeys currently run on macOS only.",
|
||||
"Docker SSH proves the Linux relay/provider path but not a Linux desktop renderer.",
|
||||
"This gate proves renderer responsiveness to main stalls; it does not identify the cause of the reported production hard freeze."
|
||||
],
|
||||
"demotionRule": "Demote if capability discovery reintroduces synchronous renderer IPC, cold daemon tabs mount eagerly despite authoritative snapshots, SSH capability polls after a definitive response, or the renderer gap budget flakes without a product or harness bug."
|
||||
},
|
||||
{
|
||||
"id": "terminal-provider.ssh-remote-reattach-contract",
|
||||
"title": "SSH restore and remote-runtime recovery preserve live terminal ownership",
|
||||
@@ -5576,14 +6004,14 @@
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"tools/repro-orchestration-long-prompt.mjs"
|
||||
"tests/tools/repro-orchestration-long-prompt.mjs"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -5619,7 +6047,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tools/repro-orchestration-long-prompt.mjs",
|
||||
"file": "tests/tools/repro-orchestration-long-prompt.mjs",
|
||||
"assertions": [
|
||||
"fake Codex-like worker observes submit after long orchestration dispatch",
|
||||
"32KB task marker survives before submit",
|
||||
@@ -5641,7 +6069,7 @@
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000",
|
||||
"command": "node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.2,
|
||||
"summary": "Live dev-runtime repro passed: expectedSpecBytes=32830, hasSubmit=true, rawContainsMarker=true, hasBracketedPasteFrame=true, unframedLineBreaks=0, contractOk=true."
|
||||
@@ -6430,18 +6858,19 @@
|
||||
"platforms": ["macos", "linux", "windows", "mobile"],
|
||||
"providers": ["remote-runtime", "ssh", "local", "daemon"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets plus paired-renderer parse/discard credit. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.",
|
||||
"coveredProviders": ["remote-runtime"],
|
||||
"coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets, paired-renderer parse/discard credit, and negotiated host-side suppression for hidden paired desktop panes. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, mixed-version pause negotiation, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6951",
|
||||
"https://github.com/stablyai/orca/pull/6955",
|
||||
"https://github.com/stablyai/orca/pull/7009"
|
||||
],
|
||||
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and fallback parity or explicit fallback deprecation.",
|
||||
"oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
|
||||
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. A capability-negotiated hidden paired desktop stream must deliver zero raw output frames after host model ingestion, preserve side-effect facts, and restore from one authoritative snapshot before exact live output resumes. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and safe mixed-version fallback.",
|
||||
"oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Pause three negotiated paired desktop streams, sustain output, and assert zero renderer frames; reveal one and assert one authoritative snapshot followed by exact live bytes with no loss or duplication. Prove old clients continue receiving output and new clients never send pause to old hosts. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1",
|
||||
"ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
|
||||
@@ -6452,6 +6881,7 @@
|
||||
"src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
@@ -6483,7 +6913,24 @@
|
||||
"multibyte live output flushes when encoded bytes reach the batch budget",
|
||||
"adaptive credit grows only after ACK, stays globally bounded, and drains pending streams round-robin",
|
||||
"send and recovery serialization failures detach once instead of leaking credit or retrying forever",
|
||||
"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter"
|
||||
"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter",
|
||||
"three negotiated hidden desktop streams emit zero output frames under sustained load while an unpaused stream remains live",
|
||||
"an older client that omits pause negotiation continues receiving output"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable",
|
||||
"reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication",
|
||||
"dispose releases pause and unregisters the fact consumer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"desired pause reapplies after either capability/snapshot ordering and across reconnect",
|
||||
"reconnect delivers each authoritative snapshot and post-resume live marker exactly once"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -6501,6 +6948,12 @@
|
||||
"malformed frames, malformed transformed output, disposal, late parse, renderer delivery failure, and ACK transport failure release credit or close the owning stream without reordering output"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/runtime-terminal-stream.test.ts",
|
||||
"assertions": [
|
||||
"drops output only from the armed stream when its replacement reuses the stream ID"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
|
||||
"assertions": [
|
||||
@@ -6552,6 +7005,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.91,
|
||||
"summary": "The 1/20/100 ms RTT x 1/4/8 viewer matrix stayed at or below 8 MiB in flight with zero completion spread. At 100 ms it modeled 18.8 MiB/s per viewer for 1-4 viewers and 9.7 MiB/s for 8 viewers. Measured @xterm/headless parsing was 26.7/63.6/95.3 aggregate MiB/s for 1/4/8 viewers, with 84.4/236.5/336.0 ms CPU and 2893/13409/28991 KiB retained heap for 4 MiB per viewer."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 9.05,
|
||||
"summary": "All 58 terminal multiplex tests passed, including the three-stream hidden-output oracle. Its byte-identical title-selected command failed on current origin/main and failed again with the two causal host/protocol files reverted because hidden Output frames reached the renderer."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -6564,11 +7026,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests cover mobile initial snapshot byte downgrade, requested binary snapshot byte downgrade, pending live-output cap while snapshot loads, output chunk size, output coalescing, abort cleanup, and stale resize re-stream suppression. Needs intentional-break proof plus JSON fallback coverage before promotion."
|
||||
"evidence": "The hidden paired-output oracle is red on origin/main, green on the candidate, and red with its causal host/protocol files reverted. It proves three paused streams emit no raw frames while an unpaused control remains live; compatibility tests preserve old-client/new-host output and prevent new-client/old-host pause opcodes. The broader gate remains partial pending legacy JSON fallback and live Android coverage."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB. The 2026-07-22 run modeled 9.7 MiB/s/viewer at 100 ms with eight viewers and measured @xterm/headless at 95.3 aggregate MiB/s, 336.0 ms parser CPU, and 28991 KiB retained heap for eight 4 MiB viewers."
|
||||
"evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). Negotiated hidden paired streams add zero renderer output frames or scheduler drains after host ingestion; headed and headless six-stream runs enforce under 500 ms timer lag. The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Gate binary multiplex first.",
|
||||
@@ -7812,6 +8274,139 @@
|
||||
"Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge."
|
||||
},
|
||||
{
|
||||
"id": "ssh-port-forward.renderer-snapshot-continuity",
|
||||
"title": "SSH forwarded-port state survives stale renderer hydration",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "desktop-ssh",
|
||||
"layer": "renderer-ssh-snapshot-reconciliation",
|
||||
"surfaces": [
|
||||
"SSH Ports panel",
|
||||
"forwarded-port renderer state",
|
||||
"persisted forward restoration",
|
||||
"ssh2 port forwarding",
|
||||
"system-SSH port forwarding"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["ssh2", "system-ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh2", "system-ssh"],
|
||||
"coverageNotes": "A deterministic renderer ordering test covers stale initial snapshots independently by target and stream, including hydration after partial SSH authority reconciliation. Headed macOS Electron tests force the same startup hydration race and use an ephemeral Docker sshd, a real remote Git worktree, real remote Node listeners, real HTTP forwards, in-place relay and full transport reconnect restoration, collision rejection, scan refresh, explicit removal, and an unrelated surviving forward through both transports. The forced-system run requires a recorded OpenSSH -L invocation.",
|
||||
"motivatingLinks": ["user-reported SSH Ports panel disappearance"],
|
||||
"invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.",
|
||||
"oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"src/main/ssh/ssh-port-forward.test.ts",
|
||||
"src/main/ssh/system-ssh-forward-process.test.ts",
|
||||
"src/main/ipc/ssh.test.ts",
|
||||
"src/main/ssh/ssh-relay-session.test.ts",
|
||||
"tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"tests/e2e/ssh-port-forward-lifecycle.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"assertions": ["does not let initial SSH port snapshots overwrite newer push events"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-port-forward.test.ts",
|
||||
"assertions": ["lists forwards filtered by connectionId", "removes a forward by id"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/system-ssh-forward-process.test.ts",
|
||||
"assertions": [
|
||||
"does not spawn ssh when the requested local forward port is already in use",
|
||||
"sends SIGTERM then SIGKILL when the process does not exit",
|
||||
"does not resolve stop until the process exits"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ssh.test.ts",
|
||||
"assertions": [
|
||||
"preserves active port forwards and live connections across handler re-registration",
|
||||
"persists desired forwards and broadcasts when an active forward closes unexpectedly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-session.test.ts",
|
||||
"assertions": ["cleans up port forwards on reconnect"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"assertions": ["holds only the first matching request while its snapshot is unresolved"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts",
|
||||
"assertions": ["keeps a user-forwarded listener live across scan refresh @headful"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.77,
|
||||
"summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 53.7,
|
||||
"summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 66,
|
||||
"summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused renderer/main contracts plus two headed Docker SSH transport runs"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic contract and both real transport runs passed locally; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The identical renderer unit and headed Electron oracles fail on current main because the stale empty hydration reply becomes a second and final Forwarded write, pass with bounded per-target per-stream pending-hydration state, fail again when only that fix is removed, and pass again after restoration. In the tightened headed revert, both transports first proved main inventory, persisted intent, HTTP, remote process identity, no live close warning, and renderer continuation, then failed only at the missing Forwarded-row assertion. Both passed after rebuilding the restored candidate."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The fix adds one effect-scoped map containing only unresolved target hydrations, one O(1) boolean write per relevant existing push event, and one O(1) authority/stream check per initial snapshot. Forwarded and Detected hydrate independently so a stalled stream does not block its peer or later targets; entries are removed after both settle. It adds no polling, timers, IPC calls, scans, subprocesses, or renderer subscriptions."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Run the headed topology on Linux and Windows with their native system-SSH clients.",
|
||||
"Re-run the headed snapshot barrier after Electron major-version upgrades.",
|
||||
"Keep the independent Forwarded and Detected freshness assertions and both transport runs green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Headed live evidence is macOS-only; Linux and Windows system-SSH clients were not exercised.",
|
||||
"The headed race uses Electron's private invoke-handler registry to delay the real listPortForwards handler because Electron exposes no public handler-wrapping API.",
|
||||
"The live run reconnects an existing app session but does not restart the packaged application from disk.",
|
||||
"The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh')
|
||||
const tempDirs = []
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
function makeFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-root-directory-guard-'))
|
||||
tempDirs.push(root)
|
||||
git(root, ['init', '--quiet'])
|
||||
git(root, ['config', 'user.email', 'root-directory-guard-test@example.com'])
|
||||
git(root, ['config', 'user.name', 'Root Directory Guard Test'])
|
||||
mkdirSync(join(root, 'config'), { recursive: true })
|
||||
writeFileSync(join(root, 'config', 'base.txt'), 'base\n')
|
||||
git(root, ['add', '-A'])
|
||||
git(root, ['commit', '--quiet', '-m', 'base'])
|
||||
return { root, base: git(root, ['rev-parse', 'HEAD']) }
|
||||
}
|
||||
|
||||
function commitFiles(root, files) {
|
||||
for (const [relativePath, contents] of files) {
|
||||
const target = join(root, relativePath)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, contents)
|
||||
}
|
||||
git(root, ['add', '-A'])
|
||||
git(root, ['commit', '--quiet', '-m', 'head'])
|
||||
return git(root, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
function runGuard({ root, base, head }) {
|
||||
return spawnSync('bash', [guardScript, base, head], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('root directory guard', () => {
|
||||
it('allows additions inside an existing top-level directory', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['config/new.txt', 'nested\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('no new root-level files or folders')
|
||||
})
|
||||
|
||||
it('rejects a new root-level file with the landing-page message', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['new-root.md', 'too prominent\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output).toContain('bloat the GitHub landing page')
|
||||
expect(output).toContain('new-root.md')
|
||||
})
|
||||
|
||||
it('rejects a new top-level directory', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output).toContain('new-folder')
|
||||
})
|
||||
|
||||
it('is wired into the PR verify gate', () => {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const guardJob = workflow.jobs.root_directory_guard
|
||||
const guardStep = guardJob.steps.find(
|
||||
(step) => step.name === 'Reject new root-level files and folders'
|
||||
)
|
||||
|
||||
expect(guardJob.name).toBe('root directory guard')
|
||||
expect(guardJob.steps[0].with['fetch-depth']).toBe(0)
|
||||
expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh')
|
||||
expect(workflow.jobs.verify.needs).toContain('root_directory_guard')
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
@@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => {
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const checkout = workflow.jobs['native-smoke'].steps.find(
|
||||
(step) => step.uses === 'actions/checkout@v6'
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => {
|
||||
'src/shared/remote-runtime-client.test.ts'
|
||||
]
|
||||
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(regressionRun).toBeTruthy()
|
||||
for (const file of expectedRegressionFiles) {
|
||||
expect(regressionRun).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
)
|
||||
expect(cleanupRun).toContain(
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
|
||||
)
|
||||
expect(runs).toContain('pnpm verify:computer-native')
|
||||
expect(runs.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-metrics.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
|
||||
const benchmark = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const cleanup = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]')
|
||||
expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]")
|
||||
expect(benchmark).toContain('cleanupOwnerLossTrial({')
|
||||
const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)')
|
||||
const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({')
|
||||
expect(parseIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cleanupIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(parseIndex).toBeLessThan(cleanupIndex)
|
||||
expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)')
|
||||
expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath')
|
||||
expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'")
|
||||
})
|
||||
|
||||
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
@@ -163,20 +225,15 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
|
||||
it('does not run computer-use e2e in PR smoke jobs', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = nativeSmokeRuns.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toContain('gedit')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(nativeSmokeRuns).toContain(
|
||||
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
|
||||
)
|
||||
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
|
||||
})
|
||||
|
||||
it('builds Electron main output before every computer-use e2e run', () => {
|
||||
@@ -205,7 +262,7 @@ describe('computer-use e2e workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs core Windows computer-use e2e in the PR native-smoke job', () => {
|
||||
it('keeps computer-use e2e in scheduled jobs only', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
@@ -221,9 +278,8 @@ describe('computer-use e2e workflow', () => {
|
||||
.filter((run) => typeof run === 'string')
|
||||
]
|
||||
|
||||
expect(nativeSmokeRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts'
|
||||
)
|
||||
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(allRuns.join('\n')).toContain('test:e2e:computer')
|
||||
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,39 @@ const {
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
const MUTABLE_BUILD_ENV = [
|
||||
'ORCA_MAC_HOURLY',
|
||||
'ORCA_MAC_RELEASE',
|
||||
'ORCA_HOURLY_BUILD_VERSION',
|
||||
'ORCA_LOCAL_BUILD_VERSION'
|
||||
]
|
||||
|
||||
/** Re-requires the config under a temporary env, then restores env and module cache. */
|
||||
function withEnv(env, assert) {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
|
||||
try {
|
||||
for (const key of MUTABLE_BUILD_ENV) {
|
||||
delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
delete require.cache[configPath]
|
||||
assert(require('../electron-builder.config.cjs'))
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
}
|
||||
|
||||
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('keeps the packaged app identity aligned with local-build validation', () => {
|
||||
expect(electronBuilderConfig.appId).toBe(
|
||||
@@ -158,11 +191,7 @@ describe('electron-builder config', () => {
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
'out/shared/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js'
|
||||
'out/main/claude-accounts/keychain.js'
|
||||
])
|
||||
)
|
||||
})
|
||||
@@ -175,11 +204,9 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
||||
'out/main/main-thread-hang-watchdog-entry.js'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -273,6 +300,52 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
|
||||
// same bundle id and a valid Developer ID signature. A hourly built on the local
|
||||
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
|
||||
// Orca — the whole point of the channel.
|
||||
it('builds hourly artifacts with the release signing identity', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.mac.appId).toBeUndefined()
|
||||
expect(config.appId).toBe('com.stablyai.orca')
|
||||
expect(config.mac.hardenedRuntime).toBe(true)
|
||||
expect(config.forceCodeSigning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: notarization is the one release step hourly skips; in-place updates never
|
||||
// check it, and 24 notary round trips a day is the cost being avoided.
|
||||
it('skips notarization only for hourly builds', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.mac.notarize).toBe(false)
|
||||
})
|
||||
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
|
||||
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
|
||||
// break update checks for every real user.
|
||||
it('publishes hourly builds to the separate hourly repo', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
|
||||
})
|
||||
expect(electronBuilderConfig.publish).toMatchObject({
|
||||
repo: 'orca',
|
||||
releaseType: 'release'
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps hourly packages with the hourly version', () => {
|
||||
withEnv(
|
||||
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
|
||||
(config) => {
|
||||
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
||||
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
||||
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
||||
@@ -486,6 +559,20 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails when the packaged resources directory is missing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
|
||||
try {
|
||||
await expect(
|
||||
electronBuilderConfig.afterPack({
|
||||
appOutDir: root,
|
||||
electronPlatformName: 'win32'
|
||||
})
|
||||
).rejects.toThrow(/Missing packaged resources directory/)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'marks packaged Unix CLI launchers executable',
|
||||
async () => {
|
||||
@@ -509,6 +596,19 @@ describe('electron-builder config', () => {
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
|
||||
'utf8'
|
||||
)
|
||||
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
|
||||
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
|
||||
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
|
||||
await writeFile(
|
||||
join(unpackedCliDir, 'index.js'),
|
||||
[
|
||||
'const args = process.argv.slice(2)',
|
||||
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
|
||||
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
|
||||
'else console.log(JSON.stringify({ executed: false }))'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
|
||||
@@ -1,10 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as nodeFs from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import * as nodePath from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BOOTSTRAP_FATAL_LOG_ENV_VAR,
|
||||
BOOTSTRAP_FATAL_LOG_FILE_NAME,
|
||||
createBootstrapFatalExitBanner
|
||||
} from '../../build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
|
||||
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
|
||||
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
|
||||
|
||||
type BootstrapProcessMock = EventEmitter & {
|
||||
env: Record<string, string>
|
||||
pid: number
|
||||
exit: (code: number) => void
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */
|
||||
function failBootstrapWithBanner(options: {
|
||||
env: Record<string, string>
|
||||
tmpdir?: string
|
||||
stderrWrites?: string[]
|
||||
}): BootstrapProcessMock {
|
||||
const processMock = new EventEmitter() as BootstrapProcessMock
|
||||
processMock.env = options.env
|
||||
processMock.pid = 4242
|
||||
processMock.exit = () => {}
|
||||
const fsShim = {
|
||||
...nodeFs,
|
||||
writeSync: (descriptor: number, data: string) => {
|
||||
if (descriptor === 2) {
|
||||
options.stderrWrites?.push(data)
|
||||
return data.length
|
||||
}
|
||||
return nodeFs.writeSync(descriptor, data)
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
process: processMock,
|
||||
setImmediate: () => {},
|
||||
require: (specifier: string) => {
|
||||
if (specifier === 'node:fs') {
|
||||
return fsShim
|
||||
}
|
||||
if (specifier === 'node:path') {
|
||||
return nodePath
|
||||
}
|
||||
if (specifier === 'node:os' && options.tmpdir !== undefined) {
|
||||
return { tmpdir: () => options.tmpdir }
|
||||
}
|
||||
// Electron's own module is unreachable from a bootstrap fault this early.
|
||||
throw new Error(`unexpected require: ${specifier}`)
|
||||
}
|
||||
}
|
||||
|
||||
runInNewContext(createBootstrapFatalExitBanner(), context)
|
||||
processMock.emit('uncaughtException', new Error("Cannot find module 'ws'"))
|
||||
return processMock
|
||||
}
|
||||
|
||||
describe('Electron Vite output contract', () => {
|
||||
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
|
||||
const output = electronViteConfig.main?.build?.rollupOptions?.output
|
||||
@@ -29,6 +91,100 @@ describe('Electron Vite output contract', () => {
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
expect(external('zod', undefined, false)).toBe(false)
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
it('exits when a static import fails before source error guards load', () => {
|
||||
const processMock = new EventEmitter() as EventEmitter & {
|
||||
exit: (code: number) => void
|
||||
exitCode?: number
|
||||
stderr: { write: (chunk: string) => boolean }
|
||||
}
|
||||
let scheduledExit: (() => void) | null = null
|
||||
let exitedWith: number | null = null
|
||||
const stderrWrites: string[] = []
|
||||
processMock.exit = (code) => {
|
||||
exitedWith = code
|
||||
}
|
||||
processMock.stderr = {
|
||||
write: (chunk) => {
|
||||
stderrWrites.push(chunk)
|
||||
return true
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
process: processMock,
|
||||
setImmediate: (callback: () => void) => {
|
||||
scheduledExit = callback
|
||||
}
|
||||
}
|
||||
|
||||
runInNewContext(createBootstrapFatalExitBanner(), context)
|
||||
processMock.emit('uncaughtException', new Error("Cannot find module 'zod'"))
|
||||
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
expect(scheduledExit).not.toBeNull()
|
||||
scheduledExit?.()
|
||||
expect(exitedWith).toBe(1)
|
||||
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
|
||||
expect(stderrWrites.join('')).toContain("Cannot find module 'zod'")
|
||||
})
|
||||
|
||||
it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => {
|
||||
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
|
||||
const logPath = join(logDirectory, 'fatal.log')
|
||||
const stderrWrites: string[] = []
|
||||
|
||||
try {
|
||||
const processMock = failBootstrapWithBanner({
|
||||
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath },
|
||||
stderrWrites
|
||||
})
|
||||
|
||||
expect(stderrWrites.join('')).toContain("Cannot find module 'ws'")
|
||||
const recorded = readFileSync(logPath, 'utf8')
|
||||
expect(recorded).toContain("Cannot find module 'ws'")
|
||||
expect(recorded).toContain('pid=4242')
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
} finally {
|
||||
rmSync(logDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the parent directory an overridden log path names but does not have', () => {
|
||||
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
|
||||
const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log')
|
||||
|
||||
try {
|
||||
const processMock = failBootstrapWithBanner({
|
||||
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }
|
||||
})
|
||||
|
||||
expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'")
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
} finally {
|
||||
rmSync(logDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the default location when the overridden log path is unwritable', () => {
|
||||
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
|
||||
const fallbackDirectory = join(logDirectory, 'fallback')
|
||||
|
||||
try {
|
||||
const processMock = failBootstrapWithBanner({
|
||||
// A directory can never be opened as the log file, so the override must yield.
|
||||
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory },
|
||||
tmpdir: fallbackDirectory
|
||||
})
|
||||
|
||||
const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8')
|
||||
expect(recorded).toContain("Cannot find module 'ws'")
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
} finally {
|
||||
rmSync(logDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates renderer entry side effects behind strict facades', () => {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, fork, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
childRssBytes,
|
||||
median,
|
||||
physicalFootprintBytes,
|
||||
sampleMemory,
|
||||
sampleProductionPerformance
|
||||
} from './hang-watchdog-process-metrics.mjs'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL'
|
||||
const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY'
|
||||
const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT='
|
||||
const DEFAULT_TRIALS = 7
|
||||
const SETTLE_MS = 2_000
|
||||
const SAMPLE_COUNT = 5
|
||||
const SAMPLE_INTERVAL_MS = 200
|
||||
const VERIFY_TIMEOUT_MS = 500
|
||||
const VERIFY_CHECK_INTERVAL_MS = 50
|
||||
const VERIFY_BLOCK_MS = 1_200
|
||||
const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
const PRODUCTION_TIMEOUT_MS = 45_000
|
||||
const PRODUCTION_CHECK_INTERVAL_MS = 5_000
|
||||
const PRODUCTION_SAMPLE_MS = 30_000
|
||||
const MAX_LAUNCH_ATTEMPTS = 3
|
||||
const MIB = 1024 * 1024
|
||||
const scriptPath = import.meta.filename
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function forceGc() {
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness')
|
||||
}
|
||||
global.gc()
|
||||
global.gc()
|
||||
}
|
||||
|
||||
function blockMainThread(ms) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < ms) {
|
||||
// Intentional synchronous stall.
|
||||
}
|
||||
return { startedAt, endedAt: Date.now() }
|
||||
}
|
||||
|
||||
function readMarker(markerPath) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBlockedMainDetection(markerPath, sendHeartbeat) {
|
||||
const block = blockMainThread(VERIFY_BLOCK_MS)
|
||||
const detected = readMarker(markerPath)
|
||||
sendHeartbeat()
|
||||
const deadline = Date.now() + VERIFY_TIMEOUT_MS
|
||||
let resolved
|
||||
do {
|
||||
resolved = readMarker(markerPath)
|
||||
if (resolved?.selfRecovered === true) {
|
||||
break
|
||||
}
|
||||
await sleep(VERIFY_CHECK_INTERVAL_MS)
|
||||
} while (Date.now() < deadline)
|
||||
const verified =
|
||||
detected?.detectedAt >= block.startedAt &&
|
||||
detected.detectedAt <= block.endedAt &&
|
||||
detected.selfRecovered === false &&
|
||||
resolved?.selfRecovered === true
|
||||
if (!verified) {
|
||||
throw new Error(
|
||||
`Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function startChild(markerPath, timeoutMs, checkIntervalMs) {
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
|
||||
ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath,
|
||||
ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs),
|
||||
ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs)
|
||||
}
|
||||
})
|
||||
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
return {
|
||||
pids: [process.pid, child.pid],
|
||||
startupMs,
|
||||
sendHeartbeat: () => child.send?.({ type: 'heartbeat' }),
|
||||
shutdown: async () => {
|
||||
if (child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
const exitPromise = new Promise((resolve) => child.once('exit', resolve))
|
||||
child.send?.({ type: 'shutdown' })
|
||||
if (child.connected) {
|
||||
child.disconnect()
|
||||
}
|
||||
await exitPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWorker(markerPath, timeoutMs, checkIntervalMs) {
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const worker = new Worker(entryPath, {
|
||||
workerData: {
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs,
|
||||
checkIntervalMs
|
||||
}
|
||||
})
|
||||
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
return {
|
||||
pids: [process.pid],
|
||||
startupMs,
|
||||
sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }),
|
||||
shutdown: async () => {
|
||||
if (worker.threadId === -1) {
|
||||
return
|
||||
}
|
||||
const exitPromise = new Promise((resolve) => worker.once('exit', resolve))
|
||||
worker.postMessage({ type: 'shutdown' })
|
||||
await exitPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBoundary(markerPath, startBoundary) {
|
||||
const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS)
|
||||
const heartbeat = setInterval(boundary.sendHeartbeat, 100)
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat)
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
await boundary.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
async function measureChild(markerPath) {
|
||||
forceGc()
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const before = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
|
||||
let measurements
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const childRss = await sampleMemory(
|
||||
() => childRssBytes(child.pids[1]),
|
||||
() => physicalFootprintBytes([child.pids[1]]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const total = await sampleMemory(
|
||||
() => process.memoryUsage().rss + childRssBytes(child.pids[1]),
|
||||
() => physicalFootprintBytes(child.pids),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const performance = await sampleProductionPerformance(child, {
|
||||
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
sampleMs: PRODUCTION_SAMPLE_MS,
|
||||
sleep
|
||||
})
|
||||
measurements = {
|
||||
rssBytes: childRss.rssBytes,
|
||||
summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes),
|
||||
physicalFootprintDeltaBytes: Math.max(
|
||||
0,
|
||||
total.physicalFootprintBytes - before.physicalFootprintBytes
|
||||
),
|
||||
startupMs: child.startupMs,
|
||||
...performance
|
||||
}
|
||||
} finally {
|
||||
await child.shutdown()
|
||||
}
|
||||
rmSync(markerPath, { force: true })
|
||||
return {
|
||||
...measurements,
|
||||
blockedMainThreadVerified: await verifyBoundary(markerPath, startChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWorker(markerPath) {
|
||||
forceGc()
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const before = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
|
||||
let measurements
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const after = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const performance = await sampleProductionPerformance(worker, {
|
||||
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
sampleMs: PRODUCTION_SAMPLE_MS,
|
||||
sleep
|
||||
})
|
||||
const rssBytes = Math.max(0, after.rssBytes - before.rssBytes)
|
||||
measurements = {
|
||||
rssBytes,
|
||||
summedProcessRssDeltaBytes: rssBytes,
|
||||
physicalFootprintDeltaBytes: Math.max(
|
||||
0,
|
||||
after.physicalFootprintBytes - before.physicalFootprintBytes
|
||||
),
|
||||
startupMs: worker.startupMs,
|
||||
...performance
|
||||
}
|
||||
} finally {
|
||||
await worker.shutdown()
|
||||
}
|
||||
rmSync(markerPath, { force: true })
|
||||
return {
|
||||
...measurements,
|
||||
blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternal() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
|
||||
}
|
||||
const { app } = await import('electron')
|
||||
const boundary = process.env[BOUNDARY_ENV]
|
||||
const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-'))
|
||||
app.setPath('userData', profileDir)
|
||||
try {
|
||||
await app.whenReady()
|
||||
const markerPath = path.join(profileDir, 'main-thread-hang.json')
|
||||
const result =
|
||||
boundary === 'child'
|
||||
? await measureChild(markerPath)
|
||||
: boundary === 'worker'
|
||||
? await measureWorker(markerPath)
|
||||
: (() => {
|
||||
throw new Error(`Unsupported boundary: ${boundary}`)
|
||||
})()
|
||||
process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`)
|
||||
} finally {
|
||||
app.quit()
|
||||
rmSync(profileDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--boundary' || arg === '--trials' || arg === '--output') {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!['child', 'worker'].includes(options.boundary)) {
|
||||
throw new Error('--boundary must be child or worker')
|
||||
}
|
||||
if (!Number.isInteger(options.trials) || options.trials < 1) {
|
||||
throw new Error('--trials must be a positive integer')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function electronPath() {
|
||||
const requirePath = import.meta.resolve('electron')
|
||||
const electronModulePath = fileURLToPath(requirePath)
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function runTrial(executable, boundary) {
|
||||
for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) {
|
||||
const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-'))
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'main.cjs'),
|
||||
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
let result
|
||||
try {
|
||||
result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 90_000
|
||||
})
|
||||
} finally {
|
||||
rmSync(launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
|
||||
`${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX))
|
||||
if (line) {
|
||||
return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt }
|
||||
}
|
||||
if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) {
|
||||
throw new Error(`Electron trial did not report a result (status ${result.status})`)
|
||||
}
|
||||
}
|
||||
throw new Error('Electron trial exhausted launcher attempts')
|
||||
}
|
||||
|
||||
function runBenchmark() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`)
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const builtEntry = readFileSync(entryPath, 'utf8')
|
||||
const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID')
|
||||
const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort')
|
||||
if (
|
||||
(options.boundary === 'child' && !hasChildContract) ||
|
||||
(options.boundary === 'worker' && !hasWorkerContract)
|
||||
) {
|
||||
throw new Error(
|
||||
`Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision`
|
||||
)
|
||||
}
|
||||
const executable = electronPath()
|
||||
const results = Array.from({ length: options.trials }, () =>
|
||||
runTrial(executable, options.boundary)
|
||||
)
|
||||
const rssBytes = results.map((result) => result.rssBytes)
|
||||
const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes)
|
||||
const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes)
|
||||
const startupMs = results.map((result) => result.startupMs)
|
||||
const cpuMs = results.map((result) => result.cpuMs)
|
||||
const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms)
|
||||
const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms)
|
||||
const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs)
|
||||
const report = {
|
||||
benchmark: 'hang-watchdog-memory',
|
||||
boundary: options.boundary,
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
settleMs: SETTLE_MS,
|
||||
samplesPerTrial: SAMPLE_COUNT,
|
||||
productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS,
|
||||
productionSampleMs: PRODUCTION_SAMPLE_MS,
|
||||
trials: options.trials,
|
||||
rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
|
||||
summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) =>
|
||||
Number((value / MIB).toFixed(2))
|
||||
),
|
||||
medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)),
|
||||
physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) =>
|
||||
Number((value / MIB).toFixed(2))
|
||||
),
|
||||
medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)),
|
||||
startupMs: startupMs.map((value) => Number(value.toFixed(3))),
|
||||
medianStartupMs: Number(median(startupMs).toFixed(3)),
|
||||
cpuMs: cpuMs.map((value) => Number(value.toFixed(2))),
|
||||
medianCpuMs: Number(median(cpuMs).toFixed(2)),
|
||||
eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)),
|
||||
eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)),
|
||||
eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)),
|
||||
heartbeatCounts: results.map((result) => result.heartbeatCount),
|
||||
launchAttempts: results.map((result) => result.launchAttempts),
|
||||
blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified)
|
||||
}
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
process.stdout.write(serialized)
|
||||
if (options.output) {
|
||||
writeFileSync(path.resolve(options.output), serialized)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
await runInternal()
|
||||
} else {
|
||||
runBenchmark()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { monitorEventLoopDelay } from 'node:perf_hooks'
|
||||
|
||||
export function median(values) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
export async function sampleMemory(readRss, readPhysicalFootprint, options) {
|
||||
const rssSamples = []
|
||||
const physicalFootprintSamples = []
|
||||
for (let index = 0; index < options.sampleCount; index += 1) {
|
||||
rssSamples.push(readRss())
|
||||
physicalFootprintSamples.push(readPhysicalFootprint())
|
||||
await options.sleep(options.sampleIntervalMs)
|
||||
}
|
||||
return {
|
||||
rssBytes: median(rssSamples),
|
||||
physicalFootprintBytes: median(physicalFootprintSamples)
|
||||
}
|
||||
}
|
||||
|
||||
export function childRssBytes(pid) {
|
||||
const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const rssKiB = Number(raw)
|
||||
if (!Number.isFinite(rssKiB) || rssKiB <= 0) {
|
||||
throw new Error(`Could not read watchdog child RSS for PID ${pid}`)
|
||||
}
|
||||
return rssKiB * 1024
|
||||
}
|
||||
|
||||
export function parsePhysicalFootprintBytes(output, processCount) {
|
||||
const match =
|
||||
processCount > 1
|
||||
? output.match(/^Summary Footprint:\s+(\d+) B$/m)
|
||||
: output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m)
|
||||
const bytes = Number(match?.[1])
|
||||
return Number.isFinite(bytes) && bytes > 0 ? bytes : null
|
||||
}
|
||||
|
||||
export function physicalFootprintBytes(pids) {
|
||||
const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)])
|
||||
const output = execFileSync(
|
||||
'/usr/bin/footprint',
|
||||
[...pidArgs, '--format', 'bytes', '--noCategories'],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
const bytes = parsePhysicalFootprintBytes(output, pids.length)
|
||||
if (bytes === null) {
|
||||
throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function parseProcessCpuTimeMs(raw) {
|
||||
if (!raw.trim()) {
|
||||
return null
|
||||
}
|
||||
const parts = raw.split(':').map(Number)
|
||||
if (!parts.length || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
const seconds = parts.reduce((total, part) => total * 60 + part, 0)
|
||||
const milliseconds = seconds * 1_000
|
||||
return milliseconds >= 0 ? milliseconds : null
|
||||
}
|
||||
|
||||
function processCpuTimeMs(pid) {
|
||||
const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const milliseconds = parseProcessCpuTimeMs(raw)
|
||||
if (milliseconds === null) {
|
||||
throw new Error(`Could not read CPU time for PID ${pid}`)
|
||||
}
|
||||
return milliseconds
|
||||
}
|
||||
|
||||
function combinedCpuTimeMs(pids) {
|
||||
return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0)
|
||||
}
|
||||
|
||||
export async function sampleProductionPerformance(boundary, options) {
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
|
||||
let heartbeatCount = 0
|
||||
const heartbeat = setInterval(() => {
|
||||
heartbeatCount += 1
|
||||
boundary.sendHeartbeat()
|
||||
}, options.heartbeatIntervalMs)
|
||||
const cpuBefore = combinedCpuTimeMs(boundary.pids)
|
||||
loopDelay.enable()
|
||||
try {
|
||||
await options.sleep(options.sampleMs)
|
||||
} finally {
|
||||
loopDelay.disable()
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
return {
|
||||
cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore),
|
||||
heartbeatCount,
|
||||
eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6,
|
||||
eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6,
|
||||
eventLoopDelayMaxMs: loopDelay.max / 1e6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parsePhysicalFootprintBytes,
|
||||
parseProcessCpuTimeMs
|
||||
} from './hang-watchdog-process-metrics.mjs'
|
||||
|
||||
describe('hang watchdog process metrics', () => {
|
||||
it('uses the de-duplicated summary for multiple processes', () => {
|
||||
const output = `
|
||||
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
|
||||
phys_footprint: 5100000 B
|
||||
Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page)
|
||||
phys_footprint: 2100000 B
|
||||
Summary Footprint: 6259264 B
|
||||
`
|
||||
expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264)
|
||||
})
|
||||
|
||||
it('uses the process footprint rather than auxiliary accounting for one process', () => {
|
||||
const output = `
|
||||
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
|
||||
phys_footprint: 5100000 B
|
||||
`
|
||||
expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000)
|
||||
})
|
||||
|
||||
it('rejects missing or zero footprint summaries', () => {
|
||||
expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull()
|
||||
expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['0:00.04', 40],
|
||||
['1:02.50', 62_500],
|
||||
['2:01:02.50', 7_262_500]
|
||||
])('parses ps CPU time %s', (value, expected) => {
|
||||
expect(parseProcessCpuTimeMs(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('rejects invalid CPU times', () => {
|
||||
expect(parseProcessCpuTimeMs('')).toBeNull()
|
||||
expect(parseProcessCpuTimeMs('not-a-time')).toBeNull()
|
||||
expect(parseProcessCpuTimeMs('-1:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
|
||||
* by semver and every build is uniquely versioned. */
|
||||
export function createHourlyBuildVersion(baseVersion, date) {
|
||||
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
|
||||
if (!match) {
|
||||
throw new Error(`Package version is not valid semver: ${baseVersion}`)
|
||||
}
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
throw new Error('Hourly build timestamp is invalid.')
|
||||
}
|
||||
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
||||
const stamp = [
|
||||
pad(date.getUTCFullYear(), 4),
|
||||
pad(date.getUTCMonth() + 1),
|
||||
pad(date.getUTCDate()),
|
||||
pad(date.getUTCHours()),
|
||||
pad(date.getUTCMinutes())
|
||||
].join('')
|
||||
// Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the
|
||||
// RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an
|
||||
// ordinary RC-channel check offer untested hourly builds to RC users. Stripping
|
||||
// to the base parks hourlies below both rc.N and stable ('hourly' < 'rc'
|
||||
// alphabetically), reachable only by an explicit pinned jump.
|
||||
return `${match[1]}-hourly.${stamp}`
|
||||
}
|
||||
|
||||
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*
|
||||
* Why Pacific while the tag's stamp stays UTC: the stamp is a sort key, and a
|
||||
* local one would repeat an hour at every DST fall-back, making two distinct
|
||||
* builds compare equal. The title is only ever read, so it uses the timezone the
|
||||
* people reading it are in. The two therefore disagree by the current offset.
|
||||
*/
|
||||
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||
throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`)
|
||||
}
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
throw new Error('Hourly build timestamp is invalid.')
|
||||
}
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: RELEASE_NAME_TIME_ZONE,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
|
||||
hourCycle: 'h23'
|
||||
})
|
||||
.formatToParts(date)
|
||||
.map((part) => [part.type, part.value])
|
||||
)
|
||||
return [
|
||||
version.split('-')[0],
|
||||
String(buildNumber).padStart(2, '0'),
|
||||
`${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`,
|
||||
commit.slice(0, 7)
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createHourlyBuildVersion(packageJson.version, now)
|
||||
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
|
||||
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createHourlyBuildVersion', () => {
|
||||
it('stamps the version with a zero-padded UTC timestamp', () => {
|
||||
expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe(
|
||||
'1.4.160-hourly.202607280405'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
|
||||
// every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X >
|
||||
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly
|
||||
// builds to RC users. Dropping it parks hourlies below both rc.N and stable,
|
||||
// reachable only by an explicit pinned jump.
|
||||
it('drops an in-flight rc tail so hourlies never outrank the rc series', () => {
|
||||
const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
|
||||
expect(version).toBe('1.4.160-hourly.202607281400')
|
||||
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
|
||||
expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid input', () => {
|
||||
expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/)
|
||||
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHourlyReleaseName', () => {
|
||||
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
|
||||
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
|
||||
})
|
||||
|
||||
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
|
||||
// read as an hour that does not exist and sort oddly beside 00:xx.
|
||||
it('renders midnight as 00, never 24', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
expect(name('2026-07-31T20:54:00Z', 9)).toContain(' • 09 • ')
|
||||
expect(name('2026-07-31T20:54:00Z', 42)).toContain(' • 42 • ')
|
||||
expect(name('2026-07-31T20:54:00Z', 1234)).toContain(' • 1234 • ')
|
||||
})
|
||||
|
||||
it('rejects a build number that is not a positive integer', () => {
|
||||
expect(() => name('2026-07-31T20:54:00Z', 0)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-31T20:54:00Z', -1)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-31T20:54:00Z', 1.5)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-31T20:54:00Z', Number.NaN)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
it('rejects an invalid timestamp', () => {
|
||||
expect(() => formatHourlyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
|
||||
/invalid/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,21 @@
|
||||
export const MACOS_TCC_KEY_OVERRIDES = {
|
||||
'auto.hooks.useMacosTccPromptNotice.title': {
|
||||
es: '¿Ves avisos de “Orca quiere acceder…”?',
|
||||
ja: '「Orca がアクセスしようとしています…」という確認が表示されますか?',
|
||||
ko: '“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?',
|
||||
zh: '看到“Orca 想要访问…”提示?'
|
||||
},
|
||||
'auto.hooks.useMacosTccPromptNotice.description': {
|
||||
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
|
||||
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
|
||||
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
|
||||
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
|
||||
es: 'Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.',
|
||||
ja: 'Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。',
|
||||
ko: 'Orca에서 실행 중인 에이전트나 터미널 도구가 보호된 파일에 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.',
|
||||
zh: '当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了して再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
|
||||
ko: '전체 디스크 접근 권한',
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync, fork } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import {
|
||||
median,
|
||||
percentile,
|
||||
processSnapshot,
|
||||
sampleProcess
|
||||
} from './macos-computer-helper-owner-loss-metrics.mjs'
|
||||
import {
|
||||
benchmarkTrialNeedsCleanup,
|
||||
parseBenchmarkTrialResult,
|
||||
processIdentityIsCurrent,
|
||||
signalProcessIdentity,
|
||||
spawnBenchmarkProcess,
|
||||
throwBenchmarkTrialFailures,
|
||||
writeProcessRecord
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL'
|
||||
const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION'
|
||||
const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH'
|
||||
const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH'
|
||||
const ACTIVE_REQUEST_COUNT = 100_000
|
||||
const DEFAULT_TRIALS = 3
|
||||
const OWNER_HOLD_MS = 31_000
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000
|
||||
const RETAIN_PROOF_MS = 3_000
|
||||
const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000
|
||||
const MIB = 1024 * 1024
|
||||
const scriptPath = import.meta.filename
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs')
|
||||
const processCleanupPath = path.join(
|
||||
import.meta.dirname,
|
||||
'macos-computer-helper-owner-loss-processes.mjs'
|
||||
)
|
||||
const trialCleanupPath = path.join(
|
||||
import.meta.dirname,
|
||||
'macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
)
|
||||
const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js')
|
||||
const helperAppPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'computer-use-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'Orca Computer Use.app'
|
||||
)
|
||||
const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(identity, timeoutMs) {
|
||||
const startedAt = performance.now()
|
||||
while (performance.now() - startedAt < timeoutMs) {
|
||||
if (!processIdentityIsCurrent(identity)) {
|
||||
return performance.now() - startedAt
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function stopProcess(identity) {
|
||||
if (!identity || !processIdentityIsCurrent(identity)) {
|
||||
return
|
||||
}
|
||||
signalProcessIdentity(identity, helperPath, 'SIGTERM')
|
||||
if ((await waitForProcessExit(identity, 2_000)) !== null) {
|
||||
return
|
||||
}
|
||||
signalProcessIdentity(identity, helperPath, 'SIGKILL')
|
||||
await waitForProcessExit(identity, 2_000)
|
||||
}
|
||||
|
||||
function startSidecar() {
|
||||
const errors = []
|
||||
const child = fork(sidecarPath, [], {
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_COMPUTER_SIDECAR: '1',
|
||||
ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => errors.push(error.stack ?? error.message))
|
||||
child.stderr?.on('data', (chunk) => errors.push(String(chunk)))
|
||||
return { child, errors }
|
||||
}
|
||||
|
||||
function requestSidecar(sidecar, id, method) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`))
|
||||
}, 10_000)
|
||||
const onMessage = (message) => {
|
||||
if (message?.id !== id) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
if (message.ok) {
|
||||
resolve(message.result)
|
||||
} else {
|
||||
reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`))
|
||||
}
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
cleanup()
|
||||
reject(
|
||||
new Error(
|
||||
`Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}`
|
||||
)
|
||||
)
|
||||
}
|
||||
const onError = (error) => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar ${method} process error: ${error.message}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
sidecar.child.off('message', onMessage)
|
||||
sidecar.child.off('exit', onExit)
|
||||
sidecar.child.off('error', onError)
|
||||
}
|
||||
sidecar.child.on('message', onMessage)
|
||||
sidecar.child.once('exit', onExit)
|
||||
sidecar.child.once('error', onError)
|
||||
try {
|
||||
sidecar.child.send({ id, method, params: {} })
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHelper(sidecarPid) {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)
|
||||
if (
|
||||
match &&
|
||||
Number(match[2]) === sidecarPid &&
|
||||
Number(match[3]) === Number(match[1]) &&
|
||||
match[4].includes(helperPath) &&
|
||||
match[4].includes(' --agent ')
|
||||
) {
|
||||
return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] }
|
||||
}
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`)
|
||||
}
|
||||
|
||||
function socketPathFromCommand(command) {
|
||||
const match = command.match(/ --agent (.+?) --token-file /)
|
||||
if (!match) {
|
||||
throw new Error(`Could not read helper socket path from command: ${command}`)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function connectInvalidPeer(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(socketPath)
|
||||
let accepted = false
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy()
|
||||
reject(new Error('Invalid-peer connection timed out'))
|
||||
}, 5_000)
|
||||
let buffer = ''
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.off('error', onError)
|
||||
socket.off('data', onData)
|
||||
}
|
||||
const onError = (error) => {
|
||||
if (accepted) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
return
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(buffer.slice(0, newline))
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
if (response.ok !== false || response.error?.code !== 'permission_denied') {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`))
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
accepted = true
|
||||
resolve(socket)
|
||||
}
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('error', onError)
|
||||
socket.on('data', onData)
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return true
|
||||
}
|
||||
return await Promise.race([
|
||||
new Promise((resolve) => child.once('exit', () => resolve(true))),
|
||||
sleep(timeoutMs).then(() => false)
|
||||
])
|
||||
}
|
||||
|
||||
async function startAuthenticatedSession() {
|
||||
const sidecar = startSidecar()
|
||||
let helper
|
||||
try {
|
||||
const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then(
|
||||
(capabilities) => ({ capabilities }),
|
||||
(error) => ({ error })
|
||||
)
|
||||
helper = await waitForHelper(sidecar.child.pid)
|
||||
const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV]
|
||||
if (!helperRecordPath) {
|
||||
throw new Error('Missing helper process record path')
|
||||
}
|
||||
writeProcessRecord(helperRecordPath, helper)
|
||||
const { capabilities, error } = await capabilitiesResult
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (capabilities?.protocolVersion !== 1) {
|
||||
throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`)
|
||||
}
|
||||
return { authenticated: capabilities.protocolVersion === 1, sidecar, helper }
|
||||
} catch (error) {
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function exerciseActiveRequests(sidecar) {
|
||||
const latencies = []
|
||||
const startedAt = performance.now()
|
||||
for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) {
|
||||
const requestStartedAt = performance.now()
|
||||
const result = await requestSidecar(sidecar, 10_000 + index, 'listApps')
|
||||
if (!Array.isArray(result?.apps)) {
|
||||
throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`)
|
||||
}
|
||||
latencies.push(performance.now() - requestStartedAt)
|
||||
}
|
||||
const totalMs = performance.now() - startedAt
|
||||
return {
|
||||
totalMs,
|
||||
requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs,
|
||||
medianLatencyMs: median(latencies),
|
||||
p95LatencyMs: percentile(latencies, 0.95),
|
||||
maxLatencyMs: Math.max(...latencies)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGracefulClose() {
|
||||
const { sidecar, helper } = await startAuthenticatedSession()
|
||||
try {
|
||||
const startedAt = performance.now()
|
||||
sidecar.child.disconnect()
|
||||
if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) {
|
||||
throw new Error('Sidecar did not exit after graceful IPC close')
|
||||
}
|
||||
const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS)
|
||||
if (helperExitMs === null) {
|
||||
throw new Error('Helper did not exit after graceful owner close')
|
||||
}
|
||||
return Math.round(performance.now() - startedAt)
|
||||
} finally {
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternalTrial(expectation) {
|
||||
let sidecar
|
||||
let helper
|
||||
let invalidPeer
|
||||
let invalidPeerRejected = false
|
||||
try {
|
||||
const session = await startAuthenticatedSession()
|
||||
sidecar = session.sidecar
|
||||
helper = session.helper
|
||||
const authenticatedAt = performance.now()
|
||||
const initial = await sampleProcess(helper.pid)
|
||||
const activeRequests = await exerciseActiveRequests(sidecar)
|
||||
const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt))
|
||||
await sleep(remainingHoldMs)
|
||||
const connected = await sampleProcess(helper.pid)
|
||||
const invalidSocketPath = socketPathFromCommand(helper.command)
|
||||
invalidPeer = await connectInvalidPeer(invalidSocketPath)
|
||||
invalidPeerRejected = true
|
||||
const survivedClaimDeadline =
|
||||
performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid)
|
||||
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS)
|
||||
const abruptExitMs = await waitForProcessExit(
|
||||
helper,
|
||||
expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS
|
||||
)
|
||||
const helperExitedAfterAbruptLoss = abruptExitMs !== null
|
||||
if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) {
|
||||
throw new Error('Expected helper to exit after abrupt authenticated owner loss')
|
||||
}
|
||||
if (expectation === 'retained' && helperExitedAfterAbruptLoss) {
|
||||
throw new Error('Expected baseline helper to remain after abrupt owner loss')
|
||||
}
|
||||
const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes
|
||||
await stopProcess(helper)
|
||||
helper = null
|
||||
invalidPeer.destroy()
|
||||
invalidPeer = null
|
||||
|
||||
const gracefulExitMs = await verifyGracefulClose()
|
||||
return {
|
||||
authenticated: session.authenticated,
|
||||
survivedClaimDeadline,
|
||||
invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss,
|
||||
connectedRssBytes: connected.rssBytes,
|
||||
connectedCpuMilliseconds: Math.max(
|
||||
0,
|
||||
Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000)
|
||||
),
|
||||
activeRequests,
|
||||
cpuSampleMs: Math.round(performance.now() - authenticatedAt),
|
||||
helperExitedAfterAbruptLoss,
|
||||
abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs),
|
||||
postLossRssBytes,
|
||||
gracefulExitMs
|
||||
}
|
||||
} finally {
|
||||
invalidPeer?.destroy()
|
||||
sidecar?.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { expect: '', trials: DEFAULT_TRIALS, output: '' }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--expect' || arg === '--trials' || arg === '--output') {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!['retained', 'reaped'].includes(options.expect)) {
|
||||
throw new Error('--expect must be retained or reaped')
|
||||
}
|
||||
if (!Number.isInteger(options.trials) || options.trials < 1) {
|
||||
throw new Error('--trials must be a positive integer')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function electronPath() {
|
||||
const electronModulePath = fileURLToPath(import.meta.resolve('electron'))
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
}
|
||||
|
||||
function buildArtifacts() {
|
||||
execFileSync('pnpm', ['exec', 'electron-vite', 'build'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
execFileSync('pnpm', ['build:computer-macos'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
function artifactSha256(artifactPath) {
|
||||
return createHash('sha256').update(readFileSync(artifactPath)).digest('hex')
|
||||
}
|
||||
|
||||
function runTrial(executable, expectation) {
|
||||
let launcherDir
|
||||
let trialTempDir
|
||||
let helperRecordPath
|
||||
let resultPath
|
||||
let stderrPath
|
||||
let stdoutPath
|
||||
let result
|
||||
let serializedResult
|
||||
let parsedResult
|
||||
let parsedResultAvailable = false
|
||||
let trialError
|
||||
let cleanupError
|
||||
let stderrDescriptor
|
||||
let stdoutDescriptor
|
||||
let trialOutput = ''
|
||||
try {
|
||||
launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-'))
|
||||
trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-'))
|
||||
helperRecordPath = path.join(launcherDir, 'helper.json')
|
||||
resultPath = path.join(launcherDir, 'result.json')
|
||||
stderrPath = path.join(launcherDir, 'stderr.log')
|
||||
stdoutPath = path.join(launcherDir, 'stdout.log')
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'main.cjs'),
|
||||
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: trialTempDir,
|
||||
[INTERNAL_ENV]: '1',
|
||||
[EXPECTATION_ENV]: expectation,
|
||||
[HELPER_RECORD_PATH_ENV]: helperRecordPath,
|
||||
[RESULT_PATH_ENV]: resultPath
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
stderrDescriptor = openSync(stderrPath, 'w')
|
||||
stdoutDescriptor = openSync(stdoutPath, 'w')
|
||||
result = spawnBenchmarkProcess(executable, [launcherDir], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: ['ignore', stdoutDescriptor, stderrDescriptor],
|
||||
timeout: TRIAL_TIMEOUT_MS
|
||||
})
|
||||
if (result.status === 0 && existsSync(resultPath)) {
|
||||
serializedResult = readFileSync(resultPath, 'utf8')
|
||||
parsedResult = parseBenchmarkTrialResult(serializedResult)
|
||||
parsedResultAvailable = true
|
||||
}
|
||||
} catch (error) {
|
||||
trialError = error
|
||||
} finally {
|
||||
const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable)
|
||||
const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined
|
||||
const cleanup = cleanupOwnerLossTrial({
|
||||
failed: failedTrial,
|
||||
pid: result?.pid,
|
||||
marker: trialMarker,
|
||||
recordPath: helperRecordPath,
|
||||
helperPath,
|
||||
tempDir: trialTempDir,
|
||||
stderrDescriptor,
|
||||
stdoutDescriptor,
|
||||
outputPaths: [stderrPath, stdoutPath],
|
||||
launcherDir
|
||||
})
|
||||
cleanupError = cleanup.error
|
||||
trialOutput = cleanup.output
|
||||
}
|
||||
if (!trialError && result?.status !== 0) {
|
||||
trialError = new Error(
|
||||
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}`
|
||||
)
|
||||
}
|
||||
if (!trialError && !serializedResult) {
|
||||
trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`)
|
||||
}
|
||||
throwBenchmarkTrialFailures(trialError, cleanupError)
|
||||
return parsedResult
|
||||
}
|
||||
|
||||
function runBenchmark() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The computer-use helper owner benchmark is macOS-only')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const dirty = execFileSync('git', ['status', '--porcelain'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
if (dirty) {
|
||||
throw new Error('Commit or stash changes before running the provenance-bound benchmark')
|
||||
}
|
||||
buildArtifacts()
|
||||
if (!existsSync(sidecarPath) || !existsSync(helperPath)) {
|
||||
throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts')
|
||||
}
|
||||
const executable = electronPath()
|
||||
const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect))
|
||||
const rssBytes = results.map((result) => result.connectedRssBytes)
|
||||
const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds)
|
||||
const activeRequestTotals = results.map((result) => result.activeRequests.totalMs)
|
||||
const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond)
|
||||
const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs)
|
||||
const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs)
|
||||
const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs)
|
||||
const postLossRssBytes = results.map((result) => result.postLossRssBytes)
|
||||
const report = {
|
||||
benchmark: 'macos-computer-helper-authenticated-owner-loss',
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
artifacts: {
|
||||
sidecarSha256: artifactSha256(sidecarPath),
|
||||
helperSha256: artifactSha256(helperPath)
|
||||
},
|
||||
sources: {
|
||||
benchmarkSha256: artifactSha256(scriptPath),
|
||||
metricsSha256: artifactSha256(metricsPath),
|
||||
processCleanupSha256: artifactSha256(processCleanupPath),
|
||||
trialCleanupSha256: artifactSha256(trialCleanupPath)
|
||||
},
|
||||
expectation: options.expect,
|
||||
trials: options.trials,
|
||||
ownerHoldMs: OWNER_HOLD_MS,
|
||||
activeRequestCount: ACTIVE_REQUEST_COUNT,
|
||||
authenticated: results.every((result) => result.authenticated),
|
||||
survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline),
|
||||
invalidPeerRejectedAndDidNotRetain: results.every(
|
||||
(result) => result.invalidPeerRejectedAndDidNotRetain
|
||||
),
|
||||
connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
|
||||
connectedCpuMilliseconds: cpuMilliseconds,
|
||||
medianConnectedCpuMilliseconds: median(cpuMilliseconds),
|
||||
activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)),
|
||||
activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))),
|
||||
medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)),
|
||||
activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)),
|
||||
activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)),
|
||||
activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))),
|
||||
helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss),
|
||||
abruptExitMs: results.map((result) => result.abruptExitMs),
|
||||
postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)),
|
||||
gracefulExitMs: results.map((result) => result.gracefulExitMs)
|
||||
}
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
process.stdout.write(serialized)
|
||||
if (options.output) {
|
||||
writeFileSync(path.resolve(options.output), serialized)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
const { app } = await import('electron')
|
||||
await app.whenReady()
|
||||
try {
|
||||
const result = await runInternalTrial(process.env[EXPECTATION_ENV])
|
||||
writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result))
|
||||
} finally {
|
||||
app.quit()
|
||||
}
|
||||
} else {
|
||||
runBenchmark()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
|
||||
describe('macOS helper owner-loss benchmark group recovery', () => {
|
||||
it('retains uncertain stop state across cleanup stage failures', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const groupState = { stopped: false }
|
||||
let scanCount = 0
|
||||
let continueAttempts = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount >= 3) {
|
||||
throw new Error(
|
||||
scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed'
|
||||
)
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === -41 && signal === 'SIGCONT') {
|
||||
continueAttempts += 1
|
||||
if (continueAttempts === 1) {
|
||||
throw new Error('first compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(groupState.stopped).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('final inspection failed')
|
||||
expect(continueAttempts).toBe(2)
|
||||
expect(groupState.stopped).toBe(false)
|
||||
})
|
||||
|
||||
it('retains an uncertain anchor stop across cleanup stage failures', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let scanCount = 0
|
||||
let continueAttempts = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount >= 2) {
|
||||
throw new Error(
|
||||
scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed'
|
||||
)
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === 41 && signal === 'SIGCONT') {
|
||||
continueAttempts += 1
|
||||
if (continueAttempts === 1) {
|
||||
throw new Error('first anchor compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(groupState.anchorPid).toBe(41)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('final inspection failed')
|
||||
expect(continueAttempts).toBe(2)
|
||||
expect(groupState.anchorPid).toBeNull()
|
||||
})
|
||||
|
||||
it('recovers a retained anchor before selecting a new one', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let scanCount = 0
|
||||
let firstAnchorContinues = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 2) {
|
||||
throw new Error('post-anchor inspection failed')
|
||||
}
|
||||
return scanCount === 1 ? [firstAnchor] : [finalAnchor]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === 41 && signal === 'SIGCONT') {
|
||||
firstAnchorContinues += 1
|
||||
if (firstAnchorContinues === 1) {
|
||||
throw new Error('first anchor compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true)
|
||||
expect(firstAnchorContinues).toBe(2)
|
||||
expect(groupState).toEqual({ stopped: false, anchorPid: null })
|
||||
})
|
||||
|
||||
it('preserves group authority errors when compensation fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const ownershipError = 'Benchmark process group no longer belongs to this trial'
|
||||
let thrown
|
||||
|
||||
try {
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: true },
|
||||
{
|
||||
processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }],
|
||||
signalProcess: () => {
|
||||
throw new Error('resume denied')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const SAMPLE_COUNT = 5
|
||||
const SAMPLE_INTERVAL_MS = 200
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function median(values) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
export function percentile(values, fraction) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]
|
||||
}
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = clock.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
throw new Error(`Invalid process CPU time: ${value}`)
|
||||
}
|
||||
const seconds = parts.pop() ?? 0
|
||||
const minutes = parts.pop() ?? 0
|
||||
const hours = parts.pop() ?? 0
|
||||
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
export function processSnapshot(pid) {
|
||||
const raw = execFileSync(
|
||||
'ps',
|
||||
['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)],
|
||||
{ encoding: 'utf8' }
|
||||
).trim()
|
||||
const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(`Could not inspect process ${pid}: ${raw}`)
|
||||
}
|
||||
return {
|
||||
rssBytes: Number(match[1]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[2]),
|
||||
command: match[3]
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleProcess(pid) {
|
||||
const samples = []
|
||||
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
|
||||
samples.push(processSnapshot(pid))
|
||||
await sleep(SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return {
|
||||
rssBytes: median(samples.map((sample) => sample.rssBytes)),
|
||||
cpuTimeSeconds: samples.at(-1).cpuTimeSeconds,
|
||||
command: samples.at(-1).command
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 2_000
|
||||
const PROCESS_POLL_MS = 25
|
||||
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
|
||||
|
||||
const processIdentityOperations = {
|
||||
executePs: execFileSync,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
export function processIdentity(pid, operations = processIdentityOperations) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const output = operations
|
||||
.executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse process identity for ${pid}`)
|
||||
}
|
||||
return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] }
|
||||
} catch (error) {
|
||||
try {
|
||||
operations.signalProcess(pid, 0)
|
||||
} catch (lookupError) {
|
||||
if (lookupError?.code === 'ESRCH') {
|
||||
return null
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function matchingDetachedProcesses(identities, expectedCommandFragments) {
|
||||
return identities.filter(
|
||||
(identity) =>
|
||||
identity.pgid === identity.pid &&
|
||||
expectedCommandFragments.every((fragment) => identity.command.includes(fragment))
|
||||
)
|
||||
}
|
||||
|
||||
const matchingProcessOperations = {
|
||||
processIdentities,
|
||||
signalProcessIdentity,
|
||||
waitForIdentityExit
|
||||
}
|
||||
|
||||
export function killProcessMatchingCommand(
|
||||
expectedCommandFragments,
|
||||
operations = matchingProcessOperations
|
||||
) {
|
||||
const matches = matchingDetachedProcesses(
|
||||
operations.processIdentities(),
|
||||
expectedCommandFragments
|
||||
)
|
||||
if (matches.length === 0) {
|
||||
return false
|
||||
}
|
||||
const errors = []
|
||||
for (const match of matches) {
|
||||
try {
|
||||
if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) {
|
||||
operations.waitForIdentityExit(match)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const remaining = matchingDetachedProcesses(
|
||||
operations.processIdentities(),
|
||||
expectedCommandFragments
|
||||
)
|
||||
if (remaining.length > 0) {
|
||||
errors.push(
|
||||
new Error(
|
||||
`Benchmark helper cleanup left matching processes: ${remaining
|
||||
.map((identity) => identity.pid)
|
||||
.join(', ')}`
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark exact-command cleanup failed')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sleepSync(milliseconds) {
|
||||
Atomics.wait(sleepBuffer, 0, 0, milliseconds)
|
||||
}
|
||||
|
||||
function validateDetachedIdentity(identity, expectedCommandFragment) {
|
||||
if (
|
||||
!Number.isInteger(identity?.pid) ||
|
||||
identity.pid <= 0 ||
|
||||
identity.pgid !== identity.pid ||
|
||||
typeof identity.command !== 'string' ||
|
||||
!identity.command.includes(expectedCommandFragment)
|
||||
) {
|
||||
throw new Error('Recorded benchmark helper identity is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command
|
||||
}
|
||||
|
||||
function processIdentities(includeEnvironment = false) {
|
||||
const args = includeEnvironment
|
||||
? ['eww', '-axo', 'pid=,pgid=,command=']
|
||||
: ['-axo', 'pid=,pgid=,command=']
|
||||
const output = execFileSync('ps', args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
pgid: Number(match[2]),
|
||||
command: match[3]
|
||||
}))
|
||||
}
|
||||
|
||||
function waitForIdentityExit(identity) {
|
||||
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (!processIdentityIsCurrent(identity)) {
|
||||
return true
|
||||
}
|
||||
sleepSync(PROCESS_POLL_MS)
|
||||
}
|
||||
throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`)
|
||||
}
|
||||
|
||||
export function spawnBenchmarkProcess(executable, args, options) {
|
||||
return spawnSync(executable, args, {
|
||||
...options,
|
||||
detached: true,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
}
|
||||
|
||||
export function runBenchmarkCleanupStages(stages) {
|
||||
const errors = []
|
||||
for (const stage of stages) {
|
||||
try {
|
||||
stage()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark trial cleanup failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function throwBenchmarkTrialFailures(trialError, cleanupError) {
|
||||
if (trialError && cleanupError) {
|
||||
throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed')
|
||||
}
|
||||
if (trialError) {
|
||||
throw trialError
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBenchmarkTrialResult(serializedResult) {
|
||||
return JSON.parse(serializedResult)
|
||||
}
|
||||
|
||||
export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) {
|
||||
return spawnResult?.status !== 0 || !parsedResultAvailable
|
||||
}
|
||||
|
||||
const processGroupSignalOperations = {
|
||||
processIdentities,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
function compensateStoppedGroup(pgid, groupState, operations) {
|
||||
const errors = []
|
||||
const targets = [
|
||||
groupState.stopped ? [-pgid, 'stopped'] : null,
|
||||
groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null
|
||||
].filter(Boolean)
|
||||
for (const [pid, stateKey] of targets) {
|
||||
try {
|
||||
operations.signalProcess(pid, 'SIGCONT')
|
||||
groupState[stateKey] = stateKey === 'stopped' ? false : null
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState[stateKey] = stateKey === 'stopped' ? false : null
|
||||
} else {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function signalValidatedProcessGroup(
|
||||
pgid,
|
||||
environmentFragment,
|
||||
signal,
|
||||
groupState = { stopped: false, anchorPid: null },
|
||||
operations = processGroupSignalOperations
|
||||
) {
|
||||
if (!Number.isInteger(pgid) || pgid <= 0) {
|
||||
return false
|
||||
}
|
||||
let members
|
||||
try {
|
||||
members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid)
|
||||
} catch (error) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...recoveryErrors],
|
||||
'Benchmark process group recovery failed before validation'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (members.length === 0) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (members.some((identity) => !identity.command.includes(environmentFragment))) {
|
||||
const ownershipError = new Error('Benchmark process group no longer belongs to this trial')
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[ownershipError, ...recoveryErrors],
|
||||
'Benchmark process group authority recovery failed'
|
||||
)
|
||||
}
|
||||
throw ownershipError
|
||||
}
|
||||
if (groupState.anchorPid) {
|
||||
try {
|
||||
operations.signalProcess(groupState.anchorPid, 'SIGCONT')
|
||||
groupState.anchorPid = null
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState.anchorPid = null
|
||||
} else {
|
||||
throw new AggregateError([error], 'Benchmark pending anchor recovery failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
const anchor = members[0]
|
||||
try {
|
||||
operations.signalProcess(anchor.pid, 'SIGSTOP')
|
||||
groupState.anchorPid = anchor.pid
|
||||
const stoppedAnchor = operations
|
||||
.processIdentities(true)
|
||||
.find((identity) => identity.pid === anchor.pid)
|
||||
if (!sameIdentity(stoppedAnchor, anchor)) {
|
||||
throw new Error('Benchmark process group anchor changed before signaling')
|
||||
}
|
||||
operations.signalProcess(-pgid, 'SIGSTOP')
|
||||
groupState.stopped = true
|
||||
groupState.anchorPid = null
|
||||
const stoppedMembers = operations
|
||||
.processIdentities(true)
|
||||
.filter((identity) => identity.pgid === pgid)
|
||||
if (
|
||||
stoppedMembers.length === 0 ||
|
||||
stoppedMembers.some((identity) => !identity.command.includes(environmentFragment))
|
||||
) {
|
||||
throw new Error('Benchmark process group changed before signaling')
|
||||
}
|
||||
if (signal !== 'SIGSTOP') {
|
||||
operations.signalProcess(-pgid, signal)
|
||||
if (signal !== 'SIGKILL') {
|
||||
operations.signalProcess(-pgid, 'SIGCONT')
|
||||
}
|
||||
groupState.stopped = false
|
||||
groupState.anchorPid = null
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...recoveryErrors],
|
||||
'Benchmark process group signal recovery failed'
|
||||
)
|
||||
}
|
||||
if (error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProcessRecord(recordPath, processIdentity) {
|
||||
const temporaryPath = `${recordPath}.${process.pid}.tmp`
|
||||
writeFileSync(temporaryPath, JSON.stringify(processIdentity))
|
||||
renameSync(temporaryPath, recordPath)
|
||||
}
|
||||
|
||||
export function processIdentityIsCurrent(identity) {
|
||||
return sameIdentity(processIdentity(identity?.pid), identity)
|
||||
}
|
||||
|
||||
const processSignalOperations = {
|
||||
processIdentity,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
export function signalProcessIdentity(
|
||||
identity,
|
||||
expectedCommandFragment,
|
||||
signal,
|
||||
operations = processSignalOperations
|
||||
) {
|
||||
validateDetachedIdentity(identity, expectedCommandFragment)
|
||||
const currentIdentity = operations.processIdentity(identity.pid)
|
||||
if (!currentIdentity) {
|
||||
return false
|
||||
}
|
||||
if (!sameIdentity(currentIdentity, identity)) {
|
||||
throw new Error('Recorded benchmark helper PID now belongs to another process')
|
||||
}
|
||||
let stopped = false
|
||||
try {
|
||||
operations.signalProcess(identity.pid, 'SIGSTOP')
|
||||
stopped = true
|
||||
const stoppedIdentity = operations.processIdentity(identity.pid)
|
||||
if (!sameIdentity(stoppedIdentity, identity)) {
|
||||
throw new Error('Recorded benchmark helper PID changed before signaling')
|
||||
}
|
||||
operations.signalProcess(-identity.pgid, signal)
|
||||
if (signal !== 'SIGKILL') {
|
||||
operations.signalProcess(-identity.pgid, 'SIGCONT')
|
||||
}
|
||||
stopped = false
|
||||
return true
|
||||
} catch (error) {
|
||||
let resumeError
|
||||
if (stopped) {
|
||||
try {
|
||||
operations.signalProcess(identity.pid, 'SIGCONT')
|
||||
} catch (caught) {
|
||||
if (caught?.code !== 'ESRCH') {
|
||||
resumeError = caught
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resumeError) {
|
||||
throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed')
|
||||
}
|
||||
if (error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function killRecordedProcess(recordPath, expectedCommandFragment) {
|
||||
if (!existsSync(recordPath)) {
|
||||
return false
|
||||
}
|
||||
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
|
||||
if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) {
|
||||
return false
|
||||
}
|
||||
return waitForIdentityExit(record)
|
||||
}
|
||||
|
||||
export function killRecordedAndMatchingProcesses(
|
||||
recordPath,
|
||||
recordedCommandFragment,
|
||||
matchingCommandFragments
|
||||
) {
|
||||
const errors = []
|
||||
try {
|
||||
killRecordedProcess(recordPath, recordedCommandFragment)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
try {
|
||||
killProcessMatchingCommand(matchingCommandFragments)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark helper cleanup failed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
benchmarkTrialNeedsCleanup,
|
||||
killProcessMatchingCommand,
|
||||
killRecordedAndMatchingProcesses,
|
||||
killRecordedProcess,
|
||||
parseBenchmarkTrialResult,
|
||||
processIdentity,
|
||||
runBenchmarkCleanupStages,
|
||||
signalProcessIdentity,
|
||||
signalValidatedProcessGroup,
|
||||
spawnBenchmarkProcess,
|
||||
throwBenchmarkTrialFailures,
|
||||
writeProcessRecord
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
|
||||
const describeMacOS = process.platform === 'darwin' ? describe : describe.skip
|
||||
const spawnedPids = new Set()
|
||||
const temporaryDirectories = new Set()
|
||||
|
||||
afterEach(() => {
|
||||
for (const pid of spawnedPids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
spawnedPids.clear()
|
||||
for (const temporaryDirectory of temporaryDirectories) {
|
||||
rmSync(temporaryDirectory, { recursive: true, force: true })
|
||||
}
|
||||
temporaryDirectories.clear()
|
||||
})
|
||||
|
||||
describeMacOS('macOS helper owner-loss benchmark process cleanup', () => {
|
||||
it('enforces a hard timeout when the trial ignores SIGTERM', () => {
|
||||
const startedAt = Date.now()
|
||||
const result = spawnBenchmarkProcess(
|
||||
process.execPath,
|
||||
['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"],
|
||||
{ stdio: 'ignore', timeout: 100 }
|
||||
)
|
||||
expect(result.error?.code).toBe('ETIMEDOUT')
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
||||
expect(() => process.kill(result.pid, 0)).toThrow()
|
||||
})
|
||||
|
||||
it('runs every cleanup stage before aggregating errors', () => {
|
||||
const completed = []
|
||||
let thrown
|
||||
|
||||
try {
|
||||
runBenchmarkCleanupStages([
|
||||
() => {
|
||||
completed.push(1)
|
||||
throw new Error('first failure')
|
||||
},
|
||||
() => {
|
||||
completed.push(2)
|
||||
},
|
||||
() => {
|
||||
completed.push(3)
|
||||
throw new Error('last failure')
|
||||
}
|
||||
])
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(completed).toEqual([1, 2, 3])
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure'])
|
||||
})
|
||||
|
||||
it('preserves malformed-result and cleanup failures', () => {
|
||||
let trialError
|
||||
try {
|
||||
parseBenchmarkTrialResult('{malformed')
|
||||
} catch (error) {
|
||||
trialError = error
|
||||
}
|
||||
const cleanupError = new Error('cleanup failed')
|
||||
let thrown
|
||||
|
||||
try {
|
||||
throwBenchmarkTrialFailures(trialError, cleanupError)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(trialError).toBeInstanceOf(SyntaxError)
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors).toEqual([trialError, cleanupError])
|
||||
})
|
||||
|
||||
it('cleans up a status-zero trial whose result could not be parsed', () => {
|
||||
expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes a launcher directory after partial trial setup', () => {
|
||||
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-'))
|
||||
temporaryDirectories.add(launcherDir)
|
||||
|
||||
const cleanup = cleanupOwnerLossTrial({
|
||||
failed: true,
|
||||
launcherDir,
|
||||
outputPaths: []
|
||||
})
|
||||
|
||||
expect(cleanup.error).toBeUndefined()
|
||||
expect(existsSync(launcherDir)).toBe(false)
|
||||
temporaryDirectories.delete(launcherDir)
|
||||
})
|
||||
|
||||
it('kills a timed-out trial group only after validating its environment', async () => {
|
||||
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-'))
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const childPidPath = path.join(temporaryDirectory, 'child.pid')
|
||||
const environmentName = `ORCA_OWNER_GROUP_${process.pid}`
|
||||
const environmentValue = `${Date.now()}`
|
||||
const fixture = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid))
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], {
|
||||
env: { ...process.env, [environmentName]: environmentValue },
|
||||
stdio: 'ignore',
|
||||
timeout: 100
|
||||
})
|
||||
const childPid = Number(readFileSync(childPidPath, 'utf8'))
|
||||
spawnedPids.add(childPid)
|
||||
const environmentFragment = `${environmentName}=${environmentValue}`
|
||||
const groupState = { stopped: false }
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP')
|
||||
).toThrow('Benchmark process group no longer belongs to this trial')
|
||||
expect(() => process.kill(childPid, 0)).not.toThrow()
|
||||
expect(
|
||||
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState)
|
||||
).toBe(true)
|
||||
expect(
|
||||
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState)
|
||||
).toBe(true)
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
process.kill(childPid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
spawnedPids.delete(childPid)
|
||||
})
|
||||
|
||||
it('resumes the group after post-stop revalidation fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [
|
||||
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
|
||||
{ pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
]
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: false },
|
||||
{
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 3) {
|
||||
throw new Error('transient group inspection failure')
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('transient group inspection failure')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[-41, 'SIGSTOP'],
|
||||
[-41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('compensates a possible stop after group anchor replacement', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: false },
|
||||
{
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 1 ? [anchor] : [replacement]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('Benchmark process group anchor changed before signaling')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group when final inspection fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
const groupState = { stopped: false }
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 4) {
|
||||
throw new Error('transient final inspection failure')
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('transient final inspection failure')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[-41, 'SIGSTOP'],
|
||||
[-41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group when final anchor stop fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [
|
||||
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
|
||||
{ pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
]
|
||||
const signals = []
|
||||
let finalCall = false
|
||||
const groupState = { stopped: false }
|
||||
const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' })
|
||||
const operations = {
|
||||
processIdentities: () => members,
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
if (finalCall && pid === 41 && signal === 'SIGSTOP') {
|
||||
throw missingProcessError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
finalCall = true
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false)
|
||||
expect(signals.at(-1)).toEqual([-41, 'SIGCONT'])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group after final anchor replacement', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const child = { pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
const groupState = { stopped: false }
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 5 ? [replacement, child] : [anchor, child]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('Benchmark process group anchor changed before signaling')
|
||||
expect(signals.slice(-2)).toEqual([
|
||||
[-41, 'SIGCONT'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('kills a recorded helper in a separate process group', async () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const exited = new Promise((resolve) => helper.once('exit', resolve))
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const processGroup = Number(
|
||||
execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
)
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command })
|
||||
|
||||
expect(processGroup).toBe(helper.pid)
|
||||
expect(killRecordedProcess(recordPath, marker)).toBe(true)
|
||||
await exited
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
|
||||
spawnedPids.delete(helper.pid)
|
||||
})
|
||||
|
||||
it('kills every unrecorded helper using its unique trial command', async () => {
|
||||
const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}`
|
||||
const helpers = Array.from({ length: 2 }, () =>
|
||||
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
)
|
||||
for (const helper of helpers) {
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
}
|
||||
const exited = Promise.all(
|
||||
helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve)))
|
||||
)
|
||||
|
||||
expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true)
|
||||
await exited
|
||||
for (const helper of helpers) {
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
spawnedPids.delete(helper.pid)
|
||||
}
|
||||
})
|
||||
|
||||
it('continues exact-match cleanup after an earlier match fails', () => {
|
||||
const marker = `orca-owner-multiple-${process.pid}-${Date.now()}`
|
||||
const matches = [
|
||||
{ pid: 41, pgid: 41, command: `/helper ${marker}` },
|
||||
{ pid: 42, pgid: 42, command: `/helper ${marker}` }
|
||||
]
|
||||
const attempted = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
killProcessMatchingCommand(['/helper', marker], {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 1 ? matches : [matches[0]]
|
||||
},
|
||||
signalProcessIdentity: (identity) => {
|
||||
attempted.push(identity.pid)
|
||||
if (identity.pid === matches[0].pid) {
|
||||
throw new Error('identity changed')
|
||||
}
|
||||
return true
|
||||
},
|
||||
waitForIdentityExit: () => {}
|
||||
})
|
||||
).toThrow('Benchmark exact-command cleanup failed')
|
||||
expect(attempted).toEqual([41, 42])
|
||||
})
|
||||
|
||||
it('does not treat an identity query failure as process exit', () => {
|
||||
const queryError = new Error('transient ps failure')
|
||||
|
||||
expect(() =>
|
||||
processIdentity(41, {
|
||||
executePs: () => {
|
||||
throw queryError
|
||||
},
|
||||
signalProcess: () => {}
|
||||
})
|
||||
).toThrow(queryError)
|
||||
})
|
||||
|
||||
it('resumes a helper when post-stop identity inspection fails', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const signals = []
|
||||
let inspectionCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
if (inspectionCount === 2) {
|
||||
throw new Error('transient ps failure after stop')
|
||||
}
|
||||
return identity
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
})
|
||||
).toThrow('transient ps failure after stop')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('compensates a possible stop after helper PID replacement', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
|
||||
const signals = []
|
||||
let inspectionCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
return inspectionCount === 1 ? identity : replacement
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
})
|
||||
).toThrow('Recorded benchmark helper PID changed before signaling')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves helper identity errors when compensation fails', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
|
||||
let inspectionCount = 0
|
||||
let thrown
|
||||
|
||||
try {
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
return inspectionCount === 1 ? identity : replacement
|
||||
},
|
||||
signalProcess: (_pid, signal) => {
|
||||
if (signal === 'SIGCONT') {
|
||||
throw new Error('resume denied')
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual([
|
||||
'Recorded benchmark helper PID changed before signaling',
|
||||
'resume denied'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a missing PID as process exit after an identity query failure', () => {
|
||||
const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' })
|
||||
|
||||
expect(
|
||||
processIdentity(41, {
|
||||
executePs: () => {
|
||||
throw new Error('ps found no process')
|
||||
},
|
||||
signalProcess: () => {
|
||||
throw missingProcessError
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('runs unique-command cleanup after an invalid process record', async () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const exited = new Promise((resolve) => helper.once('exit', resolve))
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
|
||||
|
||||
expect(() =>
|
||||
killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker])
|
||||
).toThrow('Recorded benchmark helper identity is invalid')
|
||||
await exited
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
|
||||
spawnedPids.delete(helper.pid)
|
||||
})
|
||||
|
||||
it('rejects a record that is not a detached process-group identity', () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-identity-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
|
||||
|
||||
expect(() => killRecordedProcess(recordPath, marker)).toThrow(
|
||||
'Recorded benchmark helper identity is invalid'
|
||||
)
|
||||
expect(() => process.kill(helper.pid, 0)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import {
|
||||
killRecordedAndMatchingProcesses,
|
||||
runBenchmarkCleanupStages,
|
||||
signalValidatedProcessGroup
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
|
||||
export function cleanupOwnerLossTrial(options) {
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let error
|
||||
let output = ''
|
||||
try {
|
||||
runBenchmarkCleanupStages([
|
||||
() => {
|
||||
if (options.failed && Number.isInteger(options.pid) && options.marker) {
|
||||
signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.failed && options.recordPath && options.tempDir) {
|
||||
killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [
|
||||
options.helperPath,
|
||||
options.tempDir
|
||||
])
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.failed && Number.isInteger(options.pid) && options.marker) {
|
||||
signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.stderrDescriptor !== undefined) {
|
||||
closeSync(options.stderrDescriptor)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.stdoutDescriptor !== undefined) {
|
||||
closeSync(options.stdoutDescriptor)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
output = (options.outputPaths ?? [])
|
||||
.filter((outputPath) => outputPath && existsSync(outputPath))
|
||||
.map((outputPath) => readFileSync(outputPath, 'utf8'))
|
||||
.join('')
|
||||
},
|
||||
() => {
|
||||
if (options.launcherDir) {
|
||||
rmSync(options.launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.tempDir) {
|
||||
rmSync(options.tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
])
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
return { error, output }
|
||||
}
|
||||
@@ -297,116 +297,6 @@ describe('Electron runtime package contract', () => {
|
||||
expect(releaseMacWorkflowText).not.toContain('SIGNPATH_')
|
||||
})
|
||||
|
||||
it('preflights SignPath module install before Windows signing side effects', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const parsedWorkflow = parse(releaseWorkflow)
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const installStepIndexes = stepNames.flatMap((name, index) =>
|
||||
name === 'Install SignPath PowerShell module' ? [index] : []
|
||||
)
|
||||
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
|
||||
const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime')
|
||||
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
|
||||
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
|
||||
|
||||
expect(verifyNodePtyIndex).toBe(buildIndex + 1)
|
||||
expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1])
|
||||
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
|
||||
|
||||
expect(steps[verifyNodePtyIndex].run).toContain(
|
||||
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
|
||||
)
|
||||
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
|
||||
|
||||
const uploadThroughDownloadScript = steps
|
||||
.slice(uploadIndex, downloadIndex + 1)
|
||||
.map((step) => step.run ?? '')
|
||||
.join('\n')
|
||||
|
||||
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
|
||||
|
||||
const installStep = steps[installStepIndexes[0]]
|
||||
const installRun = installStep.run
|
||||
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
|
||||
([, seconds]) => seconds
|
||||
)
|
||||
|
||||
expect(installStep.if).toBe("matrix.platform == 'win'")
|
||||
expect(installStep.shell).toBe('pwsh')
|
||||
expect(installRun).toContain(
|
||||
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
|
||||
)
|
||||
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
|
||||
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
|
||||
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
|
||||
expect(installRun).toContain(
|
||||
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
|
||||
)
|
||||
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
|
||||
expect(sleepSeconds).toEqual(['15', '30'])
|
||||
expect(installRun).toContain(
|
||||
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
|
||||
)
|
||||
expect(installRun).toContain('Import-Module SignPath')
|
||||
expect(installRun).toContain(
|
||||
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
|
||||
)
|
||||
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
|
||||
expect(installRun).not.toContain('SignPath*')
|
||||
expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan(
|
||||
installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath')
|
||||
)
|
||||
expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/)
|
||||
expect(installRun).not.toMatch(/throw\s+\$_/)
|
||||
})
|
||||
|
||||
it('verifies Windows inner binary signatures fail-open before publishing', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const parsedWorkflow = parse(releaseWorkflow)
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
|
||||
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
|
||||
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
|
||||
|
||||
expect(outerVerifyIndex).toBeGreaterThan(-1)
|
||||
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
|
||||
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
|
||||
expect(publishIndex).toBe(evidenceIndex + 1)
|
||||
|
||||
// Why fail-open: unsigned inner binaries must warn, not block, until the
|
||||
// flow is proven on a real release (issue #7785). Flip this to 'true'
|
||||
// together with the workflow env to make the gate required.
|
||||
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
|
||||
|
||||
// Why: every step in the inner-signing chain must be unable to fail the
|
||||
// release — a SignPath outage or timeout falls through to today's
|
||||
// unsigned-inner flow instead of blocking the cut.
|
||||
const innerChainStepNames = [
|
||||
'Stage unsigned inner PE files for signing',
|
||||
'Upload unsigned inner binaries for SignPath',
|
||||
'Submit inner binaries signing request',
|
||||
'Notify Slack that inner-binary signing is waiting for approval',
|
||||
'Download signed inner binaries from SignPath',
|
||||
'Restore signed inner binaries into unpacked app',
|
||||
'Replace cached elevate.exe with the signed copy',
|
||||
'Rebuild NSIS installer from signed unpacked app'
|
||||
]
|
||||
for (const stepName of innerChainStepNames) {
|
||||
const step = steps[stepNames.indexOf(stepName)]
|
||||
expect(step, stepName).toBeDefined()
|
||||
expect(step['continue-on-error'], stepName).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes both Linux release matrix entries', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
@@ -597,7 +487,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
|
||||
})
|
||||
|
||||
it('keeps terminal rendering regressions in the fast golden E2E gate', () => {
|
||||
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
|
||||
const packageScripts = packageJson.scripts
|
||||
const goldenWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
|
||||
@@ -621,7 +511,6 @@ describe('Electron runtime package contract', () => {
|
||||
|
||||
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
|
||||
})
|
||||
const pullRequestPaths = goldenWorkflow.on.pull_request.paths
|
||||
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
|
||||
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
|
||||
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
|
||||
@@ -650,11 +539,8 @@ describe('Electron runtime package contract', () => {
|
||||
for (const runStep of goldenRunSteps) {
|
||||
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
}
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
|
||||
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
|
||||
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
|
||||
expect(goldenWorkflow.on.pull_request).toBeUndefined()
|
||||
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence')
|
||||
expect(publishReleaseNeeds).toContain('terminal-rendering-golden')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('packaged hang watchdog worker contract', () => {
|
||||
it('boots the worker from app.asar in PR checks', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const smokeSource = readFileSync(
|
||||
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
|
||||
'utf8'
|
||||
)
|
||||
const smokeStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Smoke packaged hang watchdog worker'
|
||||
)
|
||||
|
||||
expect(smokeStep.run).toBe(
|
||||
'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked'
|
||||
)
|
||||
expect(smokeSource).toContain(
|
||||
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
|
||||
)
|
||||
})
|
||||
|
||||
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
|
||||
it('fails the smoke when the packaged worker never reports success', () => {
|
||||
const smokeSource = readFileSync(
|
||||
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(smokeSource).toContain('app.exit(1)')
|
||||
expect(smokeSource).not.toContain('app.quit()')
|
||||
expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))')
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,10 @@ import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
(step) => step.name === 'Filter E2E-relevant paths'
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
@@ -25,7 +26,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(verifyStep.run).not.toContain('$E2E')
|
||||
})
|
||||
|
||||
it('runs E2E only when the detector says the PR touches E2E paths', () => {
|
||||
it('passes only changed specs to the reusable E2E workflow', () => {
|
||||
// Why: without this the job could lose its filter and run on every PR — the
|
||||
// cost the path filter exists to avoid — while the gate assertions above
|
||||
// stay green.
|
||||
@@ -34,6 +35,10 @@ describe('PR E2E gate contract', () => {
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
|
||||
'${{ steps.filter.outputs.test_files }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
|
||||
})
|
||||
|
||||
it('enforces every job verify depends on', () => {
|
||||
@@ -49,16 +54,41 @@ describe('PR E2E gate contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the Playwright config where it actually lives', () => {
|
||||
// Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather
|
||||
// than inside it. A bare `playwright.` prefix matches no tracked file, so
|
||||
// editing the runner config would silently skip E2E.
|
||||
expect(filterStep.run).toContain('tests/playwright\\.')
|
||||
expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./)
|
||||
it('selects modified Playwright specs without running deleted tests', () => {
|
||||
expect(filterStep.run).toContain('--diff-filter=AMCR')
|
||||
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
|
||||
expect(filterStep.run).not.toContain('tests/playwright\\.')
|
||||
})
|
||||
|
||||
it('uses one runner for changed specs and keeps full runs sharded', () => {
|
||||
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
|
||||
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
|
||||
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
|
||||
(step) => step.name === 'Run changed E2E specs'
|
||||
)
|
||||
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
|
||||
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
|
||||
})
|
||||
|
||||
it('keeps dedicated E2E workflows out of pull request CI', () => {
|
||||
const dedicatedWorkflows = [
|
||||
'golden-e2e-experiment.yml',
|
||||
'linux-wayland-gpu-sandbox.yml',
|
||||
'terminal-ime-e2e.yml',
|
||||
'win-crash-survival-e2e.yml',
|
||||
'windows-terminal-restart-e2e.yml'
|
||||
]
|
||||
|
||||
for (const file of dedicatedWorkflows) {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
expect(workflow.on.pull_request, file).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
|
||||
expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"')
|
||||
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
|
||||
expect(filterStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const testFilePatterns = [
|
||||
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
||||
'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
||||
]
|
||||
const realZshUsage =
|
||||
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
|
||||
@@ -173,11 +173,13 @@ describe('PR workflow parallelism', () => {
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'static_analysis',
|
||||
'root_directory_guard',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'package'
|
||||
'package',
|
||||
'package_windows'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('release-cut SignPath Slack approval pings', () => {
|
||||
it('includes cut source ref/commit and who triggered the cut', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
|
||||
)
|
||||
|
||||
const cutOutputs = workflow.jobs.cut.outputs
|
||||
expect(cutOutputs.source_ref).toContain('steps.resolve.outputs.ref')
|
||||
expect(cutOutputs.source_sha).toContain('steps.resolve.outputs.sha')
|
||||
expect(cutOutputs.source_short_sha).toContain('steps.resolve.outputs.short_sha')
|
||||
|
||||
const steps = workflow.jobs.build.steps
|
||||
const notifySteps = steps.filter(
|
||||
(step) =>
|
||||
step.name === 'Notify Slack that inner-binary signing is waiting for approval' ||
|
||||
step.name === 'Notify Slack that Windows signing is waiting for approval'
|
||||
)
|
||||
expect(notifySteps).toHaveLength(2)
|
||||
|
||||
for (const step of notifySteps) {
|
||||
expect(step.env.SOURCE_REF).toContain('needs.cut.outputs.source_ref')
|
||||
expect(step.env.SOURCE_SHA).toContain('needs.cut.outputs.source_sha')
|
||||
expect(step.env.SOURCE_SHORT_SHA).toContain('needs.cut.outputs.source_short_sha')
|
||||
expect(step.env.CUT_BY).toMatch(/github\.(triggering_actor|actor)/)
|
||||
expect(step.run).toContain('Source:')
|
||||
expect(step.run).toContain('cut by')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => {
|
||||
expect(refInput.type).toBe('string')
|
||||
expect(refInput.required).toBe(false)
|
||||
})
|
||||
|
||||
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
|
||||
const buildStep = e2eWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Build Electron app for E2E'
|
||||
)
|
||||
|
||||
expect(buildStep.run).toContain('electron-vite build --mode e2e')
|
||||
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
|
||||
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getDefaultUserDataPath } from '../../src/cli/runtime/metadata'
|
||||
import type { PairingOffer } from '../../src/shared/pairing'
|
||||
import { RemoteRuntimeSharedControlConnection } from '../../src/shared/remote-runtime-shared-control-connection'
|
||||
import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
if (!environmentName) {
|
||||
throw new Error('ORCA_PROBE_ENVIRONMENT_NAME is required')
|
||||
}
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const environment = resolveEnvironment(userDataPath, environmentName)
|
||||
const pairing = resolveEnvironmentPairingOffer(userDataPath, environment.id)
|
||||
const cycles = readProbeInteger('ORCA_PROBE_CYCLES', 10, 100)
|
||||
const concurrency = readProbeInteger('ORCA_PROBE_CONCURRENCY', 25, 200)
|
||||
const settleMs = readProbeInteger('ORCA_PROBE_SETTLE_MS', 250, 5_000)
|
||||
const cleanupTimeoutMs = readProbeInteger('ORCA_PROBE_CLEANUP_TIMEOUT_MS', 10_000, 30_000)
|
||||
const unknownResponses = new Map<string, number>()
|
||||
const originalWarn = console.warn
|
||||
console.warn = (message?: unknown, details?: unknown): void => {
|
||||
if (
|
||||
message === '[remote-runtime.shared-control] unknown response id' &&
|
||||
typeof details === 'object' &&
|
||||
details !== null
|
||||
) {
|
||||
const responseId = String((details as { responseId?: unknown }).responseId ?? 'unknown')
|
||||
unknownResponses.set(responseId, (unknownResponses.get(responseId) ?? 0) + 1)
|
||||
return
|
||||
}
|
||||
originalWarn(message, details)
|
||||
}
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const before = await requestMemorySnapshot(pairing, environment.id)
|
||||
let ok = 0
|
||||
let subscriptionResponses = 0
|
||||
let runtimeStatus: RuntimeStatus | null = null
|
||||
const cleanupDurationsMs: number[] = []
|
||||
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
||||
const result = await runCycle({
|
||||
pairing,
|
||||
environmentId: environment.id,
|
||||
concurrency,
|
||||
settleMs,
|
||||
cleanupTimeoutMs
|
||||
})
|
||||
ok += result.ok
|
||||
subscriptionResponses += result.subscriptionResponses
|
||||
runtimeStatus ??= result.runtimeStatus
|
||||
cleanupDurationsMs.push(result.cleanupDurationMs)
|
||||
}
|
||||
await wait(settleMs)
|
||||
const after = await requestMemorySnapshot(pairing, environment.id)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
environment: { id: environment.id, name: environment.name },
|
||||
runtime: runtimeStatus
|
||||
? {
|
||||
appVersion: runtimeStatus.appVersion ?? null,
|
||||
capabilities: runtimeStatus.capabilities ?? [],
|
||||
hostPlatform: runtimeStatus.hostPlatform ?? null
|
||||
}
|
||||
: null,
|
||||
cycles,
|
||||
concurrency,
|
||||
requests: cycles * concurrency,
|
||||
ok,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs: {
|
||||
average: Math.round(
|
||||
cleanupDurationsMs.reduce((total, duration) => total + duration, 0) /
|
||||
cleanupDurationsMs.length
|
||||
),
|
||||
maximum: Math.max(...cleanupDurationsMs)
|
||||
},
|
||||
unknownResponseFrames: Array.from(unknownResponses.values()).reduce(
|
||||
(total, count) => total + count,
|
||||
0
|
||||
),
|
||||
unknownResponseIds: unknownResponses.size,
|
||||
memory: {
|
||||
before: summarizeMemory(before),
|
||||
after: summarizeMemory(after),
|
||||
appDelta: after.app.memory - before.app.memory
|
||||
},
|
||||
elapsedMs: Date.now() - startedAt
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
}
|
||||
}
|
||||
|
||||
async function runCycle(args: {
|
||||
pairing: PairingOffer
|
||||
environmentId: string
|
||||
concurrency: number
|
||||
settleMs: number
|
||||
cleanupTimeoutMs: number
|
||||
}): Promise<{
|
||||
ok: number
|
||||
subscriptionResponses: number
|
||||
cleanupDurationMs: number
|
||||
runtimeStatus: RuntimeStatus | null
|
||||
}> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(args.pairing, {
|
||||
environmentId: args.environmentId
|
||||
})
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: args.concurrency }, () =>
|
||||
connection.request<RuntimeStatus>('status.get', undefined, 10_000)
|
||||
)
|
||||
)
|
||||
const runtimeStatus = responses.find((response) => response.ok)
|
||||
let subscriptionResponses = 0
|
||||
const subscriptions = await Promise.all([
|
||||
connection.subscribe('runtime.clientEvents.subscribe', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
}),
|
||||
connection.subscribe('session.tabs.subscribeAll', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
})
|
||||
])
|
||||
await wait(args.settleMs)
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.close()
|
||||
}
|
||||
const cleanupDurationMs = await waitForConnectionIdle(connection, args.cleanupTimeoutMs)
|
||||
// Let cleanup replies reach the retirement cache before closing the socket.
|
||||
await wait(args.settleMs)
|
||||
return {
|
||||
ok: responses.filter((response) => response.ok).length,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs,
|
||||
runtimeStatus: runtimeStatus?.ok === true ? runtimeStatus.result : null
|
||||
}
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionIdle(
|
||||
connection: RemoteRuntimeSharedControlConnection,
|
||||
timeoutMs: number
|
||||
): Promise<number> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const diagnostics = connection.getDiagnostics()
|
||||
if (diagnostics.pendingRequestCount === 0 && diagnostics.subscriptionCount === 0) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await wait(25)
|
||||
}
|
||||
throw new Error(`Cycle did not settle: ${JSON.stringify(connection.getDiagnostics())}`)
|
||||
}
|
||||
|
||||
async function requestMemorySnapshot(
|
||||
pairing: PairingOffer,
|
||||
environmentId: string
|
||||
): Promise<MemorySnapshot> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId })
|
||||
try {
|
||||
const response = await connection.request<MemorySnapshot>(
|
||||
'diagnostics.memory',
|
||||
undefined,
|
||||
20_000
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Memory snapshot failed: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: MemorySnapshot['app']
|
||||
host: MemorySnapshot['host']
|
||||
processMemoryMetric: MemorySnapshot['processMemoryMetric']
|
||||
totalCpu: number
|
||||
totalMemory: number
|
||||
worktreeCount: number
|
||||
sessionCount: number
|
||||
worktreeMemory: number
|
||||
topWorktrees: {
|
||||
worktreeName: string
|
||||
repoName: string
|
||||
cpu: number
|
||||
memory: number
|
||||
sessionCount: number
|
||||
topSessions: { pid: number; cpu: number; memory: number }[]
|
||||
}[]
|
||||
} {
|
||||
return {
|
||||
app: snapshot.app,
|
||||
host: snapshot.host,
|
||||
processMemoryMetric: snapshot.processMemoryMetric,
|
||||
totalCpu: snapshot.totalCpu,
|
||||
totalMemory: snapshot.totalMemory,
|
||||
worktreeCount: snapshot.worktrees.length,
|
||||
sessionCount: snapshot.worktrees.reduce(
|
||||
(total, worktree) => total + worktree.sessions.length,
|
||||
0
|
||||
),
|
||||
worktreeMemory: snapshot.worktrees.reduce((total, worktree) => total + worktree.memory, 0),
|
||||
topWorktrees: [...snapshot.worktrees]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 10)
|
||||
.map((worktree) => ({
|
||||
worktreeName: worktree.worktreeName,
|
||||
repoName: worktree.repoName,
|
||||
cpu: worktree.cpu,
|
||||
memory: worktree.memory,
|
||||
sessionCount: worktree.sessions.length,
|
||||
topSessions: [...worktree.sessions]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 5)
|
||||
.map((session) => ({
|
||||
pid: session.pid,
|
||||
cpu: session.cpu,
|
||||
memory: session.memory
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function readProbeInteger(name: string, fallback: number, maximum: number): number {
|
||||
const value = process.env[name]
|
||||
if (value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) {
|
||||
throw new Error(`${name} must be an integer from 1 through ${maximum}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
* pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \
|
||||
* --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix]
|
||||
*
|
||||
* Results land in tools/benchmarks/results/multi-workspace-typing-*.json.
|
||||
* Results land in tests/tools/benchmarks/results/multi-workspace-typing-*.json.
|
||||
* Run once per build/config with distinct --label values, then diff the
|
||||
* totalMs/inputHalfMs/echoHalfMs percentiles.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const defaultFiles = [
|
||||
'src/main/ssh/sftp-upload.test.ts',
|
||||
'src/main/ssh/ssh-file-transfer-abort.test.ts',
|
||||
'src/main/ssh/ssh-relay-deploy-staged-upload.test.ts',
|
||||
'src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts',
|
||||
'src/main/ssh/ssh-relay-sftp-namespace-install.test.ts',
|
||||
'src/main/ssh/ssh-relay-install-namespace.test.ts',
|
||||
'src/main/ssh/ssh-relay-upload-stage-commands.test.ts',
|
||||
'src/main/ssh/sftp-namespace-resolution.test.ts',
|
||||
'src/main/ssh/ssh-connection-sftp-wire.test.ts',
|
||||
'src/main/ssh/ssh-remote-commands.test.ts',
|
||||
'src/main/ssh/ssh-relay-cross-version-isolation.test.ts'
|
||||
]
|
||||
|
||||
const cliArguments = process.argv.slice(2)
|
||||
const powerShellFlag = cliArguments.indexOf('--powershell')
|
||||
const configuredPowerShell =
|
||||
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
|
||||
if (powerShellFlag >= 0 && !configuredPowerShell) {
|
||||
console.error('--powershell requires an executable path')
|
||||
process.exit(2)
|
||||
}
|
||||
const powerShellExecutable = [
|
||||
configuredPowerShell,
|
||||
...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'])
|
||||
].find((candidate) => {
|
||||
if (!candidate) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
spawnSync(
|
||||
candidate,
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
|
||||
{ encoding: 'utf8' }
|
||||
).status === 0
|
||||
)
|
||||
})
|
||||
if (!powerShellExecutable) {
|
||||
console.error('A native PowerShell executable is required')
|
||||
process.exit(2)
|
||||
}
|
||||
const powerShellVersion = spawnSync(
|
||||
powerShellExecutable,
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
|
||||
{ encoding: 'utf8' }
|
||||
).stdout.trim()
|
||||
console.log(`SSH staged-upload reliability: PowerShell ${powerShellVersion}`)
|
||||
const requestedFiles = cliArguments.filter(
|
||||
(_argument, index) => index !== powerShellFlag && index !== powerShellFlag + 1
|
||||
)
|
||||
const files = requestedFiles.length > 0 ? requestedFiles : defaultFiles
|
||||
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'config/vitest.config.ts',
|
||||
...files,
|
||||
'--maxWorkers=1',
|
||||
'--reporter=dot'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_POWERSHELL_EXECUTABLE: powerShellExecutable
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Provisions the credentials hourly-mac-build.yml uses to publish into
|
||||
# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo
|
||||
# running the workflow, and hourly artifacts are published to a different one.
|
||||
#
|
||||
# A GitHub App is used rather than a PAT because its private key does not expire
|
||||
# — no yearly rotation — and it belongs to the org rather than to the person who
|
||||
# created it, so it survives that person leaving.
|
||||
#
|
||||
# The key is read from a file and piped straight into `gh secret set`. It is never
|
||||
# echoed, never passed as a command-line argument (argv is world-readable via
|
||||
# `ps`), and never copied anywhere on disk.
|
||||
#
|
||||
# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Guard: xtrace would echo the key to stderr on every expansion. Test before
|
||||
# disabling, or the check reads the state this line just cleared and never fires.
|
||||
if [[ -o xtrace ]]; then
|
||||
echo "Refusing to run with xtrace enabled; it would echo the private key." >&2
|
||||
exit 1
|
||||
fi
|
||||
set +x
|
||||
|
||||
MAIN_REPO="stablyai/orca"
|
||||
HOURLY_REPO="stablyai/orca-hourly"
|
||||
APP_ID_SECRET="HOURLY_RELEASE_APP_ID"
|
||||
APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY"
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
|
||||
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
|
||||
|
||||
# Setting repo secrets requires admin; check before asking for anything.
|
||||
if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then
|
||||
fail "You need admin on $MAIN_REPO to set repository secrets."
|
||||
fi
|
||||
gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 ||
|
||||
fail "$HOURLY_REPO does not exist or you cannot see it."
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Create a GitHub App (one time — the key never expires)
|
||||
──────────────────────────────────────────────────────
|
||||
1. Open: https://github.com/organizations/stablyai/settings/apps/new
|
||||
2. Name .................. orca-hourly-release
|
||||
Homepage URL .......... https://github.com/$HOURLY_REPO
|
||||
Webhook ............... UNCHECK "Active"
|
||||
3. Repository permissions -> Contents: Read and write
|
||||
(leave everything else alone)
|
||||
4. "Where can this app be installed?" -> Only on this account
|
||||
5. Create, then note the App ID shown at the top of the page.
|
||||
6. Generate a private key (bottom of the page) — a .pem downloads.
|
||||
7. Install App -> Only select repositories -> $HOURLY_REPO
|
||||
|
||||
EOF
|
||||
|
||||
read -rp "App ID (numeric): " APP_ID
|
||||
[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-<empty>}"
|
||||
|
||||
KEY_PATH="${1:-}"
|
||||
if [[ -z "$KEY_PATH" ]]; then
|
||||
read -rp "Path to the downloaded .pem: " KEY_PATH
|
||||
fi
|
||||
# Expand a leading ~ so a pasted path works without quoting rules.
|
||||
KEY_PATH="${KEY_PATH/#\~/$HOME}"
|
||||
[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH"
|
||||
grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" ||
|
||||
fail "$KEY_PATH does not look like a PEM private key."
|
||||
|
||||
echo "Storing $APP_ID_SECRET in $MAIN_REPO..."
|
||||
printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" ||
|
||||
fail "Could not set $APP_ID_SECRET."
|
||||
|
||||
# Piped on stdin so the key never appears in argv or in shell history.
|
||||
echo "Storing $APP_KEY_SECRET in $MAIN_REPO..."
|
||||
gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" ||
|
||||
fail "Could not set $APP_KEY_SECRET."
|
||||
|
||||
echo
|
||||
echo "Done. Both secrets are set on $MAIN_REPO."
|
||||
echo
|
||||
echo "Delete your local copy of the key — the workflow reads it from the secret,"
|
||||
echo "and a .pem sitting in ~/Downloads is a standing credential:"
|
||||
echo " rm '$KEY_PATH'"
|
||||
echo
|
||||
echo "Smoke-test the pipeline without waiting for the hour (after this merges):"
|
||||
echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true"
|
||||
echo " gh run watch --repo $MAIN_REPO"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
|
||||
describe('packaged skills CLI PR gates', () => {
|
||||
it('builds and executes the Windows packaged CLI', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const buildStep = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
|
||||
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(smokeStep.run).toBe(
|
||||
'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked'
|
||||
)
|
||||
|
||||
const aggregateStep = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
|
||||
expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -34,13 +35,70 @@ const appDir = resolve(readAppDirArg(process.argv.slice(2)))
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-packaged-cli-smoke-'))
|
||||
const copiedAppDir = join(tempRoot, basename(appDir))
|
||||
|
||||
let smokeFailure = null
|
||||
try {
|
||||
await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true })
|
||||
const cliPath = getPackagedCliPath(copiedAppDir)
|
||||
await execFileAsync(cliPath, ['--help'], {
|
||||
env: { ...process.env, NODE_PATH: '' }
|
||||
})
|
||||
console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`)
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
const env = { ...process.env, NODE_PATH: '' }
|
||||
delete env.ORCA_CLI_CWD
|
||||
const run = (args) =>
|
||||
execFileAsync(cliPath, args, {
|
||||
env,
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
await run(['--help'])
|
||||
const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout)
|
||||
assert(list.topics.some((topic) => topic.name === 'orca-cli'))
|
||||
assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/)
|
||||
assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/)
|
||||
const install = JSON.parse(
|
||||
(
|
||||
await run([
|
||||
'skills',
|
||||
'install',
|
||||
'--skill',
|
||||
'orca-cli',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--dry-run',
|
||||
'--json'
|
||||
])
|
||||
).stdout
|
||||
)
|
||||
const update = JSON.parse(
|
||||
(await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout
|
||||
)
|
||||
assert.equal(install.executed, false)
|
||||
assert.equal(update.executed, false)
|
||||
console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`)
|
||||
} catch (error) {
|
||||
smokeFailure = error
|
||||
}
|
||||
|
||||
// Why: on Windows the launcher above spawns the copied Orca.exe (and its crashpad/utility children)
|
||||
// once per command; those handles can outlive execFile's exit by a few ms, so this cleanup hits
|
||||
// EBUSY on our own just-exited process after every assertion already passed. Same retry treatment
|
||||
// as removeHostTree(); a lock that never clears still throws — unless the smoke run itself failed,
|
||||
// in which case surfacing EBUSY instead of the real assertion would hide the actual regression.
|
||||
const cleanupFailure = await rm(tempRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
retryDelay: 250
|
||||
}).then(
|
||||
() => null,
|
||||
(error) => error
|
||||
)
|
||||
|
||||
if (smokeFailure) {
|
||||
if (cleanupFailure) {
|
||||
console.warn(`[packaged-cli-smoke] temp cleanup failed: ${cleanupFailure.message}`)
|
||||
}
|
||||
throw smokeFailure
|
||||
}
|
||||
if (cleanupFailure) {
|
||||
throw cleanupFailure
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL'
|
||||
const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR'
|
||||
const TIMEOUT_MS = 100
|
||||
const CHECK_INTERVAL_MS = 20
|
||||
const POLL_TIMEOUT_MS = 5_000
|
||||
const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall'
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
||||
}
|
||||
|
||||
function readAppDirArg(argv) {
|
||||
const explicit = argv.find((arg) => arg.startsWith('--app-dir='))
|
||||
if (explicit) {
|
||||
return explicit.slice('--app-dir='.length)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return 'dist/mac-arm64/Orca.app'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'dist/win-unpacked'
|
||||
}
|
||||
return 'dist/linux-unpacked'
|
||||
}
|
||||
|
||||
function getResourcesDir(appDir) {
|
||||
return process.platform === 'darwin' || appDir.endsWith('.app')
|
||||
? join(appDir, 'Contents', 'Resources')
|
||||
: join(appDir, 'resources')
|
||||
}
|
||||
|
||||
function readMarker(markerPath) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMarker(markerPath, predicate, workerError) {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (workerError.current) {
|
||||
throw workerError.current
|
||||
}
|
||||
const marker = readMarker(markerPath)
|
||||
if (predicate(marker)) {
|
||||
return marker
|
||||
}
|
||||
await sleep(CHECK_INTERVAL_MS)
|
||||
}
|
||||
throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`)
|
||||
}
|
||||
|
||||
async function waitForExit(worker, workerError) {
|
||||
const exitCode = await Promise.race([
|
||||
new Promise((resolveExit) => worker.once('exit', resolveExit)),
|
||||
sleep(POLL_TIMEOUT_MS).then(() => {
|
||||
throw new Error('Timed out waiting for packaged watchdog worker to exit')
|
||||
})
|
||||
])
|
||||
if (workerError.current) {
|
||||
throw workerError.current
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Packaged watchdog worker exited with code ${exitCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternal() {
|
||||
const appAsar = process.env[ASAR_ENV]
|
||||
if (!appAsar) {
|
||||
throw new Error(`Missing ${ASAR_ENV}`)
|
||||
}
|
||||
const { app } = await import('electron')
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-'))
|
||||
const markerPath = join(tempRoot, 'main-thread-hang.json')
|
||||
const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
let worker
|
||||
try {
|
||||
await app.whenReady()
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`)
|
||||
}
|
||||
const workerError = { current: null }
|
||||
worker = new Worker(entryPath, {
|
||||
workerData: {
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
checkIntervalMs: CHECK_INTERVAL_MS
|
||||
}
|
||||
})
|
||||
worker.once('error', (error) => {
|
||||
workerError.current = error
|
||||
})
|
||||
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError)
|
||||
worker.postMessage({ type: 'heartbeat' })
|
||||
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError)
|
||||
worker.postMessage({ type: 'shutdown' })
|
||||
await waitForExit(worker, workerError)
|
||||
worker = undefined
|
||||
console.log(SUCCESS_LINE)
|
||||
} finally {
|
||||
await worker?.terminate()
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function runSmoke() {
|
||||
const appDir = resolve(readAppDirArg(process.argv.slice(2)))
|
||||
const appAsar = join(getResourcesDir(appDir), 'app.asar')
|
||||
if (!existsSync(appAsar)) {
|
||||
throw new Error(`Packaged app archive is missing: ${appAsar}`)
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
const executable = require('electron')
|
||||
const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-'))
|
||||
const launcherPath = join(launcherDir, 'main.cjs')
|
||||
writeFileSync(
|
||||
join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
launcherPath,
|
||||
`import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
try {
|
||||
const electronArgs =
|
||||
process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]
|
||||
const result = spawnSync(executable, electronArgs, {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
|
||||
`${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
// Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran.
|
||||
if (!result.stdout.includes(SUCCESS_LINE)) {
|
||||
throw new Error(
|
||||
`Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
process.stdout.write(result.stdout)
|
||||
} finally {
|
||||
rmSync(launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
// Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure.
|
||||
const { app } = await import('electron')
|
||||
try {
|
||||
await runInternal()
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
}
|
||||
} else {
|
||||
runSmoke()
|
||||
}
|
||||
@@ -10,24 +10,10 @@ describe('terminal IME e2e workflow', () => {
|
||||
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('runs for xterm patch and terminal IME regression changes', () => {
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-ime-*',
|
||||
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
|
||||
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'tests/e2e/terminal-ime-*.ts'
|
||||
])
|
||||
)
|
||||
it('runs only on schedule or manual dispatch', () => {
|
||||
expect(workflow.on.pull_request).toBeUndefined()
|
||||
expect(workflow.on.workflow_dispatch).toBeNull()
|
||||
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
|
||||
@@ -82,13 +82,17 @@ async function extractToTemporaryCatalog(root, tempDir) {
|
||||
const outputPattern = path.join(tempDir, '{{language}}.json')
|
||||
// Why: extraction output is evidence for this check, not another committed
|
||||
// catalog that feature authors must keep synchronized.
|
||||
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
const { existsSync, readFileSync, realpathSync } = require('node:fs')
|
||||
const { builtinModules, createRequire, isBuiltin } = require('node:module')
|
||||
const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const ts = require('typescript-api')
|
||||
|
||||
const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))
|
||||
const CLI_COMMAND_TIMEOUT_MS = 30_000
|
||||
|
||||
function artifactPath(outDir, file) {
|
||||
return relative(outDir, file).split(sep).join('/')
|
||||
}
|
||||
|
||||
function runtimeImportSpecifiers(source, file) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
false,
|
||||
ts.ScriptKind.JS
|
||||
)
|
||||
const specifiers = []
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isCallExpression(node) && node.arguments.length > 0) {
|
||||
const [argument] = node.arguments
|
||||
const expression = node.expression
|
||||
const isRequire = ts.isIdentifier(expression) && expression.text === 'require'
|
||||
const isRequireResolve =
|
||||
ts.isPropertyAccessExpression(expression) &&
|
||||
ts.isIdentifier(expression.expression) &&
|
||||
expression.expression.text === 'require' &&
|
||||
expression.name.text === 'resolve'
|
||||
const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|
||||
if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) {
|
||||
specifiers.push(argument.text)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return specifiers
|
||||
}
|
||||
|
||||
function isOutsideRoot(root, target) {
|
||||
const pathFromRoot = relative(root, target)
|
||||
return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`)
|
||||
}
|
||||
|
||||
function isOptionalPackageImport(artifactRoot, importer, specifier) {
|
||||
if (specifier.startsWith('.') || isAbsolute(specifier)) {
|
||||
return false
|
||||
}
|
||||
const segments = specifier.split('/')
|
||||
const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
|
||||
let directory = realpathSync(dirname(importer))
|
||||
|
||||
while (!isOutsideRoot(artifactRoot, directory)) {
|
||||
const packageJson = join(directory, 'package.json')
|
||||
if (existsSync(packageJson)) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(packageJson, 'utf8'))
|
||||
return (
|
||||
Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) ||
|
||||
manifest.peerDependenciesMeta?.[packageName]?.optional === true
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (directory === artifactRoot) {
|
||||
break
|
||||
}
|
||||
directory = dirname(directory)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) {
|
||||
if (BUILTINS.has(specifier) || isBuiltin(specifier)) {
|
||||
return null
|
||||
}
|
||||
let resolved
|
||||
try {
|
||||
resolved = createRequire(importer).resolve(specifier)
|
||||
} catch (error) {
|
||||
if (isOptionalPackageImport(artifactRoot, importer, specifier)) {
|
||||
return null
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` +
|
||||
`${artifactPath(outDir, importer)}: ${detail}`
|
||||
)
|
||||
}
|
||||
if (isOutsideRoot(artifactRoot, resolved)) {
|
||||
throw new Error(
|
||||
`[verify-skills-cli-runtime] import "${specifier}" from ` +
|
||||
`${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}`
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) {
|
||||
outDir = realpathSync(outDir)
|
||||
artifactRoot = realpathSync(artifactRoot)
|
||||
if (isOutsideRoot(artifactRoot, outDir)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`)
|
||||
}
|
||||
const entry = resolve(outDir, 'cli', 'index.js')
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`)
|
||||
}
|
||||
const pending = [entry]
|
||||
const visited = new Set()
|
||||
|
||||
while (pending.length > 0) {
|
||||
const file = pending.pop()
|
||||
if (!file || visited.has(file)) {
|
||||
continue
|
||||
}
|
||||
visited.add(file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
for (const specifier of runtimeImportSpecifiers(source, file)) {
|
||||
const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier)
|
||||
if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) {
|
||||
pending.push(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...visited].sort()
|
||||
}
|
||||
|
||||
function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) {
|
||||
const entry = resolve(outDir, 'cli', 'index.js')
|
||||
const env = { ...process.env, NODE_PATH: '' }
|
||||
delete env.ORCA_CLI_CWD
|
||||
const result = spawnSync(process.execPath, [entry, ...args], {
|
||||
cwd: dirname(outDir),
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: timeoutMs
|
||||
})
|
||||
if (result.error || result.signal || result.status !== 0) {
|
||||
const detail = [
|
||||
result.error?.message,
|
||||
result.signal ? `terminated by ${result.signal}` : null,
|
||||
result.stdout,
|
||||
result.stderr
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
throw new Error(
|
||||
`[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}`
|
||||
)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function parseJson(label, output) {
|
||||
try {
|
||||
return JSON.parse(output)
|
||||
} catch {
|
||||
throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`)
|
||||
}
|
||||
}
|
||||
|
||||
function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) {
|
||||
const absoluteOutDir = resolve(outDir)
|
||||
const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot))
|
||||
if (options.executeCommands === false) {
|
||||
return { closureFiles: closure.length, commands: 0 }
|
||||
}
|
||||
const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json']))
|
||||
const topicNames = new Set(list.topics?.map((topic) => topic.name))
|
||||
for (const topic of ['orca-cli', 'computer-use']) {
|
||||
if (!topicNames.has(topic)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`)
|
||||
}
|
||||
const guide = runCli(absoluteOutDir, ['skills', 'get', topic])
|
||||
if (!guide.includes(`name: ${topic}`)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`)
|
||||
}
|
||||
}
|
||||
|
||||
const install = parseJson(
|
||||
'skills install --dry-run',
|
||||
runCli(absoluteOutDir, [
|
||||
'skills',
|
||||
'install',
|
||||
'--skill',
|
||||
'orca-cli',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--dry-run',
|
||||
'--json'
|
||||
])
|
||||
)
|
||||
const update = parseJson(
|
||||
'skills update --dry-run',
|
||||
runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])
|
||||
)
|
||||
if (install.executed !== false || update.executed !== false) {
|
||||
throw new Error('[verify-skills-cli-runtime] a dry-run reported execution')
|
||||
}
|
||||
|
||||
return { closureFiles: closure.length, commands: 5 }
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const result = verifySkillsCliRuntime(process.argv[2] ?? 'out')
|
||||
console.log(
|
||||
`[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` +
|
||||
`${result.commands} commands passed`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime }
|
||||
@@ -0,0 +1,230 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
collectRuntimeClosure,
|
||||
runCli,
|
||||
verifySkillsCliRuntime
|
||||
} = require('./verify-skills-cli-runtime.cjs')
|
||||
|
||||
async function writeSkillsCliFixture(outDir, handlerSource) {
|
||||
const cliDir = join(outDir, 'cli')
|
||||
const handlerDir = join(cliDir, 'handlers')
|
||||
await mkdir(handlerDir, { recursive: true })
|
||||
await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8')
|
||||
await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8')
|
||||
}
|
||||
|
||||
describe('skills CLI runtime closure', () => {
|
||||
it('runs after Electron composes the final output', async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL('../../package.json', import.meta.url), 'utf8')
|
||||
)
|
||||
for (const scriptName of ['build:desktop', 'build:release']) {
|
||||
const script = packageJson.scripts[scriptName]
|
||||
expect(script.indexOf('build:electron-vite')).toBeLessThan(
|
||||
script.indexOf('verify:built-skills-cli')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the missing final-artifact import and its owner', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n")
|
||||
|
||||
expect(() => collectRuntimeClosure(root)).toThrow(
|
||||
/missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('walks static and dynamic relative imports', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const sharedDir = join(root, 'shared')
|
||||
await mkdir(sharedDir, { recursive: true })
|
||||
await writeSkillsCliFixture(
|
||||
root,
|
||||
"require('../../shared/first.js'); import('../../shared/second.js')\n"
|
||||
)
|
||||
await writeFile(join(sharedDir, 'first.js'), '', 'utf8')
|
||||
await writeFile(join(sharedDir, 'second.js'), '', 'utf8')
|
||||
|
||||
expect(
|
||||
collectRuntimeClosure(root)
|
||||
.map((file) => relative(realpathSync(root), file))
|
||||
.sort()
|
||||
).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores import-shaped text in comments and strings', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(
|
||||
root,
|
||||
[
|
||||
"// require('../../missing-comment.js')",
|
||||
'const message = "import(\'../../missing-string.js\')"',
|
||||
"const template = `require.resolve('../../missing-template.js')`"
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(collectRuntimeClosure(root)).toHaveLength(2)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('can inspect a cross-arch artifact without executing it', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(root, '')
|
||||
|
||||
expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({
|
||||
closureFiles: 2,
|
||||
commands: 0
|
||||
})
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds command execution time', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n')
|
||||
|
||||
expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bare imports resolved outside the artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const externalPackageDir = join(root, 'node_modules', 'external-package')
|
||||
await mkdir(externalPackageDir, { recursive: true })
|
||||
await writeSkillsCliFixture(outDir, "require('external-package')\n")
|
||||
await writeFile(
|
||||
join(externalPackageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
|
||||
|
||||
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
|
||||
/external-package.*resolved outside/s
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects package dependencies resolved outside the artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
|
||||
const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency')
|
||||
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await mkdir(externalPackageDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8')
|
||||
await writeFile(
|
||||
join(externalPackageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
|
||||
|
||||
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
|
||||
/ancestor-dependency.*resolved outside/s
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows absent dependencies declared optional by their package', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
|
||||
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
main: 'index.js',
|
||||
peerDependencies: { 'optional-native': '*' },
|
||||
peerDependenciesMeta: { 'optional-native': { optional: true } }
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(
|
||||
join(packageDir, 'index.js'),
|
||||
"try { require('optional-native') } catch {}\n",
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects optional dependencies resolved only outside the artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
|
||||
const externalPackageDir = join(root, 'node_modules', 'optional-native')
|
||||
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await mkdir(externalPackageDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
main: 'index.js',
|
||||
optionalDependencies: { 'optional-native': '*' }
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8')
|
||||
await writeFile(
|
||||
join(externalPackageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
|
||||
|
||||
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
|
||||
/optional-native.*resolved outside/s
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseArgs } from '../../tools/win-crash-survival-e2e/cli-args.mjs'
|
||||
import { buildCrashAssertions } from '../../tools/win-crash-survival-e2e/crash-assertions.mjs'
|
||||
import { scanPwshFailFast } from '../../tools/win-crash-survival-e2e/crash-step.mjs'
|
||||
import { selectScopedDaemon } from '../../tools/win-crash-survival-e2e/daemon-identity.mjs'
|
||||
import { parseArgs } from '../../tests/tools/win-crash-survival-e2e/cli-args.mjs'
|
||||
import { buildCrashAssertions } from '../../tests/tools/win-crash-survival-e2e/crash-assertions.mjs'
|
||||
import { scanPwshFailFast } from '../../tests/tools/win-crash-survival-e2e/crash-step.mjs'
|
||||
import { selectScopedDaemon } from '../../tests/tools/win-crash-survival-e2e/daemon-identity.mjs'
|
||||
import {
|
||||
reattachSentinelMatches,
|
||||
selectCreatedTabId
|
||||
} from '../../tools/win-crash-survival-e2e/reattach-proof.mjs'
|
||||
import { quotePowerShellLiteral } from '../../tools/win-update-e2e/powershell-runner.mjs'
|
||||
import { closeApp, resolveElectronMainPid } from '../../tools/win-update-e2e/app-driver.mjs'
|
||||
import { isPidAlive } from '../../tools/win-update-e2e/daemon-processes.mjs'
|
||||
} from '../../tests/tools/win-crash-survival-e2e/reattach-proof.mjs'
|
||||
import { quotePowerShellLiteral } from '../../tests/tools/win-update-e2e/powershell-runner.mjs'
|
||||
import { closeApp, resolveElectronMainPid } from '../../tests/tools/win-update-e2e/app-driver.mjs'
|
||||
import { isPidAlive } from '../../tests/tools/win-update-e2e/daemon-processes.mjs'
|
||||
|
||||
describe('win-crash-survival-e2e proof contracts', () => {
|
||||
it('keeps the packaged proof wired as a targeted pull-request gate', () => {
|
||||
it('keeps the packaged proof manually dispatchable without a PR trigger', () => {
|
||||
const workflow = readFileSync('.github/workflows/win-crash-survival-e2e.yml', 'utf8')
|
||||
expect(workflow).toMatch(/^ pull_request:/m)
|
||||
expect(workflow).not.toMatch(/^ pull_request:/m)
|
||||
expect(workflow).toMatch(/^ workflow_dispatch:/m)
|
||||
expect(workflow).not.toMatch(/^ push:/m)
|
||||
expect(workflow).toContain("- 'src/main/daemon/**'")
|
||||
expect(workflow).toContain("- 'src/main/index.ts'")
|
||||
expect(workflow).toContain("- 'src/main/ipc/pty*.ts'")
|
||||
expect(workflow).toContain("- 'src/main/startup/first-window-startup-services.ts'")
|
||||
expect(workflow).toContain("- 'src/main/window/attach-main-window-services.ts'")
|
||||
expect(workflow).toContain("- 'src/preload/**'")
|
||||
expect(workflow).toContain("- 'src/renderer/src/components/terminal-pane/**'")
|
||||
expect(workflow).toContain("- 'src/renderer/src/store/slices/terminals.ts'")
|
||||
expect(workflow).toContain("- '!src/**/*.test.*'")
|
||||
expect(workflow).toContain("- '!src/**/*.bench.*'")
|
||||
expect(workflow).toContain('--expect "$env:EXPECT"')
|
||||
expect(workflow).toContain('exit $LASTEXITCODE')
|
||||
expect(workflow).toContain("'!config/**/*.test.*'")
|
||||
@@ -68,7 +59,7 @@ describe('win-crash-survival-e2e proof contracts', () => {
|
||||
})
|
||||
|
||||
it('scans for FailFast only after the post-crash input probe', () => {
|
||||
const harness = readFileSync('tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
const scanIndex = harness.indexOf('const { events: failFastEvents }')
|
||||
const probeIndex = harness.indexOf('reattachProven = await proveReattachedShell')
|
||||
expect(scanIndex).not.toBe(-1)
|
||||
@@ -172,7 +163,7 @@ describe('win-crash-survival-e2e proof contracts', () => {
|
||||
})
|
||||
|
||||
it('requires the real packaged main for the crash proof but permits fallback cleanup', async () => {
|
||||
const harness = readFileSync('tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
expect(harness).toContain(
|
||||
'resolveElectronMainPid(session.app, { allowLauncherFallback: false })'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8'))
|
||||
|
||||
describe('Windows signing workflow contract', () => {
|
||||
it('preflights SignPath module install before Windows signing side effects', () => {
|
||||
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const installStepIndexes = stepNames.flatMap((name, index) =>
|
||||
name === 'Install SignPath PowerShell module' ? [index] : []
|
||||
)
|
||||
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
|
||||
const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime')
|
||||
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
|
||||
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
|
||||
|
||||
expect(verifyNodePtyIndex).toBe(buildIndex + 1)
|
||||
expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1])
|
||||
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
|
||||
|
||||
expect(steps[verifyNodePtyIndex].run).toContain(
|
||||
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
|
||||
)
|
||||
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
|
||||
|
||||
const uploadThroughDownloadScript = steps
|
||||
.slice(uploadIndex, downloadIndex + 1)
|
||||
.map((step) => step.run ?? '')
|
||||
.join('\n')
|
||||
|
||||
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
|
||||
|
||||
const installStep = steps[installStepIndexes[0]]
|
||||
|
||||
expect(installStep.if).toBe("matrix.platform == 'win'")
|
||||
expect(installStep.uses).toBe('./.github/actions/install-signpath-module')
|
||||
expect(installStep.run).toBeUndefined()
|
||||
|
||||
const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml')
|
||||
const actionStep = installAction.runs.steps[0]
|
||||
const installRun = actionStep.run
|
||||
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
|
||||
([, seconds]) => seconds
|
||||
)
|
||||
|
||||
expect(installAction.runs.using).toBe('composite')
|
||||
expect(actionStep.shell).toBe('pwsh')
|
||||
expect(installRun).toContain(
|
||||
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
|
||||
)
|
||||
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
|
||||
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
|
||||
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
|
||||
expect(installRun).toContain(
|
||||
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
|
||||
)
|
||||
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
|
||||
expect(sleepSeconds).toContain('15')
|
||||
expect(sleepSeconds).toContain('30')
|
||||
expect(installRun).toContain(
|
||||
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
|
||||
)
|
||||
expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop')
|
||||
expect(installRun).toContain(
|
||||
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
|
||||
)
|
||||
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
|
||||
expect(installRun).not.toContain('SignPath*')
|
||||
expect(installRun).not.toMatch(/throw\s+\$_/)
|
||||
})
|
||||
|
||||
it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => {
|
||||
const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml')
|
||||
const installRun = installAction.runs.steps[0].run
|
||||
|
||||
// Why: the gallery API 403s during Azure Front Door incidents while its CDN
|
||||
// stays up, so a pinned nupkg is the fallback. The hash pin is the only
|
||||
// integrity check on that route — losing it would let any payload install.
|
||||
const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs
|
||||
expect(version.default).toMatch(/^4\.\d+\.\d+$/)
|
||||
expect(sha256.default).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256')
|
||||
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
|
||||
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
|
||||
expect(installRun).toContain(
|
||||
'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg'
|
||||
)
|
||||
|
||||
// The module only resolves by name when the folder matches its ModuleVersion.
|
||||
expect(installRun).toContain(
|
||||
'$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version'
|
||||
)
|
||||
// The fallback only runs after the gallery route is exhausted, and still
|
||||
// fails the job when neither route produced a usable module.
|
||||
expect(installRun.indexOf('$installed = $true')).toBeLessThan(
|
||||
installRun.indexOf('if (-not $installed)')
|
||||
)
|
||||
expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module')
|
||||
})
|
||||
|
||||
it('still installs SignPath when the cut ref predates the composite action', () => {
|
||||
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const checkoutIndex = stepNames.indexOf('Checkout')
|
||||
const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref')
|
||||
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
|
||||
|
||||
// Why: the build job checks out the cut tag, which for a hotfix cut from an
|
||||
// older ref can predate `.github/actions/install-signpath-module`; without
|
||||
// this restore the `uses: ./…` step dies on a missing action.yml.
|
||||
expect(restoreIndex).toBeGreaterThan(checkoutIndex)
|
||||
expect(restoreIndex).toBeLessThan(installIndex)
|
||||
|
||||
const restoreStep = steps[restoreIndex]
|
||||
const restoreRun = restoreStep.run
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml')
|
||||
expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions')
|
||||
|
||||
// Why: restoring the action must not turn signing into a soft dependency —
|
||||
// a missing module still has to fail the Windows job, and the CDN fallback
|
||||
// still has to reject an unexpected payload.
|
||||
expect(steps[installIndex]['continue-on-error']).toBeUndefined()
|
||||
expect(restoreStep['continue-on-error']).toBeUndefined()
|
||||
|
||||
const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs
|
||||
.steps[0].run
|
||||
|
||||
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
|
||||
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
|
||||
})
|
||||
|
||||
it('shares one SignPath module install path between release and rehearsal', () => {
|
||||
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
|
||||
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
|
||||
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
|
||||
|
||||
// Why: the rehearsal exists to prove the real signing flow, so it must
|
||||
// install the module exactly the way the release job does.
|
||||
expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe(
|
||||
'./.github/actions/install-signpath-module'
|
||||
)
|
||||
expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined()
|
||||
expect(installIndex).toBeLessThan(
|
||||
stepNames.indexOf('Download signed inner binaries from SignPath')
|
||||
)
|
||||
})
|
||||
|
||||
it('verifies Windows inner binary signatures fail-open before publishing', () => {
|
||||
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
|
||||
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
|
||||
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
|
||||
|
||||
expect(outerVerifyIndex).toBeGreaterThan(-1)
|
||||
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
|
||||
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
|
||||
expect(publishIndex).toBe(evidenceIndex + 1)
|
||||
|
||||
// Why fail-open: unsigned inner binaries must warn, not block, until the
|
||||
// flow is proven on a real release (issue #7785). Flip this to 'true'
|
||||
// together with the workflow env to make the gate required.
|
||||
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
|
||||
|
||||
// Why: every step in the inner-signing chain must be unable to fail the
|
||||
// release — a SignPath outage or timeout falls through to today's
|
||||
// unsigned-inner flow instead of blocking the cut.
|
||||
const innerChainStepNames = [
|
||||
'Stage unsigned inner PE files for signing',
|
||||
'Upload unsigned inner binaries for SignPath',
|
||||
'Submit inner binaries signing request',
|
||||
'Notify Slack that inner-binary signing is waiting for approval',
|
||||
'Download signed inner binaries from SignPath',
|
||||
'Restore signed inner binaries into unpacked app',
|
||||
'Replace cached elevate.exe with the signed copy',
|
||||
'Rebuild NSIS installer from signed unpacked app'
|
||||
]
|
||||
for (const stepName of innerChainStepNames) {
|
||||
const step = steps[stepNames.indexOf(stepName)]
|
||||
expect(step, stepName).toBeDefined()
|
||||
expect(step['continue-on-error'], stepName).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,6 @@
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/ipc/local-agent-install-dir-detection.ts",
|
||||
"../src/main/ipc/tui-agent-detection-commands.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
"../src/renderer/src/**/*.tsx",
|
||||
"../src/preload/api-types.ts",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/gitlab/mappers.ts",
|
||||
"../src/main/ipc/worktree-branch-name.ts",
|
||||
"../src/main/ipc/worktree-logic.ts",
|
||||
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
|
||||
"../src/main/ipc/worktree-metadata-merge.ts",
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
"../src/main/wsl-distro-list-output.ts",
|
||||
"../src/main/wsl-distro-retry.ts",
|
||||
"../src/main/wsl.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
'src/**/*.test.tsx',
|
||||
'config/scripts/**/*.test.ts',
|
||||
'config/scripts/**/*.test.mjs',
|
||||
'tools/**/*.test.mjs',
|
||||
'tests/tools/**/*.test.mjs',
|
||||
'tests/e2e/**/*.unit.test.ts'
|
||||
],
|
||||
// Why: the full suite runs heavy TS transforms plus real git/http fixtures;
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
# Agent Status over WSL (STA-1515)
|
||||
|
||||
Status: implemented and rig-validated (2026-07-09 round 3 + 2026-07-10 round-4 re-run
|
||||
pinned to the hardened build, Windows 11 + WSL2 NAT): Claude end-to-end live —
|
||||
provisioning, working→done in the store, completion toast, loopback-only posture, and
|
||||
restart resume over a daemon-surviving PTY with the instance-keyed endpoint dir reused
|
||||
across restarts. The round-4 re-run also proved the `--exec` spawn form live (host
|
||||
process table) and the stale-exit reinstall upgrading the guest to the new bundle
|
||||
version in place. Residual: Codex's done/Stop leg unproven live — env-blocked on the
|
||||
rig (no dev-profile Codex credentials AND the model backend is unreachable from the
|
||||
guest under NAT); everything that fired behaved correctly. Confirm on a credentialed
|
||||
rig with a guest-reachable backend, ideally on a fresh distro to also observe the
|
||||
deferred Codex trust entries landing after config.toml is seeded.
|
||||
Owner: brennanb2025. Linear: STA-1515.
|
||||
Precedent this mirrors: the SSH agent-hook relay (`src/relay/agent-hook-server.ts`,
|
||||
`src/shared/agent-hook-relay.ts`, ingest at `agentHookServer.ingestRemote` in
|
||||
`src/main/agent-hooks/server.ts`).
|
||||
|
||||
## Background — how we got here
|
||||
|
||||
GitHub issue `7565` reported OMP agents in WSL worktrees disappearing from the worktree
|
||||
sidebar after v1.4.124. Diagnosis split it into a regression and a pre-existing class gap:
|
||||
|
||||
- The **regression** was a title-normalization change (PR `7447`) that stopped idle OMP
|
||||
titles from producing the sidebar's title-derived fallback row. Decision: the sidebar is
|
||||
moving to **hook-driven rows only** (fallback removal in flight, separate PR), so the
|
||||
fallback was not restored.
|
||||
- The **class gap** is that agent hooks have never worked from inside WSL for any agent.
|
||||
Two scoped PRs fixed it for OMP alone (merged, live-validated on a Windows+WSL2-NAT rig):
|
||||
- PR `7642` — Orca-managed WSL shells wrap interactive `omp` invocations with
|
||||
`--extension "$ORCA_OMP_STATUS_EXTENSION"` (the env var is WSLENV `/p`-translated so
|
||||
the WSL process reads the extension out of the Windows filesystem via `/mnt/c`).
|
||||
- PR `7641` — when the extension's loopback POST cannot connect, it delivers via
|
||||
Windows-side `/mnt/c/Windows/System32/curl.exe` (a Windows process, so *its*
|
||||
`127.0.0.1` is the loopback Orca actually binds). Fire-and-forget spawn,
|
||||
`--noproxy 127.0.0.1`, memoized WSL/curl probes, load-tolerant timeouts
|
||||
(`--connect-timeout 3 --max-time 10`; 0.5s dropped events under load).
|
||||
|
||||
This document is the full context for the general fix: every other hook client is still
|
||||
dead from WSL, and the hooks-only sidebar change makes this work the gate for the
|
||||
Windows+WSL story.
|
||||
|
||||
## Why hooks don't work on Windows+WSL — two independent gaps
|
||||
|
||||
### Gap A — transport
|
||||
|
||||
The hook listener binds `127.0.0.1` only, deliberately (`src/main/agent-hooks/server.ts`,
|
||||
`listen(0, '127.0.0.1')`; auth via `X-Orca-Agent-Hook-Token`, 403 otherwise). Every hook
|
||||
client POSTs to a hardcoded `http://127.0.0.1:$ORCA_AGENT_HOOK_PORT/hook/<source>`.
|
||||
|
||||
WSL2 under default **NAT** networking is a VM with its own network namespace. Microsoft's
|
||||
localhost forwarding is **one-way (Windows→WSL only)**: `127.0.0.1` inside WSL is WSL's
|
||||
own loopback, so every POST dies `ECONNREFUSED` — silently, because hook clients are
|
||||
deliberately fail-open. Reaching Windows from WSL would require the host vNIC IP (changes
|
||||
per boot) + a non-loopback listener bind + a firewall rule — all three conflict with the
|
||||
loopback-only security posture.
|
||||
|
||||
The env coordinates DO cross correctly (`src/main/pty/wsl-orca-env.ts`
|
||||
`addOrcaWslInteropEnv`: WSLENV `PORT/u TOKEN/u ENV/u VERSION/u` plus
|
||||
`ORCA_AGENT_HOOK_ENDPOINT/p` path-translated; called from `src/main/ipc/pty.ts` and
|
||||
`src/main/daemon/pty-subprocess.ts`). The address is simply unreachable.
|
||||
|
||||
Opt-in **mirrored** networking (Win11, `.wslconfig`) shares loopback and makes plain fetch
|
||||
work — the fix must not fight it. No `wslinfo` probing is needed: under mirrored mode the
|
||||
relay's preferred-port bind collides with the Windows listener and the `EADDRINUSE`
|
||||
fallback (below) handles it, while clients that go straight to the shared loopback reach
|
||||
the Windows listener directly. Both delivery paths stay valid.
|
||||
|
||||
### Gap B — installation
|
||||
|
||||
Hook configs and scripts are written to the **Windows** home by every hook service:
|
||||
Claude `settings.json` + managed scripts, Codex config, Gemini/Cursor/Droid/Devin/Grok/
|
||||
Copilot scripts, Amp/OpenCode plugin files, the Pi/OMP extension file. An agent inside WSL
|
||||
reads the **WSL-side** `$HOME` and sees none of it. There is zero WSL-targeted install
|
||||
code in `src/main/agent-hooks/` or any hook service. SSH remotes have the exact precedent
|
||||
needed: dedicated remote installers (`src/main/ssh/ssh-relay-session.ts` remote
|
||||
settings.json handling; PR `7744` installed Droid/Copilot hooks over SSH).
|
||||
|
||||
Consequence: even mirrored-networking users get no hooks — transport fine, configs absent.
|
||||
OMP escapes Gap B by *pointing across* the boundary (`/mnt/c` path via `/p` translation)
|
||||
rather than installing WSL-side; that trick can carry file *content* for some clients, but
|
||||
shell hooks still execute inside WSL and then hit Gap A regardless.
|
||||
|
||||
## Transport map — every client, how it posts
|
||||
|
||||
Endpoint file contract: `writeEndpointFile` (`src/shared/agent-hook-listener.ts`) emits
|
||||
exactly four keys (`ORCA_AGENT_HOOK_PORT/TOKEN/ENV/VERSION`) to `endpoint.env` (POSIX) /
|
||||
`endpoint.cmd` (Windows) — **no host field**. Shell clients source it to refresh stale
|
||||
coords after an Orca restart; node clients parse it. It is never executed as a delivery
|
||||
script. Clients prefer endpoint-FILE coords over env (restart re-coordination) — any
|
||||
transport change must preserve that property.
|
||||
|
||||
| Client | Mechanism | Runtime that POSTs |
|
||||
| --- | --- | --- |
|
||||
| Claude, Codex, Gemini, Cursor, Droid, Devin, Grok | managed shell script | `curl` (POSIX) / `curl.exe` (Windows), built in `src/main/agent-hooks/installer-utils.ts` |
|
||||
| Copilot | managed script | `curl` (POSIX) / PowerShell `Invoke-WebRequest` (Windows) |
|
||||
| command-code | managed script (parse-not-source hardened) | `curl` |
|
||||
| Amp, OpenCode | in-process node plugin | `fetch` |
|
||||
| Pi / OMP | bundled in-process extension (`src/main/pi/agent-status-extension-source.ts`) | `fetch`, now with the WSL curl.exe fallback |
|
||||
|
||||
All of them target `127.0.0.1`.
|
||||
|
||||
## Status quo + why this gates the hooks-only sidebar
|
||||
|
||||
Today the worktree-card rows still have a title-derived fallback producer
|
||||
(`src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts`), so WSL users
|
||||
DO currently see rows for title-rich agents (Claude `✳`/spinner titles, Gemini glyphs) —
|
||||
degraded (generic text, no prompt/last-message preview, no notifications) but present.
|
||||
Title-poor agents are dark (Codex — hence GH `6907`). When the hooks-only change removes
|
||||
that producer, **every non-OMP agent in a WSL worktree loses its card row entirely until
|
||||
this work ships**. Hook fidelity adds: prompt + last-assistant-message previews,
|
||||
waiting/blocked precision, completion notifications, AI Vault / native chat session
|
||||
integration.
|
||||
|
||||
## Solution design — WSL relay + WSL-side installation
|
||||
|
||||
### Transport (Gap A): guest-resident relay, host-owned stdio
|
||||
|
||||
Run a small receiver **inside WSL on WSL's own loopback, listening on the very port the
|
||||
clients were already given** (`$ORCA_AGENT_HOOK_PORT` — free inside WSL, since that port
|
||||
only exists on the Windows side). Unmodified clients then deliver successfully with
|
||||
**zero client changes**; the reporter's diagnostic relay in GH `7565` proved this shape
|
||||
live. Forward each parsed envelope to the Windows host over the relay's **own stdio**
|
||||
(Orca spawns it via `wsl.exe`, so it owns that pipe). Ingest through the existing trust
|
||||
boundary: `agentHookServer.ingestRemote` (`src/main/agent-hooks/server.ts`), envelope
|
||||
shape `src/shared/agent-hook-relay.ts` — identical to the SSH relay, which runs a
|
||||
loopback-only receiver on the remote box and forwards over the SSH control channel.
|
||||
|
||||
**Port binding.** Bind the inherited `$ORCA_AGENT_HOOK_PORT` first — it keeps every
|
||||
already-crossed coordinate (env and the `/p`-translated endpoint file) truthful with zero
|
||||
divergence. On `EADDRINUSE` inside the guest, fall back to the SSH relay's own pattern:
|
||||
bind `127.0.0.1:0`, write a **WSL-side endpoint file**, and point WSL PTYs'
|
||||
`ORCA_AGENT_HOOK_ENDPOINT` at it (clients already prefer endpoint-file coords over env).
|
||||
The relay writes that WSL-side endpoint file in **both** modes so restart re-coordination
|
||||
never depends on `/mnt/c` translation being readable.
|
||||
|
||||
Lifecycle: one relay per distro **per Orca instance** (concurrent instances have distinct
|
||||
ports, so guest listeners never collide); **ensure** — not just start — whenever a WSL PTY
|
||||
exists: first spawn *and* daemon-PTY reattach after an Orca restart (WSL PTYs survive in
|
||||
the daemon; the new instance has a new port + token and must respawn the relay before
|
||||
surviving agents re-coordinate). The relay **exits when its stdin closes**: a lingering
|
||||
guest listener would let WSL's own Windows→WSL forwarder grab the freed Windows-side port
|
||||
and blackhole stale Windows-side hook posts. Restart if WSL restarts; token still
|
||||
validated at the relay's HTTP receiver; harmless under mirrored networking (bind
|
||||
fallback) and inert on non-WSL platforms (ensure only fires from WSL PTY spawns).
|
||||
|
||||
Reliability contract (invariant class `agent-session.hook-transport`): hook clients are
|
||||
fail-open silent, so the relay must not be — spawn failures, `EADDRINUSE` fallback, and
|
||||
forward errors each leave a diagnosable breadcrumb; the `wsl.exe` "Catastrophic failure
|
||||
(E_UNEXPECTED)" retry is **bounded** with backoff, never a spawn loop. Oracle: provider-
|
||||
contract tests with fault injection (stdin close → exit, occupied port → fallback + file
|
||||
rewrite, envelope round-trip to `ingestRemote`) rather than end-to-end flows only.
|
||||
|
||||
Design notes from a survey of comparable WSL-capable tools (kept nameless per policy):
|
||||
- Guest-resident component + host-owned channel + guest-side installation, explicitly
|
||||
reusing the tool's SSH-remote machinery, **is the established pattern**. No surveyed
|
||||
tool makes guest processes dial back to a Windows-localhost listener — the merged OMP
|
||||
curl.exe stopgap is the outlier as a primary path (but see the round-4 revised
|
||||
stance below: it survives as the no-node fallback).
|
||||
- Prefer host-owned **stdio** over Windows→WSL localhost port forwarding (wslhost
|
||||
forwarding is known-flaky under load; one surveyed tool dials the distro vNIC IP just to
|
||||
avoid it — stdio sidesteps the question entirely).
|
||||
- WSL offers no persistent control channel between separate `wsl.exe` invocations —
|
||||
collapse the relay's ensure-installed + launch into **one idempotent script per spawn**.
|
||||
- Install into the guest from inside the guest (download/extract in WSL) or stream the
|
||||
binary through `wsl.exe` stdin — not by copying through `/mnt/c`.
|
||||
|
||||
### Installation (Gap B): WSL-side hook installers
|
||||
|
||||
Write agent hook configs/scripts into the WSL-side home, per agent, analogous to the SSH
|
||||
remote installers — via `wsl.exe`-executed scripts (preferred, mirrors SSH most closely)
|
||||
or `\\wsl.localhost\<distro>\...` writes. Without this half, the relay receives nothing:
|
||||
the hook clients themselves are absent from the WSL filesystem.
|
||||
|
||||
## Alternatives considered (and why not)
|
||||
|
||||
1. **Endpoint-file host/URL field** — the file already crosses into WSL (`/p`-translated),
|
||||
but clients read only PORT/TOKEN and hardcode the host, so all ~13 still need edits;
|
||||
and a WSL-reachable listener bind breaks the loopback-only posture (LAN exposure,
|
||||
firewall prompts).
|
||||
2. **Replicate the curl.exe bridge per client** — the shell clients share ~2 generated
|
||||
builders so it is cheaper than it sounds, but it is N point fixes, requires WSL interop
|
||||
enabled (`/etc/wsl.conf` can disable it), pays a per-event process spawn (load-sensitive,
|
||||
see validation facts), and keeps the ecosystem-outlier direction.
|
||||
3. **Listener-side bind changes / rely on mirrored networking** — posture conflict /
|
||||
opt-in only.
|
||||
4. **OSC 9999 in-band status** (`src/shared/agent-status-osc.ts`, parsed per-pane in
|
||||
`pty-transport.ts` and `orca-runtime.ts`) — zero-network and pane-attributed, but only
|
||||
viable for in-process clients and carries status payloads, not the full hook event
|
||||
vocabulary (prompts, tools, completion) — cannot replace the pipeline.
|
||||
|
||||
## Facts + gotchas from the 2026-07-08 Windows-rig validation
|
||||
|
||||
- curl.exe interop delivery works under NAT (shipped for OMP), but per-event process spawn
|
||||
is load-sensitive: `--connect-timeout 0.5` dropped 3/3 events to a *healthy* listener
|
||||
under load; fine at 3s. A resident relay avoids per-event spawns entirely.
|
||||
- `wslinfo --networking-mode` distinguishes NAT vs mirrored.
|
||||
- Clients prefer endpoint-FILE coords over env. Testing gotcha: unset
|
||||
`ORCA_AGENT_HOOK_ENDPOINT` in synthetic tests or events go to the real running app.
|
||||
- Server ingest silently drops paneKeys that are not `uuid:uuid`-shaped — use real-shaped
|
||||
keys in synthetic validation.
|
||||
- OMP is a Bun single-file binary; Bun's `node:child_process`/`fetch` compat held. Other
|
||||
in-process clients run inside their agents' runtimes — verify per runtime.
|
||||
- Environmental: fresh WSL 2.7.10 intermittently threw "Catastrophic failure
|
||||
(E_UNEXPECTED)" from `wsl.exe -d <distro> -- bash -lc` under concurrent spawn load
|
||||
(cleared by `wsl --terminate`). The relay spawn path should tolerate/retry this.
|
||||
- Fork-PR CI runs sit in `action_required` until approved:
|
||||
`gh api repos/stablyai/orca/actions/runs/<id>/approve -X POST`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
On a default-config Windows 11 + WSL2 **NAT** machine: launch **Codex or Claude**
|
||||
(explicitly not OMP) in a WSL worktree → live hook-driven worktree-card row with status
|
||||
transitions and a completion notification; hook listener still bound to Windows loopback
|
||||
only; zero per-client transport changes; hooks installed WSL-side automatically (no manual
|
||||
config); harmless under mirrored networking and inert on non-WSL platforms. After an Orca
|
||||
restart with the WSL agent still running (daemon-surviving PTY), status events resume
|
||||
without relaunching the agent.
|
||||
|
||||
## 2026-07-09 Windows-rig validation follow-ups
|
||||
|
||||
The first live GUI run proved every mechanism in isolation but failed end-to-end, yielding
|
||||
two fixes:
|
||||
|
||||
1. **Link death must be handled, not just child death.** A mux protocol error or timeout
|
||||
can kill the host↔guest link while the guest process (and its 204-returning receiver)
|
||||
stays alive — the exact observed signature: hooks POST 204, store never populates.
|
||||
`wsl-hook-relay-link.ts` now guarantees exactly-once death handling from either signal;
|
||||
the manager breadcrumbs it, kills the child, and self-restarts after a short cooldown
|
||||
(a live agent session produces no new PTY spawns to re-trigger ensure).
|
||||
`ORCA_WSL_HOOK_RELAY_DEBUG=1` traces every received envelope pre-ingest so a live rig
|
||||
can pinpoint any residual drop. The full host chain is pinned by a live integration
|
||||
test (real bundle over real child stdio through the real manager into a real
|
||||
`ingestRemote`).
|
||||
2. **(Round 2) The renderer's SSH-era ownership gate dropped `wsl:*` events.** With the
|
||||
link fixed, envelopes reached `ingestRemote` and the durable cache, but
|
||||
`useIpcEvents.applyAgentStatus` compares the stamped connectionId against the owning
|
||||
repo's — `"wsl:<distro>" !== null` for a local repo, so every WSL-relayed status died
|
||||
before `setAgentStatus`/notifications. Fix: `wsl:*` ids are transport provenance, not
|
||||
ownership — the gate normalizes them to local (null) via
|
||||
`isWslHookRelayConnectionId`, while still rejecting WSL-stamped events against
|
||||
SSH-owned repos. Provenance stays stamped (it made the drop diagnosable in the first
|
||||
place).
|
||||
3. **Codex reads a redirected home.** Orca launches WSL Codex with `CODEX_HOME` pointed at
|
||||
the managed runtime home (`~/.local/share/orca/codex-runtime-home/home`), so installing
|
||||
hooks to `~/.codex` left Codex dark. The installers now accept an explicit codex home;
|
||||
the trust write into `config.toml` is deferred while that file doesn't exist (the
|
||||
launch path seeds it only-if-absent — creating it first would cancel the seed), and the
|
||||
manager re-runs the idempotent installers on later ensures (throttled) to upsert trust
|
||||
once the seed lands. Consequence: the very first WSL Codex session after a cold relay
|
||||
may miss hooks; the next one has them.
|
||||
|
||||
## 2026-07-09 adversarial-review hardening (pre-rig round 3)
|
||||
|
||||
Four independent review lenses over the full diff; confirmed findings fixed:
|
||||
|
||||
- **Endpoint identity (all 4 reviewers)**: the guest endpoint dir was keyed by the
|
||||
ephemeral Windows hook port, so a daemon-surviving agent kept sourcing the DEAD
|
||||
`port-P1` file after an Orca restart — breaking the restart-resume acceptance criterion
|
||||
and regressing shipped OMP recovery. Now keyed by a restart-stable instance key
|
||||
(hash of the Windows endpoint file path = userData + namespace, crossed via
|
||||
`ORCA_WSL_HOOK_INSTANCE`): the restarted instance's relay REWRITES the same file, which
|
||||
is exactly what re-coordinates survivors.
|
||||
- **Restart policy**: every failure now arms the restart timer (one failed relaunch no
|
||||
longer ends self-recovery), and the timer probes `wsl --list --running` first — `wsl -d`
|
||||
BOOTS a stopped distro, so recovery must never resurrect a VM the user shut down; a
|
||||
stopped distro's state is dropped instead (next WSL terminal re-ensures). Failure
|
||||
counters only reset after 2 min of stable uptime, so connect-then-die loops escalate to
|
||||
the 10-min cap instead of cycling every 10s.
|
||||
- **Install-dir versioning**: the guest install dir is namespaced by bundle version, so
|
||||
concurrent Orca instances with different bundles (dev + prod) never reinstall over each
|
||||
other; tmp files carry the guest PID. The install spawn also gained the 30s timeout it
|
||||
was missing (a wedged wsl.exe could previously pin the state machine at 'starting'
|
||||
forever).
|
||||
- **Guest node resolution**: candidates (PATH, nvm glob, fixed paths) are each
|
||||
version-probed, first pass wins — an apt node 12 on PATH no longer masks an nvm node 20
|
||||
into a false "no node >= 18" 10-minute cooldown.
|
||||
- **wsl.exe text handling**: `WSL_UTF8=1` on all spawns + NUL-stripping on stderr, so the
|
||||
"Catastrophic failure" transient-retry matcher and breadcrumbs survive UTF-16LE output.
|
||||
- Smaller: ordered post-sentinel chunk handoff (frame-decoder desync race), port-fallback
|
||||
breadcrumb now reaches host logs via the home handshake, bad home reply fails the
|
||||
connect (was: silently 'running' without installs), missing-bundle warn-once, distro
|
||||
map keys case-normalized, `disposeAll` wired to app `will-quit`, single-spawn Codex
|
||||
trust catch-up via a one-shot 60s reinstall timer.
|
||||
|
||||
Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced
|
||||
install dirs accrete across upgrades (~200KB each); an outdated running daemon
|
||||
/p-translates the guest endpoint path until it restarts (hook scripts fall back to env
|
||||
coords, which same-port binding keeps correct); `wslDistroCache` caches a transient
|
||||
empty list for the app run (pre-existing semantics, now load-bearing for default-distro
|
||||
resolution); default-distro resolution caches the first answer for the app run.
|
||||
|
||||
## 2026-07-09 round-4 external adversarial review
|
||||
|
||||
A second adversarial sweep (five independent lenses: guest relay + fs bridge, host
|
||||
lifecycle state machine, app integration + renderer gate, design-vs-alternatives, and a
|
||||
platform fact-check of every WSL claim). Design verdict: the guest-resident relay over
|
||||
host-owned stdio is the right architecture — the zero-per-client-change chokepoint is
|
||||
what the curl.exe alternative cannot match, and the lifecycle weight is inherent to any
|
||||
guest-resident helper. Confirmed findings, all fixed on this branch:
|
||||
|
||||
- **`dropState` identity race (major)**: the recovery timer re-checked state identity
|
||||
only BEFORE the async `wsl --list --running` probe; an ensure() landing during the
|
||||
probe could get its fresh state deleted by key — orphaning a live relay child outside
|
||||
the map (unkillable by `disposeAll`, duplicate relay on next ensure). Fixed: identity
|
||||
re-check after the probe await + identity-guarded delete in the manager.
|
||||
- **Distro-running probe failed OPEN**: any probe error (including its 10s timeout)
|
||||
reported "running", so recovery could `wsl -d` — and thereby BOOT — a distro the user
|
||||
shut down, in exactly the wedged-wsl.exe failure mode where the probe errors. Now
|
||||
fails closed: drop the state; the next WSL PTY spawn re-ensures.
|
||||
- **Spawn form hardened to `--exec`**: `wsl.exe -- <cmd>` routes through the distro's
|
||||
default login shell (Microsoft docs: only `--exec` runs "without using the default
|
||||
Linux shell"), so a fish/nushell chsh could mangle the launch; `--exec sh -c`/-`s`
|
||||
bypasses it and passes argv verbatim (no `$`-preprocessing, escaping shim dropped) —
|
||||
same form as the Codex WSL login spawn.
|
||||
- **Post-sentinel handoff microtask**: pending chunks flushed synchronously inside the
|
||||
mux constructor, before the manager could register notification handlers — an
|
||||
envelope arriving in the trailing bytes dispatched to zero handlers (recovered only
|
||||
by the later replay request). Flush now rides a microtask: after the caller's
|
||||
synchronous wiring, still ahead of any subsequent stdout IO event.
|
||||
- **Relay process posture**: the guest relay now mirrors the SSH relay's
|
||||
`uncaughtException` (log + exit → manager respawns) / `unhandledRejection` (log +
|
||||
survive) handlers.
|
||||
- **Replay cache recency cap**: the WSL relay has no per-pane teardown signal, so the
|
||||
per-pane replay cache grew for the relay's lifetime; now capped at 256 panes,
|
||||
evicting longest-idle first (meta map kept in lockstep). Backstop for SSH too.
|
||||
- Smaller: guest launch script derives the stale-exit code from the shared contract
|
||||
constant (was a hardcoded 42 twin); one-shot reinstall timer refuses to arm after
|
||||
dispose; fs-bridge scope comment states the lexical (symlink-following) bound
|
||||
honestly. New oracles: sentinel unit suite (chunk splits, overflow kill, timeout,
|
||||
microtask handoff), fs-bridge scoping suite, 403 + fallback endpoint-file rewrite,
|
||||
cache-cap eviction, and the recovery/manager race regressions.
|
||||
|
||||
**Revised stance on the OMP curl.exe bridge — keep it, do not retire.** The relay
|
||||
requires node ≥ 18 in the distro; a fresh WSL Ubuntu ships none, Codex CLI is a native
|
||||
binary that brings none, and Claude Code's native installer no longer implies a system
|
||||
node. A distro running only Codex would hit the no-node cooldown and stay dark — the
|
||||
exact GH `6907` shape. The interop bridge is the one delivery path with no guest
|
||||
runtime requirement, so it stays as the documented no-node fallback (currently wired
|
||||
for OMP; extending it to the shared shell-script builders is the tracked follow-up if
|
||||
no-node distros show up in telemetry). The relay remains the primary path: resident
|
||||
(no per-event spawn cost) and interop-independent.
|
||||
|
||||
## Implementation map
|
||||
|
||||
- Guest: `src/relay/wsl-agent-hook-relay.ts` (entry; exits on stdin close),
|
||||
`src/relay/wsl-hook-fs-bridge.ts` (home-scoped fs RPCs for installs),
|
||||
`src/relay/agent-hook-server.ts` (`token`/`preferredPort` options + `EADDRINUSE`
|
||||
fallback). Bundled by `config/scripts/build-relay.mjs` → `out/relay/wsl/`.
|
||||
- Host: `src/main/agent-hooks/wsl-hook-relay-manager.ts` (per-distro state machine),
|
||||
`wsl-hook-relay-launch.ts` (bundle resolve, guest launch/install scripts, spawn env,
|
||||
sentinel wait), `wsl-hook-relay-link.ts` (envelope forward + exactly-once link-death
|
||||
handling), `wsl-hook-relay-deps.ts` (DI seam), `wsl-hook-fs-adapter.ts` (SFTP-shaped
|
||||
adapter + `installWslGuestHooks`, which targets Codex's managed runtime home).
|
||||
- Wiring: `buildPtyHostEnv` (`src/main/ipc/pty.ts`) ensures the relay on every WSL spawn
|
||||
and repoints `ORCA_AGENT_HOOK_ENDPOINT` at the guest endpoint file once known;
|
||||
`src/main/pty/wsl-orca-env.ts` picks `/u` vs `/p` by value shape.
|
||||
- Contract shared by both sides: `src/shared/wsl-hook-relay-contract.ts`.
|
||||
- Oracles: `src/relay/wsl-agent-hook-relay.test.ts`,
|
||||
`src/main/agent-hooks/wsl-hook-relay-manager.test.ts` (fault injection: stale-42
|
||||
reinstall, no-node-43 cooldown, bounded E_UNEXPECTED retry, exit re-ensure gating, and a
|
||||
full installer run against an in-memory guest).
|
||||
|
||||
## References
|
||||
|
||||
- GitHub: issues `6907` (Codex/WSL), `7091` + `7565` (OMP, fixed), `7563` (WSL CLI
|
||||
detection, adjacent); PRs `7642` + `7641` (OMP fixes), `7744` (SSH hook installers
|
||||
precedent), `7447` (title-collapse regression).
|
||||
- Linear: STA-1515 (this work; ticket comments carry the same context).
|
||||
- Key files: `src/main/agent-hooks/server.ts`, `src/shared/agent-hook-listener.ts`,
|
||||
`src/shared/agent-hook-relay.ts`, `src/relay/agent-hook-server.ts`, `src/relay/relay.ts`,
|
||||
`src/main/pty/wsl-orca-env.ts`, `src/main/agent-hooks/installer-utils.ts`,
|
||||
`src/main/pi/agent-status-extension-source.ts`, `src/main/ssh/ssh-relay-session.ts`,
|
||||
`src/main/providers/windows-shell-args.ts`, `src/shared/wsl-login-shell-command.ts`.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Android Emulation — Live Pane Streaming
|
||||
|
||||
The Android **control** path (device list, boot, tap/type/buttons/rotate/exec,
|
||||
install/launch/permissions/ax/logcat) is complete and unit-tested. This document
|
||||
covers the **live H.264 video pane** (scrcpy + WebCodecs), which requires the
|
||||
Android SDK, a running AVD, the bundled `scrcpy-server.jar`, and Electron's
|
||||
WebCodecs runtime for end-to-end validation.
|
||||
|
||||
## What is built (committed)
|
||||
|
||||
| Module | Tested | Notes |
|
||||
|---|---|---|
|
||||
| `android/scrcpy-control-protocol.ts` | ✅ unit | Byte-exact control encoders (touch/key/text/back). |
|
||||
| `android/scrcpy-video-frame-parser.ts` | ✅ unit | scrcpy v2.4 codec-meta + frame-header parsing. |
|
||||
| `android/scrcpy-server-deploy.ts` | ✅ unit | push / forward / server-start arg builders. |
|
||||
| `android/scrcpy-stream-session.ts` | live-validated | Owns the server process + video/control sockets. |
|
||||
| `emulator/scrcpy-video-registry.ts` | ✅ unit | Pub/sub bridging a session to renderer subscribers. |
|
||||
| `ipc/emulator-video-stream.ts` | registration tested | `emulator:videoStream*` IPC; registered in `register-core-handlers`. |
|
||||
| `emulator-pane/use-emulator-video-stream.ts` | live-validated | WebCodecs `VideoDecoder` → `<canvas>`. |
|
||||
|
||||
## Current wiring
|
||||
|
||||
### 1. `scrcpy-server.jar`
|
||||
|
||||
The version-pinned server jar must match `SCRCPY_SERVER_VERSION` in
|
||||
`scrcpy-server-deploy.ts`. Runtime resolution checks the packaged resource and a
|
||||
development fallback.
|
||||
|
||||
### 2. `AndroidEmulatorBackend.startSession`
|
||||
|
||||
`startSession` ensures the target is booted, resolves the bundled server jar,
|
||||
starts `ScrcpyStreamSession`, and feeds `scrcpyVideoRegistry`. A
|
||||
`createStreamSession` option lets unit tests inject a fake because the real
|
||||
session does socket I/O.
|
||||
|
||||
```ts
|
||||
async startSession(deviceId: string): Promise<EmulatorSessionInfo> {
|
||||
const serial = await this.ensureBooted(deviceId)
|
||||
const jar = resolveScrcpyServerJar()
|
||||
if (!jar) {
|
||||
throw new EmulatorError('emulator_helper_failed', 'scrcpy-server.jar not bundled (see docs/android-emulation-streaming.md).')
|
||||
}
|
||||
const session = await (this.createStreamSession ?? ScrcpyStreamSession.start)(
|
||||
{ runner: this.runner, sdk: this.requireSdk(), serial, localJarPath: jar, maxSize: 1024 },
|
||||
{
|
||||
onMeta: (meta) => scrcpyVideoRegistry.pushMeta(serial, meta),
|
||||
onFrame: (f) => scrcpyVideoRegistry.pushFrame(serial, { config: f.config, keyFrame: f.keyFrame, pts: String(f.pts), bytes: toArrayBuffer(f.data) }),
|
||||
onError: () => scrcpyVideoRegistry.stop(serial),
|
||||
onClose: () => scrcpyVideoRegistry.stop(serial)
|
||||
}
|
||||
)
|
||||
scrcpyVideoRegistry.register(serial, () => session.close())
|
||||
this.streamSessions.set(serial, session)
|
||||
return { deviceUdid: serial, streamUrl: `scrcpy://${serial}`, wsUrl: '', streamCodec: 'h264' }
|
||||
}
|
||||
```
|
||||
|
||||
`stopHelperForDevice(serial)` stops the registry and closes the stored session.
|
||||
|
||||
### 3. Low-latency input via the scrcpy control socket (optional refinement)
|
||||
|
||||
Input already works via `adb shell input`. For smooth multi-touch, when a live
|
||||
session exists, route `tap`/`gesture` through `session.sendControl(...)` using the
|
||||
encoders in `scrcpy-control-protocol.ts` (convert normalized coords →
|
||||
device pixels with `android-input-mapping`, then `encodeInjectTouchEvent`).
|
||||
|
||||
### 4. Preload + pane
|
||||
|
||||
- **Preload** (`src/preload/...` emulator API): exposes `startVideoStream`,
|
||||
`stopVideoStream`, `onVideoStreamMeta`, `onVideoStreamFrame` wrapping the
|
||||
`emulator:videoStream*` channels (mirror the existing `startFrameStream` etc.).
|
||||
- **Pane** (`emulator-screen-stream-content.tsx`): when
|
||||
`session.streamCodec === 'h264'`, renders the `<canvas>` from
|
||||
`useEmulatorVideoStream(deviceId, enabled)` instead of the MJPEG `<img>`.
|
||||
- **Hardware buttons** (`emulator-phone-hardware-buttons.tsx`): includes Android
|
||||
variants (Back, Home, Recents, Power, Volume) selected by backend kind, per
|
||||
`docs/STYLEGUIDE.md`.
|
||||
|
||||
### 5. Validate on hardware
|
||||
|
||||
```sh
|
||||
pnpm build:cli
|
||||
# boot an AVD (Android Studio or `emulator @<avd>`), then:
|
||||
orca-dev emulator devices --json # see the device
|
||||
orca-dev emulator tap 0.5 0.8 --device <serial> # control works today
|
||||
# after wiring §2/§4: open the emulator pane and confirm the live frame + taps.
|
||||
```
|
||||
|
||||
## Risks to validate first
|
||||
|
||||
- **WebCodecs H.264 in Electron**: confirm `VideoDecoder.isConfigSupported({ codec: 'avc1.640028' })`. If unsupported, fall back to a wasm decoder (Broadway/tinyh264) or a main-process H.264→JPEG transcode into the existing MJPEG channel — neither changes the backend interface (it advertises the codec).
|
||||
- **scrcpy server protocol/version**: the option set + handshake in
|
||||
`scrcpy-server-deploy.ts` / `scrcpy-stream-session.ts` are pinned to v2.4 and
|
||||
must match the bundled jar.
|
||||
- **Annex-B vs avcC**: the renderer configures the decoder without a description
|
||||
(Annex-B). If frames don't decode, extract the avcC from the config packet.
|
||||
@@ -1,376 +0,0 @@
|
||||
# Android Emulation
|
||||
|
||||
## Problem
|
||||
|
||||
Orca ships a built-in mobile emulator surface (live pane + `orca emulator` CLI +
|
||||
agent skill), but it is **iOS Simulator only and macOS only**:
|
||||
|
||||
- `src/main/emulator/emulator-availability.ts:32` hard-returns "unavailable" for
|
||||
any `platform() !== 'darwin'`, so Windows and Linux users get nothing.
|
||||
- The backend (`src/main/emulator/emulator-bridge.ts`) is wired directly to
|
||||
`serve-sim` (`serve-sim-*.ts`) and `xcrun simctl`
|
||||
(`simctl-simulator-devices.ts`), both Apple-only tooling.
|
||||
|
||||
Android emulators run on Windows, Linux, and macOS via the Android SDK that
|
||||
Android Studio installs. We want Android emulation as a first-class peer of the
|
||||
iOS feature: full AVD lifecycle management, a live ~60fps pane, the full
|
||||
tap/gesture/type/button/rotate control surface, accessibility tree, app
|
||||
install/launch, runtime permissions, logcat, plus a dedicated
|
||||
`orca-emulator-android` agent skill.
|
||||
|
||||
## Current architecture (what we reuse vs. replace)
|
||||
|
||||
The existing stack already separates a backend from everything above it. The
|
||||
renderer pane, session registry, RPC/CLI shape, and tab system are effectively
|
||||
**backend-agnostic** and are reused unchanged:
|
||||
|
||||
- **Frame transport is main-owned.** `src/main/ipc/emulator-frame-stream.ts:40`
|
||||
runs the MJPEG socket in the main process and forwards raw JPEG bytes to the
|
||||
renderer over `emulator:frameStreamFrame`. The renderer
|
||||
(`src/renderer/src/components/emulator-pane/use-emulator-frame-stream.ts:74`)
|
||||
just wraps each frame in a `Blob`/`<img>`. The renderer is a **frame
|
||||
consumer**, decoupled from the source.
|
||||
- **Per-worktree "active emulator"** lives in
|
||||
`src/main/emulator/emulator-session-registry.ts` (like the active browser
|
||||
tab). Backend-agnostic.
|
||||
- **RPC** is declared in `src/main/runtime/rpc/methods/emulator.ts` and
|
||||
implemented in `src/main/runtime/orca-runtime-emulator.ts`.
|
||||
- **CLI** is `src/cli/specs/emulator.ts` + `src/cli/handlers/emulator.ts`.
|
||||
- **Pane** is `src/renderer/src/components/emulator-pane/**` (~50 files).
|
||||
|
||||
What is iOS-bound and needs an Android sibling:
|
||||
|
||||
- Device management: `xcrun simctl` → `adb` / `emulator` / `avdmanager`.
|
||||
- Streaming helper: `serve-sim` (MJPEG/H.264 over HTTP+WS) → `scrcpy-server.jar`
|
||||
(H.264 + control over adb-forwarded sockets).
|
||||
- Input: serve-sim normalized-coord WS → adb-backed Android input commands.
|
||||
- Availability gate: darwin-only → SDK-present on any OS.
|
||||
|
||||
## Goals
|
||||
|
||||
- Android emulation on **Windows, Linux, and macOS** (macOS users choose iOS or
|
||||
Android in the same pane).
|
||||
- **Full AVD lifecycle**: discover installed AVDs via the SDK, boot/shutdown
|
||||
them from Orca, and attach to already-running emulators + physical `adb`
|
||||
devices.
|
||||
- **Live ~60fps pane** via scrcpy H.264 decoded in the renderer with WebCodecs.
|
||||
- Control parity: tap, swipe/gesture, type, hardware buttons (Back, Home,
|
||||
Recents, Power, Volume), rotate.
|
||||
- Extra capabilities: accessibility tree (`uiautomator dump`), app
|
||||
install/launch (`adb install` / `am start`), runtime permissions
|
||||
(`pm grant/revoke/reset`), logcat capture.
|
||||
- A dedicated `skills/orca-emulator-android/SKILL.md`.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Camera/sensor injection (the Android emulator's virtual-scene path is a much
|
||||
larger problem than serve-sim's iOS camera injection; defer).
|
||||
- Remote/SSH device control (matches the current iOS limitation; emulator
|
||||
hardware is local).
|
||||
- Wear OS / Android TV / Automotive form factors.
|
||||
- Migrating the iOS backend to H.264 (the interface allows it later; not done
|
||||
now).
|
||||
|
||||
## Design
|
||||
|
||||
### Extract an `EmulatorBackend` interface; make the bridge a router
|
||||
|
||||
Today `EmulatorBridge` *is* the iOS implementation. Refactor it into a thin
|
||||
router over a backend interface so iOS and Android share the session registry,
|
||||
RPC/CLI shape, frame IPC, and tab system. This is the only change to existing
|
||||
iOS behavior, and it is a pure extraction (no semantic change).
|
||||
|
||||
New module `src/main/emulator/backends/emulator-backend.ts`:
|
||||
|
||||
```ts
|
||||
export type EmulatorBackendKind = 'ios' | 'android'
|
||||
export type EmulatorStreamCodec = 'mjpeg' | 'h264'
|
||||
|
||||
export type EmulatorDevice = {
|
||||
backend: EmulatorBackendKind
|
||||
id: string // opaque: simulator UDID, adb serial, or AVD name
|
||||
name: string
|
||||
state: 'shutdown' | 'booting' | 'booted'
|
||||
kind?: string // form factor / api level, display only
|
||||
isAvailable: boolean
|
||||
}
|
||||
|
||||
export type EmulatorBackendCapabilities = {
|
||||
install: boolean
|
||||
launch: boolean
|
||||
permissions: boolean
|
||||
accessibilityTree: boolean
|
||||
logcat: boolean
|
||||
}
|
||||
|
||||
export interface EmulatorBackend {
|
||||
readonly kind: EmulatorBackendKind
|
||||
readonly capabilities: EmulatorBackendCapabilities
|
||||
isSupportedOnHost(): boolean
|
||||
checkAvailability(): Promise<BackendAvailability>
|
||||
listDevices(): Promise<EmulatorDevice[]>
|
||||
bootDevice(id: string): Promise<EmulatorDevice>
|
||||
startSession(id: string): Promise<EmulatorSessionInfo> // includes streamCodec
|
||||
tap(id, x, y): Promise<void>
|
||||
gesture(id, points): Promise<void>
|
||||
type(id, text): Promise<void>
|
||||
button(id, name): Promise<void>
|
||||
rotate(id, orientation): Promise<void>
|
||||
exec(id, command): Promise<unknown>
|
||||
// capability-gated:
|
||||
installApp?(id, path): Promise<void>
|
||||
launchApp?(id, pkg, activity?): Promise<void>
|
||||
setPermission?(id, op): Promise<void>
|
||||
accessibilityTree?(id): Promise<unknown>
|
||||
logcat?(id, opts): Promise<...>
|
||||
stopSession(id): Promise<void>
|
||||
kill(id): Promise<void>
|
||||
shutdown(id): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- `src/main/emulator/backends/ios-emulator-backend.ts` — the existing serve-sim
|
||||
+ simctl logic extracted from `EmulatorBridge`, implementing the interface
|
||||
with `kind: 'ios'`, `streamCodec: 'mjpeg'`, and capabilities mapped to what
|
||||
serve-sim already supports.
|
||||
- `src/main/emulator/backends/android-emulator-backend.ts` — new, orchestrates
|
||||
the Android modules below with `kind: 'android'`, `streamCodec: 'h264'`.
|
||||
- `EmulatorBridge` keeps its public method names (so RPC/runtime callers don't
|
||||
churn) but becomes a router: it holds the available backends, resolves which
|
||||
backend owns a given device/session (via the session registry's recorded
|
||||
`backend` tag, or by probing `listDevices()` for an unknown id), and
|
||||
delegates. The session registry record gains a `backend: EmulatorBackendKind`
|
||||
field; `EmulatorSessionInfo` gains `streamCodec`. The existing `deviceUdid`
|
||||
field is retained as the opaque `id` to preserve wire-compat across the
|
||||
renderer and CLI.
|
||||
|
||||
### New Android modules — `src/main/emulator/android/`
|
||||
|
||||
Each module is small and single-purpose with a co-located `.test.ts`, matching
|
||||
the existing `serve-sim-*` / `simctl-*` granularity (no file approaches the
|
||||
`max-lines` limit):
|
||||
|
||||
- `android-sdk-discovery.ts` — resolve the SDK root and the `adb` / `emulator` /
|
||||
`avdmanager` binaries from `ANDROID_HOME`, then `ANDROID_SDK_ROOT`, then per-OS
|
||||
defaults: `%LOCALAPPDATA%\Android\Sdk` (Windows), `~/Library/Android/sdk`
|
||||
(macOS), `~/Android/Sdk` (Linux). All paths via `path.join`.
|
||||
- `adb-devices.ts` — `adb devices -l`, resolve serial, `wait-for-device` +
|
||||
`getprop sys.boot_completed` poll, and `wm size` for the device resolution.
|
||||
- `avd-manager.ts` — `emulator -list-avds`, boot an AVD via a detached
|
||||
`emulator @<name>` spawn, shut down via `adb -s <serial> emu kill`.
|
||||
- `scrcpy-server-deploy.ts` — push the version-pinned `scrcpy-server.jar`, start
|
||||
it via `app_process`, and set up the `adb forward` tunnel(s).
|
||||
- `scrcpy-stream-session.ts` — owns the server process, adb tunnel, and video
|
||||
socket lifecycle.
|
||||
- `scrcpy-video-frame-parser.ts` — read the video socket, parse scrcpy frame headers
|
||||
(PTS + length), and emit H.264 access units plus the codec config (SPS/PPS).
|
||||
- `scrcpy-control-protocol.ts` — encode scrcpy control messages (touch
|
||||
down/move/up with pointer id + pressure, inject keycode, inject UTF-8 text,
|
||||
scroll, set screen power, rotate, clipboard) for a future low-latency input path.
|
||||
- `android-input-mapping.ts` — convert normalized 0–1 ↔ device pixels using the
|
||||
live frame size; map button names → Android keycodes (BACK=4, HOME=3,
|
||||
APP_SWITCH=187, POWER=26, VOLUME_UP=24, VOLUME_DOWN=25).
|
||||
- `android-input-commands.ts` — current adb-backed tap/type/button/rotate/gesture
|
||||
command construction.
|
||||
- `uiautomator-tree.ts` — `adb shell uiautomator dump` → parsed XML tree.
|
||||
- `android-app-control.ts` — `adb install <apk>`, `am start` package/activity.
|
||||
- `android-permissions.ts` — `pm grant` / `revoke` / `reset`.
|
||||
- `android-logcat.ts` — tail/filter `adb logcat` with bounded buffering.
|
||||
- `android-availability.ts` — SDK present? AVDs + connected devices list, with
|
||||
clear, surfaced messages (mirroring the iOS availability message style).
|
||||
|
||||
### Streaming & control data flow
|
||||
|
||||
**Control currently uses `adb shell input` commands.** Coordinates stay
|
||||
normalized 0–1 at every public boundary (CLI, RPC, renderer); the Android backend
|
||||
maps them to device pixels before issuing adb-backed tap/gesture/button commands.
|
||||
The scrcpy control protocol encoders are present for a future low-latency input
|
||||
path, but video streaming does not require that path.
|
||||
|
||||
**Video path (H.264 → renderer WebCodecs):**
|
||||
|
||||
1. `android-emulator-backend.startSession()` deploys + starts scrcpy-server,
|
||||
opens the video + control sockets, and returns `EmulatorSessionInfo` with
|
||||
`streamCodec: 'h264'`.
|
||||
2. `scrcpy-video-stream.ts` reads access units and the SPS/PPS config and pushes
|
||||
them over a **new IPC channel** `emulator:videoStream{Start,Config,Frame,Stop}`
|
||||
(a sibling of the existing `emulator:frameStream*`), keyed by stream id.
|
||||
3. New renderer hook
|
||||
`src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts`
|
||||
feeds the access units to a WebCodecs `VideoDecoder`, drawing decoded
|
||||
`VideoFrame`s to a `<canvas>`.
|
||||
4. `src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx`
|
||||
branches on `session.streamCodec`: `mjpeg` keeps today's `<img>` path
|
||||
untouched; `h264` uses the canvas path. No iOS behavior changes.
|
||||
|
||||
## Coordinate & input mapping
|
||||
|
||||
- Public API (CLI/RPC/pane gestures) stays normalized 0–1, top-left origin, as
|
||||
the iOS path already mandates.
|
||||
- `android-input-mapping.ts` multiplies by the current device display size before
|
||||
adb input commands are built. Rotation changes the effective frame size; the
|
||||
mapper reads the current size each gesture rather than caching.
|
||||
- Hardware buttons: adb keyevents inject keycodes. Android adds
|
||||
**Back** and **Recents** (no iOS equivalent); the button name → keycode map
|
||||
and the renderer's hardware-button row gain Android variants.
|
||||
|
||||
## RPC + CLI surface
|
||||
|
||||
Extend `src/main/runtime/rpc/methods/emulator.ts`,
|
||||
`src/main/runtime/orca-runtime-emulator.ts`, `src/cli/specs/emulator.ts`, and
|
||||
`src/cli/handlers/emulator.ts`:
|
||||
|
||||
- `orca emulator list` gains a **platform column** and shows iOS + Android
|
||||
devices/AVDs together; device selection resolves the backend automatically
|
||||
(by recorded session tag, else by which backend's `listDevices()` owns the id).
|
||||
- Existing verbs (`attach`, `tap`, `gesture`, `type`, `button`, `rotate`,
|
||||
`exec`, `kill`, `shutdown`) route unchanged to the resolved backend.
|
||||
- New capability-gated verbs: `install`, `launch`, `permissions`, `ax`,
|
||||
`logcat`. On a backend lacking the capability they fail with a clear
|
||||
`emulator_unsupported` error rather than silently no-op.
|
||||
- Existing `--worktree` / `--device` targeting is unchanged.
|
||||
|
||||
## Renderer pane
|
||||
|
||||
- `src/renderer/src/components/emulator-pane/emulator-phone-hardware-buttons.tsx`
|
||||
→ Android variant (Back, Home, Recents, Power, Volume) selected by backend
|
||||
kind.
|
||||
- Android device bezel/frame + Android entries (and a "boot AVD" affordance) in
|
||||
the attach/list UI.
|
||||
- `MobileEmulatorAgentSetupGuide*` → Android prerequisites step (install Android
|
||||
Studio / SDK, set `ANDROID_HOME`).
|
||||
- Codec-aware stream content (the canvas path above).
|
||||
- All UI follows `docs/STYLEGUIDE.md`: existing tokens from
|
||||
`src/renderer/src/assets/main.css` and shadcn primitives in
|
||||
`src/renderer/src/components/ui/`; no new color/size/shadow values.
|
||||
- Shortcut labels and any new accelerators use the platform checks required by
|
||||
`AGENTS.md` (`CmdOrCtrl`, `⌘`/`Ctrl+`).
|
||||
|
||||
## Packaging & dependencies
|
||||
|
||||
- Bundle the single, version-pinned `scrcpy-server.jar` (~80 KB) as an app
|
||||
resource; wire it into `config/electron-builder.config.cjs` and
|
||||
`config/packaged-runtime-node-modules.cjs`. Pin the scrcpy version — the
|
||||
server protocol is coupled to the jar.
|
||||
- Do **not** bundle `adb` / `emulator` / `avdmanager` (large; Android Studio
|
||||
installs them). Discover them at runtime and surface a clear setup message if
|
||||
the SDK is absent.
|
||||
- No new runtime npm dependency is required for decode (WebCodecs is built into
|
||||
Electron's Chromium). The wasm-decoder fallback (see Risks) would add a dep
|
||||
only if the WebCodecs spike fails.
|
||||
|
||||
## Skill
|
||||
|
||||
New `skills/orca-emulator-android/SKILL.md`, mirroring
|
||||
`skills/orca-emulator/SKILL.md`:
|
||||
|
||||
- Prerequisites: Android Studio / SDK installed, `ANDROID_HOME` (or
|
||||
`ANDROID_SDK_ROOT`) set, at least one AVD or a connected device.
|
||||
- The `orca emulator ...` command table (shared CLI; Android examples).
|
||||
- Gotchas: Orca handles pixel ↔ normalized conversion (agents always pass 0–1);
|
||||
adb device/serial targeting; no camera injection in v1; scrcpy version
|
||||
coupling.
|
||||
- Cross-reference from the iOS skill's "When NOT to use" (which already
|
||||
anticipates an Android backend under the same namespace).
|
||||
- Register it the same way `orca-emulator` is registered.
|
||||
|
||||
## Availability & platform gating
|
||||
|
||||
`src/main/emulator/emulator-availability.ts` becomes an aggregator that asks
|
||||
each backend `isSupportedOnHost()` + `checkAvailability()`:
|
||||
|
||||
- iOS backend: supported only on `darwin` (unchanged behavior/messages).
|
||||
- Android backend: supported on any OS where the SDK is discoverable.
|
||||
- The combined result drives the pane's availability UI; Windows/Linux report an
|
||||
available mobile backend for the first time.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- adb device in `offline` / `unauthorized` state → clear surfaced error, not a
|
||||
hang.
|
||||
- AVD boot timeout (cold boot can take minutes) → bounded wait with a
|
||||
cancel/error path; pane shows "booting".
|
||||
- SDK present but no AVDs and no devices → availability message points to "create
|
||||
an AVD in Android Studio".
|
||||
- Device rotates while a gesture is mid-flight → mapper re-reads frame size per
|
||||
event; no cached dimensions.
|
||||
- Multiple Android devices in one worktree → same "one active per worktree"
|
||||
model as iOS; explicit `--device <serial>` for the rest.
|
||||
- WebCodecs decoder error / key-frame loss → request a new keyframe from scrcpy
|
||||
and surface a transient "reconnecting" state (parity with the MJPEG reconnect
|
||||
in `mjpeg-frame-stream.ts`).
|
||||
- Windows path handling for the SDK and the pushed jar uses `path.join` only;
|
||||
never assume `/` or `\`.
|
||||
- App quit / pane close cleans up scrcpy-server, the adb tunnel, and (for managed
|
||||
AVDs) the emulator, mirroring `EmulatorBridge.onAppQuit()` /
|
||||
`destroyAllSessions()`.
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit tests (co-located, node Vitest, matching the module's existing test
|
||||
density):
|
||||
|
||||
- `android-sdk-discovery` — env precedence + per-OS default paths (mock env/fs;
|
||||
assert Windows/macOS/Linux branches).
|
||||
- `adb-devices` — parse `adb devices -l` (booted, offline, unauthorized,
|
||||
physical), boot-complete polling.
|
||||
- `avd-manager` — parse `emulator -list-avds`, boot command construction.
|
||||
- `scrcpy-control-channel` — exact byte encoding of touch/key/text/scroll
|
||||
messages.
|
||||
- `android-input-mapping` — normalized↔pixel round-trips, rotation, keycode map.
|
||||
- `uiautomator-tree` — XML → tree parsing, including malformed input.
|
||||
- `android-availability` + `emulator-availability` aggregation — iOS-only,
|
||||
Android-only, both, neither.
|
||||
- backend router resolution in `emulator-bridge` — id → backend, unknown id,
|
||||
cross-backend isolation.
|
||||
|
||||
Integration tests mock `adb` / `emulator` and the scrcpy sockets the same way
|
||||
the iOS tests mock serve-sim (`serve-sim-*.test.ts`, `emulator-bridge.test.ts`).
|
||||
|
||||
Electron validation (manual, on a machine with the Android SDK):
|
||||
|
||||
- Boot an AVD from Orca; confirm the live pane streams and is responsive.
|
||||
- tap / swipe / type / Back / Home / Recents / rotate.
|
||||
- `ax`, `install` + `launch`, `permissions grant`, `logcat`.
|
||||
- Cross-platform smoke on Windows (primary driver) and macOS (iOS + Android
|
||||
coexistence).
|
||||
|
||||
## Risks / verify-first
|
||||
|
||||
- **Electron H.264 WebCodecs decode** — verify in a step-0 spike that an Electron
|
||||
renderer `VideoDecoder` decodes scrcpy's H.264. Electron ships proprietary
|
||||
codec decode, so this is expected to pass. Fallback if not: a wasm H.264
|
||||
decoder (Broadway / tinyh264) or dropping to a main-process H.264→JPEG
|
||||
transcode — **neither changes the backend interface**, since the session
|
||||
advertises its codec.
|
||||
- **scrcpy-server protocol is version-coupled** to the bundled jar (same class of
|
||||
risk as serve-sim's private SimulatorKit APIs). Pin the version; record it next
|
||||
to the bundled jar.
|
||||
- **adb/emulator environment variance** — offline/unauthorized devices, cold-boot
|
||||
timeouts, missing SDK. All handled via explicit, surfaced errors.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Step-0 spike: confirm WebCodecs H.264 decode in the Electron renderer.
|
||||
2. Extract the `EmulatorBackend` interface + `IosEmulatorBackend` (pure
|
||||
refactor); keep all iOS tests green.
|
||||
3. Android device management (`android-sdk-discovery`, `adb-devices`,
|
||||
`avd-manager`, `android-availability`) + availability aggregation; surface
|
||||
Android devices in `orca emulator list`.
|
||||
4. scrcpy streaming (`scrcpy-server-deploy`, `scrcpy-video-stream`) + the video
|
||||
IPC channel + the renderer WebCodecs canvas path; live pane renders.
|
||||
5. scrcpy control (`scrcpy-control-channel`, `android-input-mapping`) +
|
||||
tap/gesture/type/button/rotate end-to-end.
|
||||
6. Extra capabilities: `ax`, `install`/`launch`, `permissions`, `logcat`.
|
||||
7. Renderer polish: Android hardware buttons, bezel, setup guide.
|
||||
8. Packaging (`scrcpy-server.jar` resource) + the `orca-emulator-android` skill.
|
||||
9. Tests at each step; typecheck + lint; Electron validation on Windows + macOS.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- Whether `orca emulator install`/`launch`/`logcat` should also be exposed for
|
||||
iOS later (iOS install is `xcrun simctl install`); v1 leaves them
|
||||
Android-only via capability flags.
|
||||
- Whether to expose an explicit `orca emulator boot <avd>` verb vs. folding boot
|
||||
into `attach`; initial version folds boot into `attach` (parity with iOS,
|
||||
which boots on attach) and adds a `--no-boot` opt-out.
|
||||
@@ -1,75 +0,0 @@
|
||||
# Automations Navigation Stack
|
||||
|
||||
## Problem
|
||||
|
||||
- `worktree-nav-history.ts` models view entries as `'tasks'` only; `'automations'` cannot be recorded or replayed.
|
||||
- `openTaskPage` records a view visit before switching view; `openAutomationsPage` does not.
|
||||
- `closeTaskPage` rewinds history index when closing from a `'tasks'` history node; `closeAutomationsPage` does not.
|
||||
- Keyboard history navigation already works on Automations (`Cmd/Ctrl+Alt+Arrow`), but titlebar Back/Forward is hidden there.
|
||||
- `setWorktreeNavViewActivator` is currently Tasks-sentinel oriented in types/comments and must be widened for Automations.
|
||||
|
||||
## Goal
|
||||
|
||||
Make Automations a first-class entry in the existing mixed worktree/page navigation stack, matching Tasks behavior for open, back/forward traversal, close-page rewind, and titlebar controls.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not add persistence for navigation history; the existing stack is session-only and renderer-local.
|
||||
- Do not preserve per-automation detail selection through Back/Forward beyond existing `selectedAutomationId` state.
|
||||
- Do not change Activity, Settings, Space, Skills, or terminal navigation behavior.
|
||||
- Do not add new shortcuts; reuse existing cross-platform `Cmd/Ctrl+Alt+Arrow` handling.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add explicit view-entry type.
|
||||
- `type WorktreeNavHistoryViewEntry = 'tasks' | 'automations'`.
|
||||
- `type WorktreeNavHistoryEntry = string | WorktreeNavHistoryViewEntry`.
|
||||
- Update `recordViewVisit`, `ViewActivateFn`, and `setWorktreeNavViewActivator` signatures accordingly.
|
||||
- Update `isLiveEntry` to treat both page sentinels as live.
|
||||
|
||||
2. Generalize history replay branch.
|
||||
- In `navigateToIndex`, dispatch page sentinels through `viewActivator(entry)` and worktree ids through `activator(id)`.
|
||||
- Keep page replay on `setActiveView(entry)` (not `openTaskPage`/`openAutomationsPage`) to avoid mutating `previousViewBefore*` and avoid appending history during replay.
|
||||
- Keep existing index semantics: update index only after successful activation path.
|
||||
|
||||
3. Record and close Automations like Tasks.
|
||||
- `openAutomationsPage`: call `recordViewVisit('automations')` before switching `activeView`.
|
||||
- `closeAutomationsPage`: if current history node is `'automations'`, rewind to `findPrevLiveWorktreeHistoryIndex(state)` when available; otherwise keep index unchanged.
|
||||
- This rewind must apply regardless of close trigger (Esc / header X / any direct `closeAutomationsPage` call site).
|
||||
|
||||
4. Align titlebar controls with shortcut scope.
|
||||
- Show titlebar Back/Forward when `activeView` is `terminal`, `tasks`, or `automations`.
|
||||
- Keep shortcut logic unchanged; it already includes Automations.
|
||||
|
||||
5. Tests.
|
||||
- `worktree-nav-history.test.ts`: add Automations sentinel coverage for replay path, adjacent dedupe, dead-worktree skip, and rewind/forward behavior.
|
||||
- `ui.test.ts`: add Automations open/close history-index parity tests with Tasks, including “only automations in history” no-op rewind.
|
||||
- `App.tsx`: assert Back/Forward controls render on Automations (not optional; this is where current behavior regressed from shortcut scope).
|
||||
- `worktree-activation` wiring test coverage (or equivalent integration assertion) should verify `setWorktreeNavViewActivator` accepts/replays both sentinels.
|
||||
|
||||
## Known residual quirks
|
||||
|
||||
- Replay uses `setActiveView(...)`, so `previousViewBeforeTasks/Automations` is not recomputed on back/forward landing. Close from a replayed page can return to stale `previousViewBefore*`; this is existing Tasks behavior.
|
||||
- History is capped at 50 entries. Long sessions may evict older entries, including page sentinels; this is existing behavior.
|
||||
- History is renderer-local and session-local (no persistence, no cross-window reconciliation).
|
||||
- Liveness is evaluated against current store state at navigation time. If a target worktree becomes invalid between target selection and activation, `activateAndRevealWorktree` may fail and index stays put.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- `A -> Automations -> B`, Back lands on Automations, Back again lands on A.
|
||||
- `A -> Automations -> Automations` records only one Automations entry.
|
||||
- `A -> Automations`, close rewinds index to A; Forward reopens Automations.
|
||||
- `Automations` as the only history entry: close leaves index at `0` (do not force `-1`, or Forward target is lost).
|
||||
- If the prior worktree was deleted while Automations is open, Back/Close rewind skips it and lands on the next live prior entry.
|
||||
- Back-to-Automations must not call `openAutomationsPage`, or it would overwrite `previousViewBeforeAutomations` and append duplicate history.
|
||||
- Shortcut labels and handling remain cross-platform (`⌘⌥` on Mac, `Ctrl+Alt` elsewhere).
|
||||
- Multi-window: each renderer has an independent history stack; no cross-window reconciliation is attempted.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update `worktree-nav-history.ts` types, live-entry predicate, and replay branch.
|
||||
2. Update `ui.ts` to record and rewind Automations visits.
|
||||
3. Update `worktree-activation.ts` comments/types for generalized view activator.
|
||||
4. Update `App.tsx` titlebar visibility and comments.
|
||||
5. Add/adjust unit tests for history slice, UI slice, and titlebar visibility.
|
||||
6. Run `worktree-nav-history.test.ts` and `ui.test.ts`, then `pnpm typecheck` and `pnpm lint`.
|
||||
@@ -1,183 +0,0 @@
|
||||
# Browser Normal Download Behavior
|
||||
|
||||
## Problem
|
||||
|
||||
The built-in browser does not behave like a normal desktop browser when a page downloads a file.
|
||||
|
||||
- `src/main/browser/browser-session-registry.ts:553` installs a `will-download` handler for browser sessions.
|
||||
- `src/main/browser/browser-manager.ts:952` pauses every download before Orca has a save path.
|
||||
- `src/renderer/src/components/browser-pane/BrowserPane.tsx:4915` shows a `Save` / `Cancel` prompt instead of starting the download.
|
||||
- `src/main/ipc/browser.ts:342` opens a native save dialog after the renderer clicks `Save`.
|
||||
- `src/main/browser/browser-manager.ts:1026` calls `DownloadItem.setSavePath()` only after that renderer + dialog round trip.
|
||||
- Electron's installed type contract says `setSavePath()` is only available during the session `will-download` callback (`node_modules/electron/electron.d.ts:8271`).
|
||||
|
||||
That late path assignment explains the "clicked Save, still did not save" failure mode and makes the flow higher-friction than Chrome/Safari/Edge defaults.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Orca treats downloads as renderer-approved actions. Electron treats the destination as a main-process `will-download` decision. The current flow crosses that boundary too late: it pauses the download, asks the renderer to approve it, asks the OS where to save it, and only then sets the save path.
|
||||
|
||||
## Goal
|
||||
|
||||
Make built-in browser downloads feel like a normal browser:
|
||||
|
||||
- Download starts automatically.
|
||||
- File saves to the OS Downloads directory by default.
|
||||
- Name collisions are resolved without overwriting.
|
||||
- Browser chrome shows progress and completion.
|
||||
- Completed downloads offer familiar actions such as opening or revealing the file.
|
||||
- Cancel remains available while downloading.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- A full persistent download manager/history.
|
||||
- A settings UI for "ask where to save every file."
|
||||
- Cross-device transfer of downloads from a remote browser host to a local client.
|
||||
- Changing generic filesystem downloads outside the browser.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Resolve the save path synchronously in main**
|
||||
|
||||
Add a small browser-specific destination module, e.g. `src/main/browser/browser-download-destination.ts`, that:
|
||||
|
||||
- uses `app.getPath('downloads')`;
|
||||
- normalizes the filename to a safe basename;
|
||||
- uses `path.join` for cross-platform paths;
|
||||
- avoids overwrites with browser-style suffixes (`report.csv`, `report (1).csv`);
|
||||
- checks existing files synchronously because Electron requires `setSavePath()` during `will-download`;
|
||||
- also checks a main-process reservation set for active browser downloads, so two same-name downloads that start before either file exists still get distinct paths;
|
||||
- keys reservations by a normalized absolute path, with platform-aware case folding where the target filesystem is conventionally case-insensitive, so `Report.csv` and `report.csv` cannot collide on Windows;
|
||||
- caps suffix attempts and fails the download with a clear error instead of spinning forever in a crowded Downloads directory.
|
||||
|
||||
Do not create a placeholder file just to reserve the path: Electron may treat an existing target as an overwrite. The in-memory reservation prevents Orca-internal concurrent collisions; an external process can still create the same path after the check, so this remains best-effort at the filesystem boundary.
|
||||
|
||||
2. **Set the destination during `will-download`**
|
||||
|
||||
In `BrowserManager.handleGuestWillDownload`, compute and reserve the path, then call `DownloadItem.setSavePath()` immediately while still in the `will-download` call stack. Do not pause for renderer approval, do not open `dialog.showSaveDialog`, and do not use `setSaveDialogOptions()` for this flow because that still preserves dialog behavior. If destination resolution or `setSavePath()` fails, release the reservation, cancel the item, and send or queue a failed terminal event with a specific, non-secret error.
|
||||
|
||||
3. **Track downloads from start to finish in main**
|
||||
|
||||
Replace the approval-centric state with browser download state:
|
||||
|
||||
- `downloading`: save path is already assigned; item is progressing, waiting on network, or temporarily interrupted.
|
||||
- `completed` / `failed` / `canceled`: terminal states sent to the renderer.
|
||||
|
||||
Register `updated` and `done` listeners immediately in `will-download`, store latest received-byte count plus any terminal result, and keep the existing guest-to-tab queue so downloads that start before registration still surface when the tab binds. Queue state snapshots, not every progress event: if a download finishes before the tab registers, flush a started row followed by its terminal state so the renderer does not drop an orphan progress/finish event. If an unregistered guest is destroyed or retired before the tab binds, cancel its queued active downloads and release their reservations instead of only dropping the pending queue entry. Send exactly one terminal event per download, detach only this feature's listener references during cleanup, and release the reserved path on every terminal path and on explicit cancel.
|
||||
|
||||
4. **Update IPC shape for browser-like status**
|
||||
|
||||
Keep the existing renderer event channels if that keeps the diff smaller, but change the payload contract from "approval requested" to "download started" semantics. Include `browserPageId` on start/progress/finish when the tab is known, include `savePath` on start and terminal data, and keep progress payloads to `downloadId`, byte counts, and transient state. Remove the renderer `acceptDownload` path for normal browser downloads and delete the save-dialog IPC test; `cancelDownload` remains useful while an item is active.
|
||||
|
||||
5. **Render browser chrome, not an approval prompt**
|
||||
|
||||
In `BrowserPane`, replace the `Save` / `Cancel` prompt with a compact download row/list under the toolbar:
|
||||
|
||||
- active row: filename, origin, progress label, `Cancel`;
|
||||
- completed row: filename, "Downloaded" status, `Open`, `Show`, dismiss;
|
||||
- failed/canceled row: filename, concise failure/cancel reason, dismiss.
|
||||
|
||||
Track more than one download per pane so a second download does not hide the first. Cap visible recent completed rows to a small number, such as three, to avoid growing the browser surface. This is a per-pane transient list, not persisted history.
|
||||
|
||||
6. **Use existing shell bridges for actions**
|
||||
|
||||
Use `window.api.shell.openFilePath(savePath)` for `Open` and `window.api.shell.openInFileManager(savePath)` for `Show`. `openPath` is a legacy reveal wrapper whose `void` return type prevents the preload bridge from reporting success or failure back to the renderer. If either action fails or the file has been moved/deleted, show an inline or toast failure that does not claim the file opened.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
Browser session will-download
|
||||
-> BrowserManager computes and reserves ~/Downloads/name.ext
|
||||
-> DownloadItem.setSavePath(path) during will-download
|
||||
-> BrowserManager tracks item and sends/queues browser download-started
|
||||
-> BrowserPane renders progress row
|
||||
-> DownloadItem updated/done
|
||||
-> BrowserManager sends progress/finished
|
||||
-> BrowserPane shows completed row with Open / Show
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Duplicate filenames must not overwrite existing files.
|
||||
- Simultaneous same-name downloads in the same Orca process must not choose the same path before either file exists.
|
||||
- Same-name reservation checks must respect platform path identity, including case-insensitive collisions on Windows.
|
||||
- External filesystem races between path selection and Chromium's file creation are unavoidable with `setSavePath()`; treat them as residual risk, not a guarantee.
|
||||
- Path traversal or separator-like filenames must collapse to a safe basename.
|
||||
- Empty filenames fall back to `download`.
|
||||
- Unknown total bytes should show received bytes or a generic "Downloading" state without broken math.
|
||||
- Multiple simultaneous downloads from the same tab must render independently.
|
||||
- A download that starts, progresses, or finishes before the webview registers must still appear once the tab binds.
|
||||
- A queued download whose guest is destroyed or retired before registration must be canceled and release its reserved path.
|
||||
- Since this design intentionally avoids a global persistent download manager, closing the owning browser tab must cancel active downloads rather than leaving hidden file writes with no chrome.
|
||||
- `setSavePath()` failure should not leave a hidden, still-running download.
|
||||
- `updated` events with `interrupted` state should keep the row honest without treating them as terminal; `done` is the terminal source of truth.
|
||||
- Explicit cancel and Electron's eventual `done` callback must not produce duplicate terminal UI events.
|
||||
- Completed file actions may fail if the file was moved/deleted after download; UI must report that honestly.
|
||||
- On Windows, Linux, and macOS, all paths are built with Node/Electron path APIs.
|
||||
- In SSH/remote workflows, the file is saved on the machine running the browser runtime; UI copy must not imply cross-machine transfer.
|
||||
- Web clients keep their existing "handled by server browser" behavior unless the server-side browser implements equivalent events.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: destination builder sanitizes names, preserves extensions, picks collision-free paths, respects active path reservations including platform path identity, releases reservations, and stops at a bounded suffix limit.
|
||||
- Unit: `BrowserManager.handleGuestWillDownload` calls `setSavePath()` during handling, does not pause/resume for approval, registers listeners immediately, sends started/progress/finished events exactly once, and cancels cleanly on destination failure.
|
||||
- Unit: queued download-started/progress/finished state flushes after guest registration with the latest state.
|
||||
- Unit: multiple download events for one browser page are tracked independently.
|
||||
- Unit: guest cleanup cancels queued-but-unbound active downloads and releases reserved paths.
|
||||
- Unit: tab unregister cancels every active download for that tab and releases reserved paths.
|
||||
- IPC: remove `browser:acceptDownload` and its native save-dialog test for normal downloads; keep `browser:cancelDownload` authorization coverage.
|
||||
- Renderer: download list state covers active, completed, failed, canceled, unknown-size, multiple same-tab downloads, bounded recent rows, and missing-file action failures.
|
||||
- Shell IPC/preload: `Open` uses `openFilePath`; `Show` uses `openInFileManager` and surfaces structured failure.
|
||||
- Electron validation: from a local test page, click a download link and verify a file appears in the OS Downloads folder without a save dialog.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
The changed browser chrome should be quiet and dense, matching the toolbar area around it. Use existing tokens (`background`, `foreground`, `muted-foreground`, `border`, `accent`) and shadcn `Button` variants/sizes. Do not use amber warning color for ordinary downloads. The active state should be readable without looking like an error. Buttons must not wrap, overlap, or resize the toolbar/pane unexpectedly. Copy must describe real state only: "Downloading", "Downloaded", "Canceled", or the specific failure.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
Stage 5 must capture:
|
||||
|
||||
1. Active download in progress, with no save prompt visible.
|
||||
2. Completed download row with file actions visible.
|
||||
3. A repeat download of the same filename showing successful completion without overwrite.
|
||||
4. Multiple browser downloads visible at once or in the recent-download cap.
|
||||
5. Adjacent browser toolbar smoke state after the download row is dismissed.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the destination builder and focused tests.
|
||||
2. Move BrowserManager download handling to immediate save-path assignment and progress tracking.
|
||||
3. Update browser IPC/preload/API types to remove approval semantics and carry save-path/status data.
|
||||
4. Refresh BrowserPane state and UI for active/recent downloads.
|
||||
5. Add renderer notices, tests, and localization catalog entries as required.
|
||||
6. Run targeted unit tests, typecheck, lint, then Electron validation with screenshots.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to local built-in browser download behavior. Reduced by excluding a preferences UI, persistent download history, and remote-to-local transfer. The smallest useful normal-browser behavior is automatic save to Downloads plus transient progress/completion chrome.
|
||||
- Architecture/data flow: Main owns destination choice and file writes because Electron requires `setSavePath()` during `will-download`. Renderer owns only display and user actions (`Cancel`, `Open`, `Show`, dismiss). Existing session policy installation remains the entry point; existing shell bridges handle file actions. Browser web clients remain unchanged.
|
||||
- Failure modes covered:
|
||||
- Late `setSavePath()` is removed by assigning the path synchronously in `will-download`.
|
||||
- Duplicate filenames use existing-file checks plus active path reservations instead of overwriting each other inside Orca.
|
||||
- Unsafe or empty filenames are normalized before `path.join`.
|
||||
- Downloads starting or finishing before tab registration are snapshotted and flushed in order.
|
||||
- Queued downloads are canceled if their guest is destroyed or retired before registration.
|
||||
- Multiple downloads no longer overwrite a single pane state.
|
||||
- Tab close/app shutdown cancels active items rather than leaving hidden downloads without chrome.
|
||||
- File action failures after external deletion are surfaced without overclaiming.
|
||||
- Test coverage required:
|
||||
- Unit: `src/main/browser/browser-download-destination.test.ts` for basename/suffix/collision/reservation behavior and bounded failure.
|
||||
- Unit: `src/main/browser/browser-manager.test.ts` for immediate `setSavePath`, listener registration, queued snapshots, progress/finish, cancellation, path-reservation release, and failure.
|
||||
- Unit/IPC: `src/main/ipc/browser.test.ts` removes the native save-dialog accept path and keeps cancel authorization.
|
||||
- Renderer: focused tests around download notice/list state if an existing seam is available; otherwise cover formatting helpers and rely on Electron validation for BrowserPane DOM behavior.
|
||||
- Electron: local server download verifies file creation in Downloads without save dialog.
|
||||
- Performance/blast radius: No startup cost. Per-download synchronous filesystem checks are bounded by the collision limit and happen only during `will-download`; this is acceptable for one user action but must not walk unbounded directories. Renderer IPC volume stays proportional to Electron download progress events already emitted. No migration.
|
||||
- UI quality bar: Validation should judge a neutral browser-toolbar-adjacent download row/list against `docs/STYLEGUIDE.md`: no amber error styling for ordinary progress, compact button sizes, no overlap/wrapping, honest copy, clear active/completed/failed hierarchy, and stable layout while progress changes.
|
||||
- Required review screenshots:
|
||||
1. Active download in progress with no save prompt.
|
||||
2. Completed download row with `Open` / `Show` actions.
|
||||
3. Repeat same-name download completed with distinct filename.
|
||||
4. Multiple downloads visible or capped as designed.
|
||||
5. Browser toolbar after dismissing download chrome.
|
||||
- Residual risks: Headless Electron may not reliably prove absence of a native save dialog; validation should instead verify no Orca `Save` prompt appears and the file lands in Downloads. If the OS Downloads path is redirected or unavailable, behavior depends on Electron's `app.getPath('downloads')` result.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,75 +0,0 @@
|
||||
# Claude Fable Weekly Usage Meter
|
||||
|
||||
## Problem
|
||||
|
||||
Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can show an explicit Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them.
|
||||
|
||||
Relevant code:
|
||||
|
||||
- `src/shared/rate-limit-types.ts:46` models provider usage with `session` and `weekly` windows.
|
||||
- `src/main/rate-limits/claude-fetcher.ts:373` maps OAuth `five_hour` and `seven_day` into Orca's Claude provider state.
|
||||
- `src/main/rate-limits/claude-pty.ts:18` parses hidden `claude` `/usage` output, but `WEEKLY_RE` only accepts `Current week`.
|
||||
- `src/renderer/src/components/status-bar/StatusBar.tsx:1112` renders both session and weekly windows when both are present.
|
||||
- `src/renderer/src/components/status-bar/tooltip.tsx:138` includes weekly usage in the details popover.
|
||||
|
||||
Research:
|
||||
|
||||
- Official Claude Code statusline docs: [`rate_limits.five_hour.used_percentage` and `rate_limits.seven_day.used_percentage`](https://code.claude.com/docs/en/statusline#available-data), plus matching `resets_at`, are the 5-hour and 7-day rate-limit fields.
|
||||
- `harveyxiacn/cc-usage-monitor` uses Claude Code's statusline `rate_limits` data and shows both [`5h` and `7d` windows](https://github.com/harveyxiacn/cc-usage-monitor), matching Orca's existing `session` and `weekly` model.
|
||||
- `leeguooooo/claude-code-usage-bar` independently exposes the same [`5h` and `7d` rate-limit usage](https://github.com/leeguooooo/claude-code-usage-bar) in a Claude Code statusLine integration.
|
||||
- Fable is not part of the documented statusline schema above. Orca only treats it as Fable weekly usage when the live `/usage` panel renders a standalone `Fable` label or an OAuth response uses an explicit weekly/seven-day Fable field name.
|
||||
|
||||
Redacted live `/usage` shape this parser targets:
|
||||
|
||||
```text
|
||||
Plan usage limits
|
||||
|
||||
Current session
|
||||
18% remaining
|
||||
Resets in 2h 10m
|
||||
|
||||
Current week (all models)
|
||||
84% left
|
||||
Resets in 5d 4h
|
||||
|
||||
Fable
|
||||
42% consumed
|
||||
Resets in 3d 2h
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
Make Orca's existing Claude status-bar meter show the weekly Claude and Fable usage windows whenever Claude Code reports them, including newer `/usage` panel wording such as `Weekly limits`, `Fable`, or `7-day`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not infer subscription quota from token logs.
|
||||
- Do not spend user Claude quota during automated verification.
|
||||
- Do not change provider account switching, polling cadence, or OAuth credential handling.
|
||||
|
||||
## Design
|
||||
|
||||
1. Keep `ProviderRateLimits.weekly` as the canonical generic 7-day UI field. OAuth already maps `seven_day` to `weekly`, and the status bar already renders it next to the 5-hour window.
|
||||
2. Add `ProviderRateLimits.fableWeekly` as a distinct optional Claude window so the chip and popover can render Session, Weekly, and Fable simultaneously.
|
||||
3. Accept both OAuth `utilization` windows and Claude Code-style `used_percentage` windows with epoch-second `resets_at` values.
|
||||
4. Broaden the hidden Claude CLI parser so the generic weekly label accepts both old `Current week` wording and newer usage/statusline wording: `Weekly limits`, `Weekly usage`, `weekly rate limit`, and `7-day`.
|
||||
5. Parse only a standalone `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`; ambiguous Fable copy is a section boundary, not a meter.
|
||||
6. Broaden percent parsing to treat `consumed` like `used`, because Anthropic describes rate-limit percentages as consumed.
|
||||
7. Add focused tests for the new weekly wording and retain existing old-copy coverage.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Weekly data may be absent for API-key users or before the first Claude API response; keep `weekly: null`.
|
||||
- The hidden PTY fallback may still only return session data; the status bar should continue showing the 5-hour meter without error.
|
||||
- Reset timestamps/descriptions may be absent from CLI output; keep `resetsAt: null` and parse only visible reset text.
|
||||
- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless an explicit weekly/seven-day field or standalone `Fable` label is present.
|
||||
- A bare OAuth `fable` field is ambiguous because it does not encode the window length; ignore it until the upstream contract is clearer.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update OAuth window mapping for statusline-style percentages, reset timestamps, and distinct Fable weekly fields when present.
|
||||
2. Update `claude-pty` weekly label, Fable label, and percent parsing.
|
||||
3. Add focused tests for statusline-style OAuth data, `Weekly limits`, `Fable`, and `7-day` wording.
|
||||
4. Run focused tests, then typecheck/lint.
|
||||
5. Validate in Electron by injecting a Claude provider state with 5-hour, generic weekly, and Fable weekly data and capturing status-bar screenshots.
|
||||
6. Commit, push, open a PR, and attach screenshots in a PR comment.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Claude Scoped OAuth Usage Limits
|
||||
|
||||
## Problem
|
||||
|
||||
Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere.
|
||||
|
||||
- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows.
|
||||
- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names.
|
||||
- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows.
|
||||
- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics.
|
||||
- Do not remove the existing PTY supplement or legacy field compatibility.
|
||||
- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix.
|
||||
|
||||
## Design
|
||||
|
||||
1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing.
|
||||
2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite.
|
||||
3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp.
|
||||
4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks.
|
||||
5. Keep malformed, unrelated, or absent entries non-fatal. Do NOT gate on `is_active`: it marks which limit is currently binding, not whether the entry's data is valid, so an `is_active: false` Fable entry with a finite `percent` must still render (#8979). Accept a missing activity flag for compatibility.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- OAuth response
|
||||
- `limits[].weekly_scoped` Fable -> `fableWeekly`
|
||||
- otherwise legacy explicit Fable field -> `fableWeekly`
|
||||
- otherwise existing optional PTY supplement
|
||||
- Existing provider state -> existing status-bar and details rendering
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `limits` is missing, null, malformed, or contains null entries.
|
||||
- A scoped entry names another model.
|
||||
- Fable percent is missing, non-numeric, or non-finite.
|
||||
- Fable is inactive (`is_active: false`) but still carries a finite `percent`/reset, so it must render (#8979).
|
||||
- `is_active` is omitted by an older server response but the remaining scoped entry is valid.
|
||||
- Both current and legacy fields exist; the current scoped entry wins.
|
||||
- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent.
|
||||
- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt.
|
||||
- Unit: assert scoped data wins over a legacy field.
|
||||
- Unit: assert malformed and unrelated scoped entries are ignored while legacy fallback remains available; assert an inactive-but-valid Fable entry still surfaces (#8979).
|
||||
- Regression: retain existing legacy-field and bare-`fable` behavior tests.
|
||||
- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet.
|
||||
- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh.
|
||||
2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the scoped OAuth response types and mapper.
|
||||
2. Add focused current-schema and compatibility regression tests.
|
||||
3. Run focused and repository checks.
|
||||
4. Validate the restored row in Electron and capture review screenshots.
|
||||
5. Commit, push, and open an unmerged PR.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable.
|
||||
- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement.
|
||||
- Failure modes covered: malformed optional data, unrelated models, inactive-but-valid limits (still rendered, #8979), missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
|
||||
- Test coverage required: current-schema success without PTY, precedence, inactive-but-valid rendering, malformed/unrelated entries, and legacy fallback.
|
||||
- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work.
|
||||
- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row.
|
||||
- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context.
|
||||
- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available.
|
||||
@@ -1,391 +0,0 @@
|
||||
# Claude Usage Tracking: CodexBar Parity Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Make Orca's Claude usage limit tracking behave like CodexBar's Claude implementation: automatic, resilient, and source-aware internally, without asking users to choose between OAuth, CLI, or other data sources.
|
||||
|
||||
This plan intentionally does not introduce new product behavior from scratch. Each proposed change is based on CodexBar's existing implementation.
|
||||
|
||||
## User-Facing Principle
|
||||
|
||||
Users should not need to pick a usage source.
|
||||
|
||||
The normal product behavior should remain automatic:
|
||||
|
||||
- Try the best live Claude usage source.
|
||||
- Repair or fall back when a source fails.
|
||||
- Keep the last useful usage snapshot visible when refresh is deferred or temporarily unavailable.
|
||||
- Show a specific, actionable status only when Orca cannot recover automatically.
|
||||
|
||||
Any "source planner" described below is internal plumbing only. It should not imply a visible source picker for normal users.
|
||||
|
||||
## CodexBar References
|
||||
|
||||
CodexBar's Claude implementation already separates source selection from execution:
|
||||
|
||||
- `ClaudeSourcePlanner.resolve(...)` builds an ordered automatic plan.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:173`
|
||||
- App auto mode tries OAuth, then CLI, then web.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:173`
|
||||
- CLI runtime auto mode tries web, then CLI.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:181`
|
||||
- `ClaudeUsageFetcher.StepExecutor` executes the selected path.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:461`
|
||||
- OAuth failures can trigger delegated Claude CLI refresh, then credentials are reloaded and OAuth is retried.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:324`
|
||||
- OAuth credential loading accounts for Keychain prompt policy and cached credentials.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:250`
|
||||
- Claude source types are explicit: auto, api, oauth, web, cli.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift:3`
|
||||
|
||||
CodexBar's Codex implementation is also a useful pattern for fallback discipline:
|
||||
|
||||
- Auto mode tries OAuth, then CLI.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:43`
|
||||
- Codex OAuth refresh is performed before usage fetch when credentials need refresh.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:169`
|
||||
- Fallback from OAuth to CLI is limited to failures the CLI can plausibly repair.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:199`
|
||||
- Codex CLI usage is fetched through `codex app-server` JSON-RPC.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/UsageFetcher.swift:1012`
|
||||
|
||||
## Current Orca Behavior To Preserve Or Change
|
||||
|
||||
Orca currently reads Claude OAuth credentials and calls Anthropic's OAuth usage endpoint:
|
||||
|
||||
- OAuth usage endpoint and headers are in `claude-fetcher.ts`.
|
||||
Reference: `src/main/rate-limits/claude-fetcher.ts:24`
|
||||
- OAuth credential reads intentionally let the server decide whether a token is valid because local expiry metadata is not authoritative.
|
||||
Reference: `src/main/rate-limits/claude-fetcher.ts:80`
|
||||
- System-default Keychain lookup intentionally mirrors Claude's legacy service ordering.
|
||||
Reference: `src/main/rate-limits/claude-fetcher.ts:193`
|
||||
- Renderer currently maps many auth-looking failures into "Refresh failed" / softer auth copy.
|
||||
Reference: `src/renderer/src/components/status-bar/tooltip.tsx:115`
|
||||
|
||||
Preserve:
|
||||
|
||||
- Managed-account safety around live Claude sessions and refresh-token rotation.
|
||||
- System-default support.
|
||||
- Existing OAuth usage endpoint mapping.
|
||||
- Existing PTY fallback parser behavior where safe.
|
||||
|
||||
Change:
|
||||
|
||||
- Do not treat a single OAuth failure as overall Claude usage failure.
|
||||
- Distinguish deferred, recoverable, fallbackable, and terminal failures.
|
||||
- Use CLI fallback and delegated refresh intentionally, following CodexBar's shape.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Add An Internal Claude Usage Refresh Plan
|
||||
|
||||
Create an internal planner that returns ordered attempts for the current account/runtime state.
|
||||
|
||||
Initial app automatic order should match CodexBar's app auto ordering:
|
||||
|
||||
1. OAuth usage API.
|
||||
2. CLI/PTY usage fallback.
|
||||
3. Web usage source later, only if Orca intentionally adopts CodexBar's web/cookie machinery.
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- `ClaudeSourcePlanner.makeSteps` app auto returns OAuth, CLI, web.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:173`
|
||||
- `ClaudeUsageDataSource` defines source identifiers separately from execution.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift:3`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Add a focused module near `src/main/rate-limits/`, for example `claude-usage-refresh-plan.ts`.
|
||||
- Keep it internal to main-process rate-limit refresh.
|
||||
- Do not expose a normal user setting for source selection.
|
||||
|
||||
### 2. Add Source Attempt Execution
|
||||
|
||||
Separate "which source should be tried" from "how each source fetches usage."
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- `StepExecutor.loadLatestUsage` switches on source and executes OAuth, web, or CLI.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:461`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Split Claude refresh into source attempt functions:
|
||||
- `fetchClaudeUsageViaOAuth(...)`
|
||||
- `fetchClaudeUsageViaCli(...)`
|
||||
- possible future `fetchClaudeUsageViaWeb(...)`
|
||||
- Keep the existing OAuth endpoint logic in the OAuth attempt.
|
||||
- Reuse existing `fetchViaPty` for the CLI attempt.
|
||||
|
||||
### 3. Classify OAuth Failures Before Deciding What To Do
|
||||
|
||||
Add structured OAuth failure classification.
|
||||
|
||||
Failure kinds should include:
|
||||
|
||||
- missing credentials
|
||||
- stale or unauthorized access token
|
||||
- refreshable credentials present but access token unavailable
|
||||
- delegated refresh required
|
||||
- delegated refresh deferred by live Claude session
|
||||
- Keychain denied or unavailable
|
||||
- missing required scope
|
||||
- network/proxy/DNS failure
|
||||
- Anthropic server failure
|
||||
- response parse failure
|
||||
- rate-limited usage endpoint
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- Codex fallback is intentionally limited to specific OAuth credential/auth errors.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:199`
|
||||
- Claude OAuth maps credential/fetch errors distinctly before retrying or surfacing.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:286`
|
||||
- Missing Claude OAuth scope gets a specific actionable message.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:444`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Add `claude-usage-error-classification.ts`.
|
||||
- Use classification to decide:
|
||||
- retry OAuth after delegated refresh
|
||||
- fall back to CLI
|
||||
- defer because live Claude owns refresh
|
||||
- surface terminal error
|
||||
|
||||
### 4. Add Delegated Claude CLI Refresh For Safe Cases
|
||||
|
||||
When OAuth credentials appear stale or access is unauthorized, let Claude CLI repair its own credentials, then re-read credentials and retry OAuth once.
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- Expired Claude OAuth credentials can trigger `loadAfterDelegatedRefresh`.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:324`
|
||||
- CodexBar asserts delegated refresh is allowed in the current interaction context before running it.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:345`
|
||||
- After delegated refresh, CodexBar invalidates changed credential caches, syncs Keychain without prompt, reloads credentials, and retries OAuth.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:369`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Add a guarded delegated refresh step for system-default accounts and managed accounts only when no live Claude PTY owns the same credentials.
|
||||
- After delegation, re-run Orca's existing credential read order and retry the OAuth usage request once.
|
||||
- Keep this disabled for cases where Orca already knows live Claude is using/rotating that credential set.
|
||||
|
||||
### 5. Preserve Managed Live-Session Safety, But Change The State
|
||||
|
||||
Current Orca managed-account behavior avoids rotating refresh tokens while a live Claude terminal may rotate them. Keep that safety rule.
|
||||
|
||||
Change the user-visible outcome from generic failure to a deferred state.
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- Delegated refresh is gated by interaction policy and can be unavailable rather than blindly attempted.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:345`
|
||||
- CodexBar tracks delegated refresh outcomes and reports them distinctly.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:350`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Add a first-class `deferredByLiveClaudeSession` usage refresh outcome.
|
||||
- Keep last successful snapshot visible if present.
|
||||
- Tooltip/status should say the refresh is waiting for the live Claude session rather than "Refresh failed."
|
||||
|
||||
### 6. Apply The Same Recovery Logic To System Default
|
||||
|
||||
Do not assume failures are managed-account only.
|
||||
|
||||
System default can still fail when:
|
||||
|
||||
- Keychain access is denied.
|
||||
- Claude rotated credentials and Orca has stale cached data.
|
||||
- The access token is rejected by the OAuth usage endpoint.
|
||||
- Required scope is missing.
|
||||
- Network/proxy settings block `api.anthropic.com`.
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- OAuth credential loading accounts for cached credentials, Keychain prompt policy, and Keychain access.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:250`
|
||||
- Post-delegation retry re-reads credentials instead of reusing the stale token.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:408`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Use the same internal plan for system default and managed accounts.
|
||||
- For system default, allow delegated refresh when safe.
|
||||
- Re-read Keychain / `.credentials.json` after any delegated repair.
|
||||
|
||||
### 7. Store Attempt Metadata With Usage State
|
||||
|
||||
Add metadata to Claude provider state so the renderer can show precise state without parsing raw error strings.
|
||||
|
||||
Suggested metadata:
|
||||
|
||||
- `source`
|
||||
- `attemptedSources`
|
||||
- `failureKind`
|
||||
- `credentialSource`
|
||||
- `authProvenance`
|
||||
- `deferredByLiveClaudeSession`
|
||||
- `lastSuccessfulSource`
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- Source labels are preserved in fetch results for Codex OAuth and CLI.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:132`
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:253`
|
||||
- Claude planner logs selected source and ordered steps.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:512`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Extend shared rate-limit types conservatively.
|
||||
- Keep renderer copy based on structured state, not regex-only string matching.
|
||||
- Continue to include sanitized diagnostics in main-process logs.
|
||||
|
||||
### 8. Replace Generic UI Failure For Known States
|
||||
|
||||
Known recoverable or deferred states should not render as "Refresh failed."
|
||||
|
||||
Suggested user-facing states:
|
||||
|
||||
- `Waiting for Claude session`
|
||||
- `Refreshing sign-in`
|
||||
- `Claude CLI unavailable`
|
||||
- `Network issue`
|
||||
- `Usage unavailable`
|
||||
- `Refresh failed` only for unknown or terminal failures
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- Claude OAuth failures distinguish expired/delegated-refresh states from generic parse/network failure.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:338`
|
||||
- Codex OAuth refresh errors include specific relogin messages for expired/revoked/reused refresh tokens.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift:17`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Update status-bar tooltip mapping to prefer structured `failureKind`.
|
||||
- Keep the current auth regex fallback only for older/unstructured provider errors.
|
||||
|
||||
### 9. Add Debug-Only Source Visibility, Not A Normal Picker
|
||||
|
||||
Normal users should see automatic behavior only.
|
||||
|
||||
For debugging/support, it is useful to expose what happened:
|
||||
|
||||
- attempted OAuth
|
||||
- delegated refresh attempted/skipped
|
||||
- attempted CLI
|
||||
- final source used
|
||||
- sanitized failure kind
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- CodexBar has explicit source labels and source modes, but the app default remains automatic.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:35`
|
||||
- Claude planner can describe selected and ordered sources.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:99`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Add internal logs and possibly a debug tooltip line.
|
||||
- Avoid a normal user-facing setting unless support data later proves it is needed.
|
||||
|
||||
### 10. Defer Web/Cookie Source Until After OAuth + CLI Parity
|
||||
|
||||
Do not implement Claude web/cookie tracking in the first pass.
|
||||
|
||||
CodexBar basis:
|
||||
|
||||
- CodexBar supports web as a later fallback source in Claude app auto.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:177`
|
||||
- CodexBar's web paths are substantial and involve browser session/cookie machinery.
|
||||
Reference: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift:100`
|
||||
|
||||
Orca implementation target:
|
||||
|
||||
- Put web source behind a future milestone.
|
||||
- First match the high-value resilience behavior: OAuth classification, delegated refresh, CLI fallback, deferred live-session state.
|
||||
|
||||
## Suggested PR Sequence
|
||||
|
||||
### PR 1: Internal Plan + Structured Outcomes
|
||||
|
||||
- Add internal Claude usage refresh plan.
|
||||
- Add structured source attempt result and failure classification.
|
||||
- Preserve existing behavior by executing only the current OAuth path first.
|
||||
- Update logs and tests around classification.
|
||||
|
||||
CodexBar references:
|
||||
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:173`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:461`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:199`
|
||||
|
||||
### PR 2: CLI Fallback In Automatic Mode
|
||||
|
||||
- Add CLI source attempt using existing PTY parser.
|
||||
- Fall back from OAuth to CLI for classified recoverable/auth cases.
|
||||
- Preserve last successful snapshot when fallback fails.
|
||||
|
||||
CodexBar references:
|
||||
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:173`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:461`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/UsageFetcher.swift:1012`
|
||||
|
||||
### PR 3: Delegated Refresh + Retry
|
||||
|
||||
- Add safe delegated Claude CLI refresh for stale OAuth states.
|
||||
- Re-read credentials after delegation.
|
||||
- Retry OAuth once.
|
||||
- Do not run delegated refresh when a live managed Claude session owns the credential rotation.
|
||||
|
||||
CodexBar references:
|
||||
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:324`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:345`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:369`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:408`
|
||||
|
||||
### PR 4: Renderer State Copy
|
||||
|
||||
- Replace generic `Refresh failed` for known Claude states.
|
||||
- Prefer structured failure/deferred metadata over error regexes.
|
||||
- Keep regex fallback for unstructured errors.
|
||||
|
||||
CodexBar references:
|
||||
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:338`
|
||||
- `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift:17`
|
||||
|
||||
## Test Plan
|
||||
|
||||
Add unit tests for:
|
||||
|
||||
- OAuth success remains OAuth success.
|
||||
- OAuth missing credentials falls back to CLI.
|
||||
- OAuth unauthorized attempts delegated refresh when safe.
|
||||
- Delegated refresh re-reads credentials and retries OAuth once.
|
||||
- Managed live Claude session returns deferred state instead of generic failure.
|
||||
- System-default stale credentials use the same repair path.
|
||||
- Missing scope produces actionable message.
|
||||
- Network failure is classified separately from auth failure.
|
||||
- CLI fallback unavailable preserves last known snapshot when present.
|
||||
- Renderer maps known states to specific labels.
|
||||
|
||||
CodexBar references:
|
||||
|
||||
- Source planning: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift:173`
|
||||
- OAuth delegated refresh: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift:324`
|
||||
- Fallback discipline: `/Users/jinwoohong/stably/codexbar/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift:199`
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add a normal user-facing source picker.
|
||||
- Do not add Claude web/cookie tracking in the first implementation pass.
|
||||
- Do not weaken managed-account live-session token safety.
|
||||
- Do not rely on raw error-message regexes as the primary control flow.
|
||||
@@ -1,141 +0,0 @@
|
||||
# Cmd-J Tab and Agent Session Search
|
||||
|
||||
## Problem
|
||||
|
||||
Cmd-J can already search worktrees, settings, actions, browser pages, and simulator tabs, but not ordinary open terminal/editor tabs or their agent-session context. The existing open-tab list only combines browser and simulator matches in `WorktreeJumpPalette.tsx` (`browserItems`, `simulatorItems`, `openTabItems`). Terminal/editor activation exists elsewhere, notably the shortcut activation path in `src/renderer/src/lib/tab-number-shortcuts.ts`, but Cmd-J has no searchable row for those tab kinds. Agent prompt/session metadata is structured and bounded in `AgentStatusEntry`, `RetainedAgentEntry`, and `SleepingAgentSessionRecord`, yet Cmd-J does not index it.
|
||||
|
||||
## Goal
|
||||
|
||||
Let users open the existing Worktree Palette shortcut (`Cmd+J` on macOS, `Ctrl+Shift+J` on Windows/Linux by default) and type keywords from:
|
||||
|
||||
1. Open terminal and editor tab titles.
|
||||
2. Live or retained agent prompts/session ids associated with terminal panes.
|
||||
3. Sleeping agent session prompt/title/session-id metadata only when it can be attributed to an existing terminal tab.
|
||||
4. Worktree and repo metadata, matching the current browser/simulator tab behavior.
|
||||
|
||||
Selecting a matched tab should activate the owning worktree, split group, and tab, then focus the right surface for terminal/editor tabs.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not index terminal scrollback, file contents, or full assistant messages.
|
||||
- Do not add a new global search backend, IPC channel, persistence field, or database.
|
||||
- Do not change Cmd-J shortcut registration or platform key labels.
|
||||
- Do not alter worktree/settings/action ranking semantics outside combining the new tab result list.
|
||||
- Do not support launching/resuming a sleeping agent session from the result row; this feature only navigates to existing open tabs.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a focused tab-search helper, likely `src/renderer/src/lib/workspace-tab-palette-search.ts`, modeled after `simulator-palette-search.ts`. It should build searchable entries from `unifiedTabsByWorktree` for terminal tabs and editor-family tabs (`editor`, `diff`, `conflict-review`, `check-details`; markdown preview is an `editor` tab whose `OpenFile.mode` is `markdown-preview`). Do not fold browser/simulator into this helper unless tests prove their current scoring, empty-query comparators, rendering data, and activation behavior are unchanged.
|
||||
|
||||
2. Resolve displayed titles through existing sources:
|
||||
- Terminal: map the unified terminal tab's `entityId` to `tabsByWorktree[worktreeId]`, then use `resolveTerminalTabTitle` from `src/shared/tab-title-resolution.ts:3` with `settings.tabAutoGenerateTitle`.
|
||||
- Editor-family tabs: map the unified tab's `entityId` to `openFiles`, then use `getEditorDisplayLabel`, with relative path/full path as secondary searchable text. If the backing `OpenFile` is missing, do not create a searchable editor row; `setActiveFile` cannot safely restore it.
|
||||
- Terminal fallback: use `resolveUnifiedTabLabel` from `src/shared/tab-title-resolution.ts:17` only when a terminal's legacy `TerminalTab` record is missing but the unified terminal tab still exists.
|
||||
|
||||
3. Add agent-session keywords only from bounded structured state:
|
||||
- `agentStatusByPaneKey`: `prompt`, `agentType`, `state`, `providerSession.key/id`, `terminalTitle`, and capped `stateHistory[].prompt`.
|
||||
- `retainedAgentsByPaneKey`: the retained `entry` fields above plus the retained terminal tab title snapshot.
|
||||
- `sleepingAgentSessionsByPaneKey`: `prompt`, `agent`, `providerSession.key/id`, `state`, and `terminalTitle`.
|
||||
Attach metadata to a terminal row only when it matches that terminal by explicit `tabId`, by retained `tab.id`, or by pane-key prefix `${terminalTabId}:`, where `terminalTabId` is the legacy terminal id (`unifiedTab.entityId`). The worktree must also match when the record carries one. Do not include terminal scrollback, full assistant messages, `lastAssistantMessage`, `toolName`, or `toolInput`. Keep rendered snippets trimmed/capped even though hook payloads and history length are already bounded.
|
||||
|
||||
4. Rank with predictable field weights:
|
||||
- Displayed tab title: highest priority. Terminal title precedence must match `resolveTerminalTabTitle`: custom title, quick-command label, generated title only when enabled, raw title, then fallback. Editor title precedence must match `getEditorDisplayLabel`.
|
||||
- Agent prompt/session metadata: next, shown as supporting text when it caused the match.
|
||||
- Worktree name and repo name: lower priority, matching browser/simulator ordering.
|
||||
- Empty query: preserve current behavior by showing open tab rows. Existing browser/simulator helpers compute context-first scores, but `WorktreeJumpPalette` currently merges all open-tab item types by `result.score` and then item id; do not replace that final merge comparator unless tests deliberately cover the browser/simulator ordering change. New terminal/editor rows should encode deterministic context-first ordering in their scores: current tab, current worktree, worktree order, then group/tab order. Browser/simulator already index across all worktrees, including archived/default-hidden worktrees; terminal/editor rows may follow that open-tab behavior, but only for rows backed by existing unified tabs.
|
||||
|
||||
5. Integrate in `WorktreeJumpPalette.tsx` with a small new `WorkspaceTabPaletteItem` type and helper import. Add explicit store selectors for the new inputs (`openFiles`, `retainedAgentsByPaneKey`, `sleepingAgentSessionsByPaneKey`, `activeTabId`, `activeTabIdByWorktree`, `activeFileId`, `activeFileIdByWorktree`, and `activeTabTypeByWorktree` for current-row detection). Replace `openTabItems` with browser + simulator + workspace tab items sorted by the existing combined open-tab ordering, keeping the existing `OPEN TABS` section and caps.
|
||||
|
||||
6. Add a generic tab activation helper, likely `src/renderer/src/lib/workspace-tab-palette-activation.ts`, that mirrors `activateTabNumberShortcut` (`src/renderer/src/lib/tab-number-shortcuts.ts:57`) and existing simulator selection (`src/renderer/src/components/WorktreeJumpPalette.tsx:1090`). Re-resolve the target from `useAppStore.getState()` at selection time before mutating state:
|
||||
- `activateAndRevealWorktree(worktreeId)`.
|
||||
- Verify the target worktree, group, and unified tab still exist; the tab must still have the expected content type and still belong to the target worktree/group. Return with the same toast pattern before mutating state if any of those checks fail.
|
||||
- Terminal: activate web runtime session when needed using `getRuntimeEnvironmentIdForWorktree`, `isWebRuntimeSessionActive`, and `activateWebRuntimeSessionTab`; set `activeTab` to terminal `entityId`; set active type to `terminal`; then `focusTerminalTabSurface(entityId)`.
|
||||
- Editor-family tabs: verify `openFiles` still contains `entityId`; focus the target group, set active file to `entityId`, activate the unified tab id, then set active type to `editor`. Activating after `setActiveFile` preserves the specific split tab and is required for `check-details`, which `setActiveFile` does not implicitly re-find.
|
||||
- Simulator/browser behavior should remain unchanged unless the shared helper explicitly preserves existing semantics.
|
||||
|
||||
7. Render terminal/editor rows with the existing open-tab row density and tokens near `src/renderer/src/components/WorktreeJumpPalette.tsx:1697`: icon, highlighted title, current-tab/current-worktree chip, supporting text, worktree, host badge, and repo badge. Use existing icons (`SquareTerminal`, `FileText` or file-type icon if cheap and already available). Update the no-results subtitle at `src/renderer/src/components/WorktreeJumpPalette.tsx:1385` to include tab title/agent prompt without making it verbose.
|
||||
|
||||
8. Keep generated lists computed in `useMemo`; all required source data already exists in the renderer store, but `WorktreeJumpPalette` must subscribe to the slices it does not currently read. No new IPC, polling, filesystem reads, persistence fields, or shared mutable cache are needed. Search work should stay proportional to open unified tabs plus the small in-memory agent maps.
|
||||
|
||||
## Data flow
|
||||
|
||||
- Store slices expose `unifiedTabsByWorktree`, `tabsByWorktree`, `openFiles`, worktrees/repos, live agent statuses, retained agents, sleeping sessions, active group ids, and active terminal/editor ids.
|
||||
- `buildSearchableWorkspaceTabs(...)` produces one entry per open terminal/editor tab with resolved labels and bounded agent keywords.
|
||||
- `searchWorkspaceTabs(entries, query)` returns scored/highlighted results.
|
||||
- `WorktreeJumpPalette` maps results to open-tab rows.
|
||||
- User selects a row.
|
||||
- Selection re-resolves the target from the live store, activates the owning worktree/group/tab, and focuses terminal/editor as appropriate.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Custom title, quick-command label, generated title, and raw title should follow the same precedence as the tab bar.
|
||||
- Generated titles disabled: do not make generated terminal/unified labels the displayed title or a title-weighted match. It is acceptable to search bounded agent prompt/session metadata regardless of this setting.
|
||||
- Split groups: activate the result's `groupId`, not just the worktree's last active group.
|
||||
- Terminal unified tab missing its legacy terminal record: still show a fallback title from the unified tab and navigate if the unified tab exists.
|
||||
- Editor-family unified tab missing its `OpenFile`: omit the row and treat selection as stale if it disappears after search.
|
||||
- Markdown preview tabs: include them through `contentType: 'editor'` and `getEditorDisplayLabel`; do not look for a separate unified content type.
|
||||
- Current-row detection is type-specific: terminal active ids are legacy terminal ids, editor active ids are file ids, while unified group state stores unified tab ids.
|
||||
- Agent pane keys are composite `${tabId}:${leafId}`; only attach agent metadata when it is attributed to the result tab by explicit `tabId`, retained `tab.id`, or pane-key prefix and does not conflict with record `worktreeId`.
|
||||
- Multiple agent panes in one terminal tab: include all bounded agent prompts/metadata, but show only the best matching supporting snippet.
|
||||
- Sleeping sessions: search metadata only when tied to an existing terminal tab; selecting the row must navigate to that tab and must not call resume/launch logic, regardless of `origin`.
|
||||
- Archived/default-hidden worktrees: preserve current open-tab search behavior by indexing open tabs across all worktrees.
|
||||
- SSH/web runtime tabs: activation must call the existing runtime activation path where terminal/browser tab shortcuts already do.
|
||||
- One-character query: keep the existing two-character minimum only for settings/actions; open-tab search follows current browser/simulator tab search behavior and may match one character.
|
||||
- Stale records: ignore agent records whose tab id cannot be tied to an existing open tab; do not create standalone agent-session rows.
|
||||
- Duplicate metadata from live + retained + sleeping records: de-duplicate by pane key and prefer live, then retained, then sleeping for keywords/supporting snippets.
|
||||
- External mutations during selection: if the target tab, backing `OpenFile`, group, or worktree disappeared after search, close nothing and show the same toast-and-return pattern used for missing browser/simulator rows. Validate all of these before calling `focusGroup`, because `focusGroup` itself will stamp the requested group id even if the group was removed.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: add `workspace-tab-palette-search.test.ts` covering terminal title precedence, generated-title disabled behavior, editor label/path search for every editor-family content type and markdown preview mode, agent prompt/session search, retained/sleeping metadata attribution by legacy terminal id, stale/orphan metadata exclusion, split-group current-tab detection, and deterministic ordering.
|
||||
- Unit: update/add `WorktreeJumpPalette` tests only if there is an existing lightweight component harness; otherwise cover integration behavior through pure helper tests and targeted activation helper tests.
|
||||
- Unit: add activation tests for a helper if extracted from `WorktreeJumpPalette`, including terminal web-runtime activation, editor-family activation for `editor`/`diff`/`conflict-review`/`check-details`, missing tab/group/backing-file/worktree failures, and split group focus.
|
||||
- Regression: ensure existing `simulator-palette-search.test.ts` and `palette-results.test.ts` still pass.
|
||||
- Validation: Electron golden path for searching a terminal tab title/agent prompt and selecting it, plus an editor-family tab selection; adjacent smoke for existing browser/simulator open-tab rows.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
User-visible. The new rows must look like the existing Open Tabs rows: same spacing, typography, selection state, highlight weight, badges, truncation, host/repo badges, and row density. Long tab titles, prompts, repo names, and worktree names must truncate without overlap or layout jitter in the 736px palette and under the existing `max-w-[94vw]` mobile/narrow constraint. Use documented tokens and existing shadcn/cmdk row primitives; no new color values, font sizes, shadows, or card styling.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Empty Cmd-J palette with Open Tabs showing a terminal/editor tab alongside existing tab rows.
|
||||
2. Typed query matching a terminal tab title.
|
||||
3. Typed query matching an agent prompt/session keyword with supporting text visible.
|
||||
4. Typed query matching an editor tab title or path.
|
||||
5. Adjacent smoke: existing browser or simulator tab search result still appears and keeps its styling.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the pure tab-search helper and unit tests.
|
||||
2. Add/extract a small tab-activation helper if needed and unit-test it.
|
||||
3. Wire `WorktreeJumpPalette` to build/search/render/select workspace tab rows.
|
||||
4. Update no-results copy and imports.
|
||||
5. Run targeted tests, typecheck, lint.
|
||||
6. Validate in Electron and capture required screenshots.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: reduced to open terminal/editor tab navigation plus bounded agent prompt/session-id/title metadata. No terminal scrollback, file-content search, standalone sleeping-session rows, or resume/launch actions.
|
||||
- Architecture/data flow: renderer-only helper fed by explicit `WorktreeJumpPalette` store snapshots; selection re-resolves live state and delegates to a small activation helper that preserves current worktree/group/tab and web-runtime activation boundaries.
|
||||
- Failure modes covered:
|
||||
- stale tab/group/backing file/worktree between search and select -> toast and no state mutation beyond current palette behavior
|
||||
- duplicated live/retained/sleeping metadata -> de-dupe by pane key with live records preferred
|
||||
- orphan or cross-worktree agent records -> ignored unless tied to an existing terminal tab
|
||||
- split groups -> activate result `groupId`
|
||||
- generated titles disabled or overridden -> displayed title follows tab-bar precedence while agent prompt remains searchable
|
||||
- SSH/web runtime -> reuse existing runtime activation call shape
|
||||
- multi-window renderer state -> no shared cache or persisted search index
|
||||
- Test coverage required:
|
||||
- `src/renderer/src/lib/workspace-tab-palette-search.test.ts`: title precedence, editor path/title across editor-family types including markdown preview, agent prompt/session id, retained/sleeping attribution by legacy terminal id, duplicate metadata, orphan exclusion, current-tab/current-worktree ordering
|
||||
- `src/renderer/src/lib/workspace-tab-palette-activation.test.ts`: terminal/editor-family activation, split group focus, web-runtime terminal activation, missing tab/group/backing-file/worktree failures
|
||||
- existing `src/renderer/src/lib/simulator-palette-search.test.ts` and `src/renderer/src/components/cmd-j/palette-results.test.ts` unchanged/pass
|
||||
- Performance/blast radius: no new IPC, persistence, polling, filesystem search, or terminal output indexing. Work is proportional to current open tabs and small bounded agent maps already in memory.
|
||||
- UI quality bar: Electron validation must compare new rows with existing Open Tabs rows against `docs/STYLEGUIDE.md`, with no new colors/shadows/font sizes and no overflow/overlap under narrow palette width.
|
||||
- Required review screenshots:
|
||||
1. Empty Cmd-J palette with terminal/editor rows in Open Tabs.
|
||||
2. Typed query matching a terminal title.
|
||||
3. Typed query matching an agent prompt/session keyword.
|
||||
4. Typed query matching an editor tab title/path.
|
||||
5. Existing browser or simulator tab search still styled correctly.
|
||||
- Residual risks: Electron validation may need to seed a live agent prompt; if a real prompt cannot be created safely, use an existing local non-mutating agent tab or halt before PR creation with nearest screenshots.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Compact Worktree Card Setting Graduation
|
||||
|
||||
## Problem
|
||||
|
||||
`experimentalCompactWorktreeCards` is exposed in two places: the Experimental settings pane and the
|
||||
sidebar workspace options menu. The sidebar menu already presents the durable user-facing model as
|
||||
`Card layout` with `Detailed` and `Compact`, so the Experimental row is redundant and makes the same
|
||||
preference look like two separate features.
|
||||
|
||||
## Goal
|
||||
|
||||
Graduate the preference out of Experimental. Keep the sidebar workspace options menu as the
|
||||
canonical control, preserve existing users' saved compact-card choice, and remove the Experimental
|
||||
pane row/search entry.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change worktree card compact layout behavior.
|
||||
- Do not redesign the workspace options menu.
|
||||
- Do not remove workspace card property or agent activity layout controls.
|
||||
- Do not change SSH, provider metadata, prompt-cache, conflict, or port behavior.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add `compactWorktreeCards` to `GlobalSettings` and defaults.
|
||||
2. Preserve backward compatibility by hydrating `compactWorktreeCards` from legacy
|
||||
`experimentalCompactWorktreeCards` when the new field is absent.
|
||||
3. Update sidebar/worktree-card reads and writes to use `compactWorktreeCards`.
|
||||
4. Remove the Experimental pane row and search entry for `Compact worktree cards`.
|
||||
5. Keep the legacy optional type field only as a read-only migration input.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Persistence loads settings with defaults.
|
||||
- If `compactWorktreeCards` is missing, persistence copies the old
|
||||
`experimentalCompactWorktreeCards` value, falling back to the default.
|
||||
- The sidebar `Card layout` menu writes `compactWorktreeCards`.
|
||||
- `WorktreeCard` reads `settings?.compactWorktreeCards === true`.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Old profiles with `experimentalCompactWorktreeCards: true` still render compact cards.
|
||||
- Old profiles with the flag missing still default to detailed cards.
|
||||
- Search in Settings no longer finds a duplicate Experimental result.
|
||||
- Compact-card behavior remains unchanged for metadata rows, unread placement, main-worktree
|
||||
marker, SSH icons, prompt-cache state, conflicts, sparse checkout badges, and inline agents.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Update default-settings tests for `compactWorktreeCards`.
|
||||
- Add a persistence migration test for legacy `experimentalCompactWorktreeCards`.
|
||||
- Update Experimental pane tests to assert the row/search entry is absent.
|
||||
- Update WorktreeCard tests to set `compactWorktreeCards`.
|
||||
- Run focused tests for constants, persistence, ExperimentalPane, and WorktreeCard compact behavior.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
The Experimental pane should simply omit the redundant row without leaving spacing gaps. The
|
||||
workspace options menu should continue to show `Card layout` with `Detailed` and `Compact`.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Experimental pane without `Compact worktree cards`.
|
||||
2. Sidebar workspace options menu still showing `Card layout`.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the new setting and legacy hydration.
|
||||
2. Update card/menu consumers.
|
||||
3. Remove the Experimental pane/search entry.
|
||||
4. Update focused tests.
|
||||
5. Validate the two visible settings surfaces.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: kept to one setting graduation; no card layout behavior changes.
|
||||
- Architecture/data flow: existing settings persistence remains the boundary; renderer consumers read
|
||||
the new key, persistence bridges legacy profiles.
|
||||
- Failure modes covered:
|
||||
- Existing compact users losing the preference: migration copies the legacy flag.
|
||||
- Duplicate settings search result: Experimental search entry is removed.
|
||||
- Renderer/main mismatch: `GlobalSettings`, defaults, and consumers are updated together.
|
||||
- Test coverage required:
|
||||
- `src/shared/constants.test.ts` for the default.
|
||||
- `src/main/persistence.test.ts` for legacy hydration.
|
||||
- `src/renderer/src/components/settings/ExperimentalPane.test.tsx` for removal.
|
||||
- Existing WorktreeCard tests updated to the new key.
|
||||
- Performance/blast radius: no new IPC, polling, file watching, or provider work.
|
||||
- UI quality bar: Experimental pane has no orphan row; sidebar card layout control remains present.
|
||||
- Required review screenshots:
|
||||
1. Experimental pane without the compact-card toggle.
|
||||
2. Sidebar workspace options menu with `Card layout`.
|
||||
- Residual risks: old profiles may keep the legacy key on disk until settings are next saved, but
|
||||
runtime behavior uses the migrated value.
|
||||
@@ -1,126 +0,0 @@
|
||||
# Configurable Open In Menu
|
||||
|
||||
## Current behavior (code-checked)
|
||||
|
||||
- `WorktreeOpenInMenu` renders exactly two entries: `VS Code` and platform file manager (`Finder`/`File Explorer`/`File Manager`).
|
||||
- `openWorktreePath()` blocks local launches for remote/server context via `isLocalPathOpenBlocked(...)` before IPC.
|
||||
- Renderer calls `window.api.shell.openInExternalEditor(path)` with one argument only.
|
||||
- Preload/API types expose `openInExternalEditor(path: string)` only.
|
||||
- Main IPC `shell:openInExternalEditor` always launches hardcoded `code` (via `resolveCliCommand('code')`), validates absolute+exists path, and returns `{ ok: false, reason: 'not-absolute' | 'not-found' | 'launch-failed' }` on failure.
|
||||
- Settings persistence currently shallow-merges most fields. Only `notifications` and `telemetry` are deep-merged in main; renderer deep-merges `notifications`, `telemetry`, and `voice` locally.
|
||||
- `settings:set` returns the merged settings object, but the renderer currently ignores that return value and applies its own optimistic merge.
|
||||
|
||||
## Goal
|
||||
|
||||
Add configurable extra editor launchers (Cursor, Zed, custom) to the worktree sidebar `Open in` submenu while preserving:
|
||||
|
||||
- VS Code as fixed default entry.
|
||||
- existing local-path blocking for remote/SSH/server contexts.
|
||||
- existing path validation and failure-to-toast behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No remote editor launching.
|
||||
- No command existence checks during settings editing.
|
||||
- No shell template expansion, env interpolation, per-launcher cwd overrides, or argv parsing.
|
||||
- No removal/configuration of the built-in VS Code row.
|
||||
|
||||
## Data model
|
||||
|
||||
Add to `GlobalSettings`:
|
||||
|
||||
- `openInApplications?: OpenInApplication[]`
|
||||
- `type OpenInApplication = { id: string; label: string; command: string }`
|
||||
|
||||
Default in `getDefaultSettings()`:
|
||||
|
||||
- `openInApplications: []`
|
||||
|
||||
Why optional in type but present in defaults: keeps backward compatibility with older persisted files while giving runtime code a stable default after merge.
|
||||
|
||||
## Normalization contract
|
||||
|
||||
Implement one shared normalizer in `src/shared/` and use it in both renderer update path and main persistence update path. It must run in main before persistence.
|
||||
|
||||
Rules:
|
||||
|
||||
- trim `label`/`command`.
|
||||
- drop rows with empty `label` or empty `command`.
|
||||
- drop rows with duplicate `id` (keep first).
|
||||
- if `id` is missing/blank, generate one (e.g. `crypto.randomUUID()` in renderer before save).
|
||||
- cap length (e.g. 8).
|
||||
|
||||
Do not dedupe by `command`: users may intentionally keep separate labels for the same command with different wrappers/scripts later.
|
||||
|
||||
Main remains source of truth. Renderer normalization is UX only; main normalization must always run.
|
||||
|
||||
Also normalize on load (not only on `settings:set`) so externally edited/stale persisted rows are repaired on startup.
|
||||
|
||||
## IPC and launch behavior
|
||||
|
||||
Change signature across shared preload surface and main handler:
|
||||
|
||||
- `openInExternalEditor(path: string, command?: string)`
|
||||
|
||||
Main launch behavior:
|
||||
|
||||
- if `command` is missing/blank after trim, fall back to `code`.
|
||||
- otherwise resolve and launch that command token (same resolution path currently used for `code` via `resolveCliCommand`).
|
||||
- keep existing `validateLocalPathTarget(...)` path checks.
|
||||
- keep `getSpawnArgsForWindows(command, [path])` path for Windows.
|
||||
- keep detached spawn semantics and `launch-failed` mapping.
|
||||
|
||||
Constraint to document in UI copy: command is not shell-parsed. `cursor --new-window` is treated as a binary name and will fail. Users must provide an executable command (or wrapper script) only.
|
||||
|
||||
Important: this is not “free”. Every call site and type layer (`preload/index.ts`, `preload/api-types.ts`, renderer callers, `shell.test.ts`) must be updated together.
|
||||
|
||||
## Menu behavior
|
||||
|
||||
`WorktreeOpenInMenu` order:
|
||||
|
||||
1. `VS Code` (fixed)
|
||||
2. configured `openInApplications`
|
||||
3. file manager
|
||||
|
||||
Each configured row invokes `openInExternalEditor(worktreePath, app.command)`.
|
||||
|
||||
Remote/local guard stays exactly where it is now (`openWorktreePath`) so all rows (including file manager) remain blocked consistently in remote contexts.
|
||||
|
||||
## Settings UI
|
||||
|
||||
Add General section: `Open In Menu`.
|
||||
|
||||
- static note: VS Code is always included.
|
||||
- presets: add Cursor (`cursor`) and Zed (`zed`).
|
||||
- editable rows for label/command.
|
||||
- remove row.
|
||||
- add custom row.
|
||||
|
||||
Search indexing: add entries in `general-search.ts` so settings search can discover this section.
|
||||
|
||||
## Consistency and concurrency
|
||||
|
||||
- Multi-window: renderer has a `settings:changed` listener, but main currently emits that event only for View > Appearance toggles. `settings:set` does not broadcast generic updates. So edits to `openInApplications` in one window are not guaranteed to appear live in another until reload/fetch.
|
||||
- Renderer optimistic merge is temporary UI state only. Main write result is authoritative and may normalize away invalid rows. Use the `settings:set` return value to rebase local state immediately after each write.
|
||||
- Concurrent edits are last-write-wins at the field level (`openInApplications` array replaced wholesale). There is no compare-and-swap/version guard.
|
||||
- External file mutation of `orca-data.json` is only observed on app restart; no live file watch exists.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Empty/whitespace command or label -> dropped by normalizer.
|
||||
- Missing `openInApplications` in persisted settings -> treated as `[]` via defaults merge.
|
||||
- Duplicate IDs (including hand-edited config) -> first survives, later rows dropped.
|
||||
- Command strings with spaces/flags (e.g. `cursor --foo`) -> fail at spawn unless provided via wrapper executable.
|
||||
- Launcher not in PATH or non-executable -> `launch-failed` and existing toast.
|
||||
- Path exists but is a file (not directory) still passes current validation and will be passed to launcher/file manager; this is existing behavior.
|
||||
- Relative/non-existent worktree path -> existing `not-absolute`/`not-found` error flow.
|
||||
- Remote/server worktree -> blocked before IPC regardless of configured launchers.
|
||||
|
||||
## Tests required
|
||||
|
||||
- `shell.test.ts`: optional command path, blank command fallback to `code`, Windows spawn arg path, failure mapping.
|
||||
- `WorktreeOpenInMenu.test.tsx`: configured rows render in order, command forwarded, remote guard still blocks all targets.
|
||||
- settings normalization tests (shared normalizer + persistence update path): trim, drop invalid rows, cap, duplicate-id behavior.
|
||||
- settings normalization tests: missing/blank id handling and generated-id stability on edit.
|
||||
- `settings:set`/renderer integration test: renderer applies authoritative returned settings, not only optimistic local merge.
|
||||
- General settings search entries include new section keywords.
|
||||
@@ -1,152 +0,0 @@
|
||||
# Delete Windows Workspace Without False Unregistered Error
|
||||
|
||||
## Problem
|
||||
|
||||
GitHub issue [#5864](https://github.com/stablyai/orca/issues/5864) reports that Orca on Windows v0.14.80 fails to delete a workspace created from a project `+` button:
|
||||
|
||||
`Error invoking remote method 'worktrees:remove': Error: Refusing to delete unregistered worktree path: C:/Users/andy/orca/workspaces/ops-tools/packaging-improvements-2`
|
||||
|
||||
Relevant flow:
|
||||
|
||||
- Renderer delete calls local IPC for local targets in `src/renderer/src/store/slices/worktrees.ts`.
|
||||
- Preload exposes that as `worktrees:remove` in `src/preload/index.ts`.
|
||||
- IPC delete lists Git worktrees, matches the requested path, and throws the unregistered error if no registered entry matches in `src/main/ipc/worktrees.ts`.
|
||||
- Runtime RPC delete has the same registered-worktree gate in `src/main/runtime/orca-runtime.ts`.
|
||||
- Windows create/list coverage exists in `src/main/ipc/worktrees-windows.test.ts`, but Windows delete coverage is missing.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Delete is right to refuse arbitrary paths. This bug is a false negative in the proof step: Orca asks Git for the authoritative registered worktree list, but the list does not contain an entry equivalent to the project-created target.
|
||||
|
||||
Do not fix this by adding another path-normalization layer after the list. `findRegisteredDeletableWorktree` delegates to `areWorktreePathsEqual`, which already treats `C:/...`, `C:\...`, and drive-case variants as equal while keeping POSIX/WSL paths distinct. `git/worktree.removeWorktree` has a similar comparator for its fallback branch lookup.
|
||||
|
||||
The credible failure surfaces are before or around that comparator:
|
||||
|
||||
- Delete may list through a different local runtime than create/list/selector resolution, especially for project runtime settings on Windows.
|
||||
- `listWorktrees` currently returns `[]` for several Git/list failures. In delete, that collapses "could not prove registration" into the misleading unregistered-path error.
|
||||
- Runtime selector/list resolution calls `listRepoWorktreesForResolution(repo)`, which currently omits local project runtime options.
|
||||
- Runtime removal validates a registered row, but then calls `removeWorktree` without `knownRemovedWorktree`, allowing the Git helper to rescan under the supplied options.
|
||||
- Some runtime cleanup paths still omit or recompute local runtime options instead of using the option set captured for the delete.
|
||||
|
||||
Implementation must start with a failing regression. If an equivalent Windows row is present in the registered list under the right options, deletion should succeed without further path comparator changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not allow deletion of existing unregistered directories.
|
||||
- Do not bypass the main-worktree, nested-worktree, local dirty-worktree, archive-hook, branch-preservation, or concurrent-delete guards.
|
||||
- Do not change delete dialog UI/copy.
|
||||
- Do not change SSH provider semantics.
|
||||
- Do not add a broad path abstraction or metadata migration.
|
||||
|
||||
## Design
|
||||
|
||||
1. Capture one repo-scoped local Git option set for the delete.
|
||||
- For local repos, use the repo's project runtime options from `getLocalProjectWorktreeGitOptions(store, repo)`. This is already repo-scoped and surfaces repair-required project runtimes before any Git command.
|
||||
- Do not use `getLocalGitOptionsForRegisteredWorktree` for delete. It scans all repos and uses native `path.resolve`, which is not a safe Windows-equivalence test on macOS/Linux test hosts.
|
||||
- Do not choose options from a worktree path alone. The parsed `repoId` is the authority for local runtime selection; exact-ID fallback after selector failure should still use the owning repo's project runtime options.
|
||||
- Keep SSH paths on the existing provider branch.
|
||||
|
||||
2. Make the authoritative Git list strict enough for delete.
|
||||
- Delete must distinguish "Git listed zero matching worktrees" from "Git listing failed." A selected-runtime Git/list failure should surface the underlying failure, not turn into `Refusing to delete unregistered worktree path`.
|
||||
- If this requires a strict list API beside `listWorktrees`, keep it narrow and delete-only; do not change polling/list UI behavior that intentionally tolerates transient Git failures.
|
||||
- The registered row returned by Git remains the canonical removal target after `findRegisteredDeletableWorktree` succeeds.
|
||||
|
||||
3. Thread the captured option set through the full local removal path.
|
||||
- `listWorktrees`, archive hooks, orphan proof reads, missing-path checks, clean preflight, `git worktree remove`, branch cleanup, recursive orphan cleanup, filesystem delete, push-target cleanup, and `git worktree prune` must all use the same captured options.
|
||||
- Do not recompute project runtime options later in the operation. A project runtime setting change during an in-flight delete must not split one deletion across two runtimes.
|
||||
- IPC already passes `knownRemovedWorktree` to `removeWorktree`; keep that behavior.
|
||||
- Runtime removal must also pass `knownRemovedWorktree` so branch cleanup uses the validated row and avoids a second list.
|
||||
- Replace current runtime cleanup call sites that omit `localWorktreeGitOptions` for push-target remote cleanup.
|
||||
- Replace prune calls that recompute `getLocalProjectGitExecOptions(...)` with `{ cwd: repo.path, ...localWorktreeGitOptions }`.
|
||||
|
||||
4. Fix runtime resolution/listing.
|
||||
- Update `listRepoWorktreesForResolution(repo)` to call `listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo))` for local repos.
|
||||
- Runtime exact-ID deletes should re-list Git under the captured options before destructive work; selector caches are convenience only, not delete authority.
|
||||
- Runtime archive hooks should use the captured options rather than `this.getLocalGitExecutionOptionArgs(repo)[0]`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Delete action -> `removeWorktree(worktreeId, force)` in renderer.
|
||||
- Local target -> `window.api.worktrees.remove({ worktreeId, force, skipArchive })`.
|
||||
- Main parses `repoId` and `worktreePath`.
|
||||
- Main resolves the repo and captures one local Git option set for that repo.
|
||||
- Main strictly lists registered Git worktrees with those options.
|
||||
- Main matches requested path to registered path with `areWorktreePathsEqual` via `findRegisteredDeletableWorktree`.
|
||||
- Main uses the registered canonical path for hooks, preflight, watcher close, Git removal, orphan cleanup, metadata cleanup, and sidebar refresh. PTY teardown remains keyed by the exact worktree ID.
|
||||
|
||||
Runtime RPC follows the same rule after selector resolution, and exact-ID fallback must still re-list Git before destructive work.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `C:/...`, `C:\...`, and drive-letter case variants refer to the same Windows worktree.
|
||||
- POSIX WSL paths and Windows paths must not compare equal unless `listWorktrees` translated them through the selected WSL options.
|
||||
- UNC paths, drive-letter paths, and `/mnt/<drive>` paths need explicit tests because Node path behavior is platform-specific on macOS/Linux test hosts.
|
||||
- Main worktree deletion is still rejected.
|
||||
- Parent worktree deletion is still rejected if another registered worktree is nested inside it.
|
||||
- Existing unregistered directories are still rejected, even with `force`.
|
||||
- Already-missing Orca-known worktrees still clean metadata only.
|
||||
- Orphaned Orca-created worktree directories still require proof through the `.git` file before recursive deletion.
|
||||
- Multi-window IPC deletes coalesce only for the same exact worktree ID and options. Equivalent Windows paths with different IDs, or IPC/runtime deletes racing each other, must degrade to safe missing/orphan handling or a protected error.
|
||||
- External Git mutation between list and `git worktree remove` is handled by the existing missing/orphan branches; keep those branches under the same captured runtime options.
|
||||
- Project runtime setting changes during an in-flight delete affect only later deletes.
|
||||
- SSH deletes still use SSH Git/filesystem providers and do not touch local paths.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit:
|
||||
- `pnpm vitest run src/main/ipc/worktrees-windows.test.ts`
|
||||
- `pnpm vitest run src/main/ipc/worktrees.test.ts --testNamePattern "local worktree removal|selected WSL project runtime|unregistered delete|contains another registered|already-missing"`
|
||||
- `pnpm vitest run src/main/runtime/orca-runtime.test.ts --testNamePattern "worktree removal|selected WSL project runtime|unregistered delete|contains another registered|already-missing"`
|
||||
- Required new coverage:
|
||||
- IPC Windows delete regression: request path uses `C:/...`, Git registered row uses backslashes and/or different drive-case, delete succeeds, hooks/preflight/removal use the canonical registered path, `knownRemovedWorktree` is passed, metadata is removed, and `worktrees:changed` emits.
|
||||
- Runtime Windows delete regression with the same path mismatch. Assert selector/list resolution and final removal both use selected project runtime options, and `knownRemovedWorktree` is passed.
|
||||
- Strict-list failure regression: a selected-runtime list failure rejects with the list failure, not the unregistered-path error.
|
||||
- Negative Windows/WSL mismatch: POSIX `/mnt/c/...` or WSL-native paths must not match unrelated Windows paths unless translated by the selected WSL options.
|
||||
- Runtime cleanup regressions for already-missing/orphan/push-target cleanup under selected WSL options.
|
||||
- Integration/e2e:
|
||||
- Electron smoke with a disposable local repo/worktree: delete succeeds, row disappears, no new delete UI regressions.
|
||||
- Real Windows validation is preferred. macOS/Linux unit tests can cover comparator and option plumbing, but they cannot fully prove Node and Git path behavior on Windows.
|
||||
- Full checks:
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
No intentional UI change. Existing delete dialog, progress state, toast behavior, and sidebar row removal should remain visually unchanged and follow `docs/STYLEGUIDE.md`.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
No design screenshots are required for a backend-only fix. If the PR needs Electron smoke evidence, attach only:
|
||||
|
||||
1. Disposable workspace delete confirmation before confirming.
|
||||
2. Sidebar after successful deletion with the row gone.
|
||||
|
||||
Do not spend review time manufacturing a protected/error screenshot; cover that with unit tests.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add focused Windows-path delete regression tests.
|
||||
2. Add strict delete listing or equivalent failure propagation.
|
||||
3. Update runtime selector/list resolution to use local project runtime options.
|
||||
4. Thread captured options and `knownRemovedWorktree` through IPC/runtime delete paths.
|
||||
5. Run focused tests, typecheck, lint.
|
||||
6. Electron-validate the unchanged delete UI on a disposable workspace and collect screenshots only if required.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: delete-only; no renderer changes, no new deletion authority, and no broad path-normalization rewrite.
|
||||
- Architecture/data flow: local IPC and runtime RPC both keep Git as the authority. The fix is to ask Git through the correct project runtime, treat list failures as failures, then use Git's registered row as the canonical removal target.
|
||||
- Failure modes covered:
|
||||
- Windows slash/drive-case mismatches.
|
||||
- Wrong or failed local project runtime listing.
|
||||
- Runtime selector resolution using host listings.
|
||||
- Runtime branch cleanup rescanning instead of using the validated row.
|
||||
- Metadata absent or stale during already-missing cleanup.
|
||||
- Existing unregistered directory remains protected.
|
||||
- Main and nested registered worktrees remain protected.
|
||||
- SSH paths stay provider-owned.
|
||||
- Performance/blast radius: no material concern if the delete path performs one strict authoritative list and passes `knownRemovedWorktree` to avoid the helper rescan.
|
||||
- Feasibility: this is not a "one comparator call" fix. The current APIs make `listWorktrees` failures look like empty lists, and runtime selector resolution currently omits local runtime options.
|
||||
- UI quality bar: no UI-visible design change; Electron should judge that existing delete dialog, progress, toast, and row removal still look unchanged against `docs/STYLEGUIDE.md`.
|
||||
- Required review screenshots: none for the backend fix; optional disposable-workspace smoke screenshots only if the PR process asks for visual evidence.
|
||||
- Residual risks: true Windows filesystem/Git spelling behavior still depends on a Windows runner or user validation; macOS/Linux tests cannot fully model it.
|
||||
@@ -1,247 +0,0 @@
|
||||
# Double-tap modifier keybindings — design
|
||||
|
||||
## Goal
|
||||
|
||||
Allow any keybinding action to be bound to a **double-tap of a bare modifier**
|
||||
(Shift, Cmd/Ctrl, Alt) in Settings → Shortcuts. A double-tap binding is stored,
|
||||
recorded, formatted, conflict-checked, and matched alongside normal bindings,
|
||||
and fires everywhere a normal shortcut does — including when a browser guest or
|
||||
terminal owns focus.
|
||||
|
||||
Example: bind `DoubleTap+Shift` to `worktree.quickOpen` ("Go to File"), then
|
||||
tapping Shift twice opens Go to File (IntelliJ "double-Shift" style).
|
||||
|
||||
## Why this is not just another binding
|
||||
|
||||
The existing keybinding system is **stateless and per-keydown**: every binding
|
||||
has at least one modifier and exactly one key, and matching compares a single
|
||||
`KeyboardEvent`'s modifier state + key against the stored binding string
|
||||
(`keybindingMatchesInput` in `src/shared/keybindings.ts`). A double-tap is a
|
||||
**timed sequence of a bare modifier with no key** — press M, release M, press M
|
||||
again within a short window. It cannot be represented by the current grammar or
|
||||
matched by the current stateless comparison.
|
||||
|
||||
Dispatch is also split across two layers, and both must participate for a
|
||||
double-tap to work for "any action":
|
||||
|
||||
- **Main process** — `before-input-event` in
|
||||
`src/main/window/createMainWindow.ts` matches an explicit allowlist of ~20
|
||||
actions via `resolveWindowShortcutAction`
|
||||
(`src/shared/window-shortcut-policy.ts`), calls `preventDefault()`, and
|
||||
forwards the action to the renderer over IPC. This layer exists so a subset of
|
||||
shortcuts work even when focus lives in a browser guest `webContents` or a
|
||||
contentEditable surface that bypasses the renderer's window-level listener.
|
||||
- **Renderer** — the window `keydown` handler in
|
||||
`src/renderer/src/App.tsx` matches most actions via `keybindingMatchesAction`
|
||||
and runs their effects inline.
|
||||
|
||||
## Approach (chosen): synthetic input through the existing matchers
|
||||
|
||||
Detect the double-tap with a small shared state machine, then represent the
|
||||
completed gesture as a **synthetic shortcut input** carrying a
|
||||
`doubleTapModifier` marker and run that input through the *existing* dispatch
|
||||
chains in both layers. The matcher is extended so a `DoubleTap+<Mod>` binding
|
||||
matches only that synthetic input (and never a normal keydown, and vice-versa).
|
||||
|
||||
Because the existing dispatch chains already call
|
||||
`keybindingMatchesAction(actionId, input, …)`, every action that is already
|
||||
wired in those chains gains double-tap support automatically — no per-action
|
||||
dispatch table to build or keep in sync.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Per-action dispatch registry** — detector resolves action ids and calls a
|
||||
new `dispatchActionById()` implemented per action. Avoids refactoring the
|
||||
renderer handler but duplicates action effects that already live there, drifts
|
||||
over time, and only supports actions we explicitly wire — not "any action".
|
||||
- **Re-dispatch a synthetic DOM `KeyboardEvent`** — a double-tap can't be
|
||||
expressed as a standard key event without a key, and re-dispatching risks
|
||||
event loops.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Binding grammar — `src/shared/keybindings.ts`
|
||||
|
||||
- New canonical form `DoubleTap+<Mod>` where `<Mod>` is one of `Shift`, `Mod`,
|
||||
`Cmd`, `Ctrl`, `Alt`. `Mod` resolves to Cmd on macOS and Ctrl on
|
||||
Windows/Linux, identical to normal bindings.
|
||||
- `ParsedKeybinding` gains `doubleTapModifier?: ModifierToken` and permits an
|
||||
empty `key` (only when `doubleTapModifier` is set).
|
||||
- `parseKeybinding` recognizes a leading `DoubleTap` token followed by exactly
|
||||
one modifier token and **no** key token. Anything else with `DoubleTap` is
|
||||
invalid.
|
||||
- `canonicalizeParsedKeybinding` emits `DoubleTap+<Mod>` (modifier in the same
|
||||
canonical position rules as today).
|
||||
- `normalizeKeybindingWithOptions` accepts a well-formed double-tap binding and
|
||||
rejects malformed ones with clear errors:
|
||||
- `DoubleTap` + a key (e.g. `DoubleTap+Shift+P`) → invalid.
|
||||
- `DoubleTap` + two modifiers (e.g. `DoubleTap+Shift+Alt`) → invalid.
|
||||
- `DoubleTap+Mod+Cmd` (both forms) → reuse the existing "Mod or
|
||||
platform-specific, not both" error.
|
||||
- bare `DoubleTap` with no modifier → invalid.
|
||||
- `formatKeybinding` returns the modifier glyph **twice**: macOS `['⇧','⇧']`,
|
||||
Windows/Linux `['Shift','Shift']`.
|
||||
- `ShortcutKeyCombo` renders the two chips. Double-tap is special-cased so the
|
||||
non-Mac separator reads "Shift Shift" (space), not "Shift+Shift". A
|
||||
"Double-tap Shift" tooltip clarifies the gesture.
|
||||
|
||||
### 2. Detector — new module `src/shared/modifier-double-tap-detector.ts`
|
||||
|
||||
A pure, dependency-free state machine. Timestamps are **injected** by the caller
|
||||
so it is deterministic and unit-testable.
|
||||
|
||||
```
|
||||
class ModifierDoubleTapDetector {
|
||||
// event: { type: 'keyDown' | 'keyUp', modifier: ModifierToken | null,
|
||||
// isModifierOnly: boolean, isAutoRepeat: boolean }
|
||||
process(event, timestampMs): DetectedDoubleTap | null
|
||||
reset(): void
|
||||
}
|
||||
```
|
||||
|
||||
State machine:
|
||||
|
||||
1. **idle** → on a modifier-only `keyDown` of M that is not autorepeat: remember
|
||||
M, wait for its release.
|
||||
2. **down1** → on `keyUp` of M (clean, no other key seen): record release time,
|
||||
move to **armed(M)** with deadline `releaseTime + WINDOW_MS`.
|
||||
3. **armed(M)** → on `keyDown` of the same M within the deadline, with no other
|
||||
modifier held and no intervening non-modifier key: **emit** a double-tap of M
|
||||
and reset.
|
||||
|
||||
Any of these reset to idle: a non-modifier key event at any point, a different
|
||||
or additional modifier, autorepeat-hold of the modifier, exceeding the window,
|
||||
or an explicit `reset()` (e.g. on window blur / focus change).
|
||||
|
||||
`WINDOW_MS = 300` (internal constant; not user-configurable). A helper derives
|
||||
`(modifier, isModifierOnly)` from an event's `code`/`key`.
|
||||
|
||||
### 3. Matcher extension — `src/shared/keybindings.ts`
|
||||
|
||||
- `KeybindingInput` gains `doubleTapModifier?: ModifierToken`.
|
||||
- `keybindingMatchesInput`: when the parsed binding is a double-tap binding,
|
||||
match iff `input.doubleTapModifier` equals the binding's modifier, resolved per
|
||||
platform (`Mod` → meta on macOS, control elsewhere). A double-tap binding never
|
||||
matches a normal keydown (no `doubleTapModifier`), and a normal binding never
|
||||
matches a synthetic double-tap input.
|
||||
- No change to `keybindingMatchesAction` — it already delegates to
|
||||
`keybindingMatchesInput`, so any action becomes double-tap-capable for free.
|
||||
|
||||
### 4. Dispatch wiring — both layers
|
||||
|
||||
- **Main** (`src/main/window/createMainWindow.ts`): instantiate a
|
||||
`ModifierDoubleTapDetector` per window. In `before-input-event`, feed every
|
||||
`keyDown`/`keyUp` to the detector (it only consumes bare-modifier events). On
|
||||
emit, build the synthetic input `{ doubleTapModifier: M }`, run the existing
|
||||
`resolveWindowShortcutAction(syntheticInput, platform, keybindings,
|
||||
terminalShortcutContext)`, and if an allowlisted action resolves, dispatch via
|
||||
the current IPC + `preventDefault()` path. `resolveWindowShortcutAction` needs
|
||||
no per-action change; the implicit numeric-index shortcuts are guarded on
|
||||
`input.key`, which is undefined for a double-tap input, so they cannot match.
|
||||
Only the emitting second-keydown event is `preventDefault()`-ed — never the
|
||||
first tap's down/up (those bare modifiers are harmless and the keyup is needed
|
||||
by the detector).
|
||||
- **Renderer** (`src/renderer/src/App.tsx`): extract the body of the window
|
||||
`onKeyDown` handler into `dispatchShortcutInput(input: ShortcutDispatchInput)`,
|
||||
where `ShortcutDispatchInput` exposes the modifier/key fields plus
|
||||
`doubleTapModifier?`, a `preventDefault()` (no-op for synthetic input),
|
||||
`defaultPrevented`, and the focus/target context. The real listener wraps the
|
||||
`KeyboardEvent`; a renderer `ModifierDoubleTapDetector` (fed by both a keydown
|
||||
and a **new** keyup window listener) produces a synthetic input on emit with
|
||||
`context` derived from `document.activeElement`, and calls
|
||||
`dispatchShortcutInput`.
|
||||
|
||||
#### No double-fire between layers
|
||||
|
||||
This reuses the exact disambiguation normal shortcuts already rely on:
|
||||
|
||||
- For an **allowlisted** action, main detects the double-tap on the second
|
||||
modifier keydown, resolves it, and calls `preventDefault()`. That suppresses
|
||||
the corresponding renderer DOM keydown, so the renderer detector never
|
||||
completes its second tap → it does not fire. (The renderer detector may have
|
||||
observed the first tap's down/up. It has no timer: the second-press window is
|
||||
enforced by comparing the next keydown's timestamp against a deadline. The
|
||||
suppressed second keydown never arrives, but its keyup still does — a keyup of
|
||||
the armed modifier with no intervening second keydown clears the armed state,
|
||||
so a later lone press of the same modifier cannot phantom-complete the gesture.)
|
||||
- For a **non-allowlisted** action, main's detector still emits but
|
||||
`resolveWindowShortcutAction` returns `null`, so main does not call
|
||||
`preventDefault()`. The second-keydown DOM event reaches the renderer, whose
|
||||
detector completes and fires via `dispatchShortcutInput`.
|
||||
|
||||
### 5. Recorder UX — `ShortcutBindingRow.tsx` + `ShortcutsPane.tsx`
|
||||
|
||||
The recorder currently captures on the first keydown, which makes a bare
|
||||
modifier error with "Press a key, not only a modifier." Change the row so that
|
||||
while recording it runs a `ModifierDoubleTapDetector` fed by the row button's
|
||||
keydown **and keyup** (the button holds focus during recording, so it receives
|
||||
both):
|
||||
|
||||
- A bare-modifier keydown no longer captures immediately — the detector observes
|
||||
it.
|
||||
- A non-modifier keydown (with or without modifiers) captures a normal binding,
|
||||
exactly as today.
|
||||
- A completed double-tap captures `DoubleTap+<Mod>`: the row passes
|
||||
`{ doubleTapModifier: M }` into the capture path, and
|
||||
`keybindingFromInputWithOptions` short-circuits to build `DoubleTap+<Mod>`
|
||||
(mapping meta → `Mod` on macOS, etc.) and normalizes it.
|
||||
- A single lone modifier tap that never completes is ignored — the recorder
|
||||
keeps listening.
|
||||
|
||||
Helper text while recording: *"Press a shortcut, or double-tap a modifier (e.g.
|
||||
⇧⇧)."* Esc still cancels. The detector is reset when recording stops or the row
|
||||
loses focus.
|
||||
|
||||
### 6. Conflicts & terminal policy
|
||||
|
||||
`DoubleTap+Shift` is a canonical binding string, so `findKeybindingConflicts`
|
||||
compares it like any other binding — two actions sharing a double-tap surface a
|
||||
conflict in the UI. Terminal-policy gating (`keybindingIsActiveInContext`,
|
||||
orca-first / terminal-first) applies unchanged. Note that a bare modifier press
|
||||
emits no terminal bytes, so detecting a double-tap never steals readline input;
|
||||
policy is still honored for consistency.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Record:** row keydown/keyup → row detector → `{ doubleTapModifier: M }` →
|
||||
`keybindingFromInputForAction` → `DoubleTap+<Mod>` → stored as
|
||||
`["DoubleTap+Shift"]` in `~/.orca/keybindings.json`.
|
||||
- **Runtime:** physical modifier taps → main + renderer detectors → synthetic
|
||||
`{ doubleTapModifier: M }` → existing matchers → action dispatched (main IPC
|
||||
for allowlisted actions, renderer inline for the rest).
|
||||
|
||||
## Behavioral decisions
|
||||
|
||||
- **Trigger edge:** fire on the **second modifier keydown** (snappy), not the
|
||||
second keyup.
|
||||
- **Window:** `WINDOW_MS = 300`, internal constant, not user-configurable.
|
||||
- **Modifiers supported:** Shift, Cmd/Ctrl (`Mod`), Alt — any modifier, recorded
|
||||
as the platform-appropriate token following the existing capture convention.
|
||||
|
||||
## Testing
|
||||
|
||||
- New `src/shared/modifier-double-tap-detector.test.ts`: completion within
|
||||
window; timeout past window; reset on intervening non-modifier key; reset on
|
||||
different/extra modifier; autorepeat-hold is not a tap; wrong-modifier second
|
||||
tap; `reset()` clears state.
|
||||
- `src/shared/keybindings.test.ts` additions: parse / normalize / canonicalize /
|
||||
format for `DoubleTap+*` (incl. malformed-input rejection); platform token
|
||||
mapping (`DoubleTap+Mod` → Cmd on macOS, Ctrl elsewhere);
|
||||
`keybindingMatchesInput` with a synthetic `doubleTapModifier` input (positive
|
||||
and cross-type negatives); conflict detection across two double-tap bindings.
|
||||
- Manual: record `DoubleTap+Shift` on "Go to File"; confirm it fires globally
|
||||
including with a browser guest and a focused terminal; confirm normal Shift+key
|
||||
typing is unaffected; confirm chips and tokens are correct on macOS and
|
||||
Windows/Linux.
|
||||
|
||||
## Risks & edge cases
|
||||
|
||||
- **Accidental triggers during fast typing** — mitigated by requiring a clean
|
||||
down→up→down of the same modifier with no other key, inside a 300ms window.
|
||||
- **macOS Sticky Keys (press Shift 5×)** — unaffected; the gesture is two taps
|
||||
within a tight window.
|
||||
- **Double-fire main vs renderer** — resolved by the
|
||||
`preventDefault`-on-emit mechanism described in §4.
|
||||
- **Focus/window changes mid-sequence** — both detectors reset on blur / focus
|
||||
change (hook into the existing recorder/terminal focus reset paths in the main
|
||||
process and a window blur listener in the renderer).
|
||||
@@ -1,83 +0,0 @@
|
||||
# Droid Orchestration Group
|
||||
|
||||
## Problem
|
||||
|
||||
Issue #4560 reports that Orca CLI / orchestration cannot be used with a Droid agent. Droid is already a first-class launchable agent in `src/shared/tui-agent-config.ts:240`, title detection token-matches Droid in `src/shared/agent-detection.ts:38` and `src/shared/agent-detection.ts:397`, and `--inject` accepts a detected running agent through `runtime.isTerminalRunningAgent` in `src/main/runtime/rpc/methods/orchestration.ts:429`. The gap found locally is that orchestration agent groups are hardcoded to `claude`, `openclaude`, `codex`, `opencode`, and `gemini` in `src/main/runtime/orchestration/groups.ts:7`, so `@droid` resolves to no recipients.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The orchestration group resolver has its own closed list of addressable agent-name groups instead of deriving from the agent set that Orca can launch and recognize. Droid was added to the catalog and status paths, but not to this separate group list.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change Droid hook installation or Droid CLI launch semantics.
|
||||
- Do not add a protocol adapter or new orchestration transport.
|
||||
- Do not broaden `--inject` to send preambles into arbitrary shells.
|
||||
- Do not change UI layout, styling, or agent picker ordering.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add `droid` to orchestration's agent-name group allowlist.
|
||||
2. Keep title matching token-based so `@droid` does not match Android paths, titles, or package names.
|
||||
3. Update orchestration group tests to cover `@droid` positive and Android false-positive cases.
|
||||
4. Update CLI-facing error/help text and shipped orchestration skill docs where they name example agent groups so Droid is not implied unsupported.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- User sends `orca orchestration send --to @droid ...`.
|
||||
- CLI calls `orchestration.send`.
|
||||
- Runtime lists terminal summaries.
|
||||
- `resolveGroupAddress` sees `@droid`, matches terminal titles with the existing token regex, and returns Droid terminal handles.
|
||||
- Runtime inserts one message per recipient and delivers pending messages to idle terminals.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `@droid` must match `Droid ready` and `Droid - action required`.
|
||||
- `@droid` must not match `Android build`, `/tmp/android`, or `my-droid-worker`.
|
||||
- Sender is still excluded from group fan-out.
|
||||
- Unknown groups continue resolving to an empty list.
|
||||
- SSH/remote terminals rely on the same terminal summaries and titles, so no local-path assumptions are introduced.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: `src/main/runtime/orchestration/groups.test.ts` covers `@droid` positive fan-out and Android/path/hyphen false positives.
|
||||
- Unit: existing `src/shared/agent-detection` coverage remains the title-status source of truth; no changes expected.
|
||||
- Unit: existing orchestration RPC group fan-out tests should continue passing.
|
||||
- Manual/CLI: with a Droid terminal title, `orca orchestration send --to @droid --subject ...` should resolve recipients; without one it should report no recipients.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
Not UI-visible. Behavior changes only affect CLI orchestration group routing and error/help copy.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
No required UI screenshots. Stage 6 should capture a terminal/CLI validation artifact only if Electron validation creates a visible terminal state.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update orchestration group allowlist and tests.
|
||||
2. Update example/error copy.
|
||||
3. Run focused unit tests for group resolution and orchestration send behavior.
|
||||
4. Run typecheck/lint if the focused tests pass.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: reduced to group resolution and copy because Droid is already in launch, hook, title, and process-recognition paths.
|
||||
- Architecture/data flow: keep the boundary inside `src/main/runtime/orchestration/groups.ts`; runtime RPC continues delegating fan-out through the existing resolver.
|
||||
- Failure modes covered:
|
||||
- Android false positives from substring matching.
|
||||
- Hyphen/path token false positives.
|
||||
- Sender exclusion in group fan-out.
|
||||
- Unknown groups preserving empty-resolution behavior.
|
||||
- Test coverage required:
|
||||
- `src/main/runtime/orchestration/groups.test.ts` for `@droid` matching and false positives.
|
||||
- Existing `src/main/runtime/rpc/methods/orchestration.test.ts` smoke for agent group fan-out.
|
||||
- Performance/blast radius: no material concern; one string added to a small in-memory list and one extra unit-test case.
|
||||
- UI quality bar: not UI-visible.
|
||||
- Required review screenshots: none; validation should rely on CLI/test output unless a visible terminal state is exercised.
|
||||
- Residual risks: if Droid's real TUI never sets a title containing `Droid`, `@droid` still needs foreground-process-aware group resolution in a follow-up. Current code already synthesizes Droid titles from hooks, so this is expected to work for hook-enabled Droid sessions.
|
||||
|
||||
## Codex Review
|
||||
|
||||
- Round 1: tightened scope to include shipped orchestration skill/help text because it documents the same hardcoded group list users see when learning the feature.
|
||||
- Residual issues: none known within the small group-routing fix.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Layout-aware find in editable Monaco editors
|
||||
|
||||
## Problem
|
||||
|
||||
Issue [#7953](https://github.com/stablyai/orca/issues/7953) reports that `Cmd+F` can type `f` into a TypeScript file instead of opening find on macOS.
|
||||
|
||||
Orca's editable Monaco surfaces currently install its layout-aware save shortcut through `editor-shortcuts.ts` (`src/renderer/src/components/editor/editor-shortcuts.ts:18`), but leave find entirely to Monaco's internal keycode dispatch. Orca already declares `editor.find` as `Mod+F` (`src/shared/keybindings.ts:784`) and its matcher resolves logical keys before physical-code fallback (`src/shared/keybindings.ts:2041`).
|
||||
|
||||
A real Electron repro sends a macOS event with logical key `f`, physical code `KeyU`, and virtual key code `U`, as produced by a non-QWERTY layout. Monaco leaves its find widget closed. The equivalent QWERTY `KeyF` event opens it.
|
||||
|
||||
## Root cause
|
||||
|
||||
Monaco's built-in find keybinding follows the physical/virtual keycode delivered by Chromium. Orca's shortcut system is layout-aware, but its editable Monaco integrations do not use it for find. On layouts where the key that produces `f` is not physical `KeyF`, Monaco misses the chord and Native Edit Context remains in editing mode.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Reimplementing Monaco's find widget, match navigation, or search state.
|
||||
- Changing find behavior in markdown preview, rich markdown, PDF, browser, terminal, or file search.
|
||||
- Changing find behavior in read-only diff surfaces.
|
||||
- Reworking all Monaco keybindings or making Monaco defaults fully obey shortcut unbinding in this patch.
|
||||
- Adding telemetry for a local keyboard action.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a focused editor find installer beside the existing save installer in `editor-shortcuts.ts`. It matches `editor.find` through `editorShortcutMatches`, consumes every matched event before it reaches Native Edit Context or Monaco, and invokes a supplied callback only for the initial, non-repeat keydown. Repeat events must still be prevented and propagation-stopped so Monaco's QWERTY binding cannot reopen/reset the widget.
|
||||
2. Install that handler on every editable Monaco container in the source editor, editable diff views, and notebook code cells. Run that editor's existing `actions.find` action and dispose the bridge from its existing teardown callback.
|
||||
3. Add unit coverage at the DOM-listener seam for logical `f` with a non-`KeyF` physical code, QWERTY/default behavior, repeat suppression, unrelated typing, and cleanup.
|
||||
4. Preserve an Electron regression loop that drives both QWERTY and layout-aware raw key events against a real `.ts` editor and verifies the existing Monaco find widget becomes visible without dirtying the file.
|
||||
|
||||
## Data flow
|
||||
|
||||
- macOS/Linux/Windows keydown reaches the focused editable Monaco container.
|
||||
- `editorShortcutMatches('editor.find', event)` resolves the active platform, user bindings, modifiers, and logical key.
|
||||
- On match, Orca consumes the DOM event and calls Monaco's existing `actions.find` action.
|
||||
- Monaco owns the visible find widget and focus exactly as before.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Auto-repeat must be consumed without invoking find again; returning early before prevention would let Monaco handle a repeated QWERTY `KeyF` event.
|
||||
- Ordinary unmodified `f` typing and unrelated shortcuts must continue to Monaco unchanged.
|
||||
- A removed/disposed source editor, diff pane, or notebook cell must not retain the listener.
|
||||
- QWERTY `Cmd/Ctrl+F` must still open the same Monaco widget once, not twice.
|
||||
- User-configured bindings accepted by Orca's `editor.find` matcher should open find; Monaco's own default bindings remain outside the scope of this patch.
|
||||
- The behavior is renderer-local and does not read files or execute commands, so local, SSH, and Remote Orca files share the same path.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: `editor-shortcuts.test.ts` dispatches keyboard events through a real element and parameterizes the shared bridge across macOS (`metaKey`) and Linux/Windows (`ctrlKey`) using logical `f` with physical `KeyU`. It also asserts QWERTY/default handling, matched-repeat prevention without a second callback, unrelated typing, and disposal behavior used by every editable Monaco integration.
|
||||
- Electron: open a disposable `.ts` file and drive `Cmd+F`/`Ctrl+F` using the platform modifier; assert `.find-widget.visible`, focused find input, unchanged source text, and clean editor state.
|
||||
- Electron layout regression: on macOS, dispatch logical `f` with non-QWERTY physical/virtual key identity; assert the same find state and unchanged source text.
|
||||
- Adjacent smoke: dismiss find, type a normal character, and verify it edits the file rather than reopening find.
|
||||
- Static: run focused Vitest, `pnpm typecheck`, and `pnpm lint`.
|
||||
|
||||
## UI quality bar
|
||||
|
||||
No new UI. The existing Monaco find widget must appear in its current position and styling, focus its input, and leave the editor content unchanged. No overlap, clipping, duplicate widget, or focus flicker is acceptable.
|
||||
|
||||
## Review screenshots
|
||||
|
||||
1. QWERTY/default shortcut with the existing Monaco find widget visible in a `.ts` editor.
|
||||
2. Non-QWERTY logical-`f` regression path with the same find widget visible and source text unchanged.
|
||||
3. Adjacent ordinary typing state after find is dismissed, showing the source editor still accepts text normally.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add failing unit coverage for the layout-aware find installer contract.
|
||||
2. Implement the installer in `editor-shortcuts.ts`.
|
||||
3. Wire it to each editable Monaco surface's existing find action and lifecycle cleanup.
|
||||
4. Run focused tests, typecheck, lint, and the Electron QWERTY/layout/typing scenarios.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to one shared shortcut installer and the existing mount/teardown seams for source editors, editable diff panes, and notebook code cells; no new find implementation or global shortcut interception.
|
||||
- Architecture/data flow: The renderer-local Monaco container owns the keydown. Orca's canonical matcher resolves the logical key, while Monaco continues to own widget state and rendering. No main/preload/IPC, persistence, network, SSH, or provider boundary changes.
|
||||
- Failure modes covered:
|
||||
- Non-QWERTY logical key differs from physical/virtual keycode.
|
||||
- QWERTY double handling.
|
||||
- Auto-repeat escaping to Monaco's native QWERTY handler.
|
||||
- Listener surviving editor disposal.
|
||||
- Ordinary typing being consumed.
|
||||
- Custom `editor.find` chord accepted by Orca but not Monaco.
|
||||
- Test coverage required:
|
||||
- DOM-listener unit tests in `src/renderer/src/components/editor/editor-shortcuts.test.ts`, parameterized for Darwin/Meta, Linux/Ctrl, and Windows/Ctrl.
|
||||
- Electron-visible QWERTY and layout-aware `.ts` scenarios.
|
||||
- Adjacent ordinary typing smoke test.
|
||||
- Performance/blast radius: One capture listener per mounted editable Monaco editor, doing a constant-time keybinding comparison only for events within that editor. Multiple mounted diff sections do not fan out because each listener is scoped to its own container. Listeners are removed with Monaco disposal; no polling, scans, IPC, storage, or render-loop work.
|
||||
- UI quality bar: Existing Monaco find widget only; verify focus, unchanged source text, no duplicate opening, and unchanged styling against `docs/STYLEGUIDE.md`.
|
||||
- Required review screenshots:
|
||||
1. Default QWERTY find-open state.
|
||||
2. Non-QWERTY logical-key find-open state.
|
||||
3. Find-dismissed ordinary typing state.
|
||||
- Residual risks: Monaco's internal default bindings remain active when a user explicitly rebinds `editor.find`; fully suppressing/remapping Monaco's native keybinding table is a separate, larger change.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Failed Automation Rerun Action
|
||||
|
||||
## Problem
|
||||
|
||||
- Failed Orca automation run details show only the disabled/open-target action when no workspace launched, so a user who sees a `dispatch_failed` error has no local recovery action in the failed-run view: `src/renderer/src/components/automations/AutomationsPage.tsx:1858`.
|
||||
- The reusable detail header already accepts actions, but the run detail action set is limited to `getAutomationRunViewState`/`openRunWorkspace`: `src/renderer/src/components/automations/AutomationRunPageFrame.tsx:9`, `src/renderer/src/components/automations/automation-run-view-state.ts:13`.
|
||||
- Manual rerun behavior already exists as `runNow`, which creates a fresh manual run for the automation; the renderer wrapper currently refreshes the page after calling it: `src/renderer/src/components/automations/AutomationsPage.tsx:1033`, `src/main/automations/service.ts:66`.
|
||||
|
||||
## Goal
|
||||
|
||||
Add an easy rerun button to Orca automation run detail pages for failed launch/recovery statuses. The button creates a fresh manual run for the same automation through the existing `runNow` path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not mutate or replay the failed run record.
|
||||
- Do not add backend APIs or provider-specific rerun behavior.
|
||||
- Do not add rerun support for external Hermes/OpenClaw run details in this change.
|
||||
- Do not bypass existing SSH availability, worktree creation, or dispatch failure handling.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a small pure predicate near the run view state code, for example `canRerunAutomationRun({ automation, run })`.
|
||||
- Return `true` only when an Orca automation still exists, `run.automationId === automation.id`, and the run status is one of `dispatch_failed`, `skipped_unavailable`, or `skipped_needs_interactive_auth`.
|
||||
- Do not show rerun for `pending`, `dispatching`, `dispatched`, `completed`, or `skipped_missed`. `skipped_missed` is scheduler catch-up history, not a launch failure the detail page should recover.
|
||||
2. In `AutomationsPage`, render a `Rerun` action in `AutomationRunPageFrame.actions` when that predicate is true for `selected` and `selectedAutomationRunPage`.
|
||||
3. The action calls `window.api.automations.runNow({ id: selected.id })` through a small handler, then `refresh()`, then shows the existing queued toast. Wrap the call in `try/catch/finally`: if the automation was deleted or the IPC rejects, toast the error and refresh so the stale run detail can disappear.
|
||||
4. Preserve dispatch behavior by reusing `runNow`; do not duplicate local/SSH dispatch logic in the renderer. `runNow` creates a new manual run and `AutomationService.requestDispatch` sends the existing `automations:dispatchRequested` payload to the renderer, where `useAutomationDispatchEvents` handles SSH reconnect, interactive-auth skips, worktree creation/reuse, and terminal launch.
|
||||
5. Add a local in-flight state keyed by the failed run id. Disable only that rerun button while pending so repeated clicks in one window cannot queue duplicates before the first request settles.
|
||||
6. Keep the existing `View run` / `Open workspace` action beside the new rerun action. Do not gate rerun on `selectedAutomationRunPageViewState.canOpen`; failed runs with no launch should still show the disabled view action plus enabled rerun.
|
||||
7. Use existing shadcn `Button`, lucide icon sizing, and styleguide tokens. The button should be compact (`size="sm"`), outline-level emphasis, and fit the existing header action row.
|
||||
8. Add focused tests for the pure predicate in `automation-run-view-state.test.ts`. Avoid component-level tests unless a harness already exists locally.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Failed run has no workspace because base ref refresh, SSH connection, missing project/workspace, or renderer availability failed: rerun remains available and lets the existing dispatch path try again.
|
||||
- Failed run has a workspace but terminal is closed: rerun stays available, and open workspace behavior remains unchanged.
|
||||
- Automation was deleted while the request is in flight or by another window: `runNow` can throw `Automation not found.`; catch it, clear pending state, and refresh.
|
||||
- Selection changed while the request is in flight: capture `automation.id` and `run.id` before awaiting, clear that run id in `finally`, and avoid reading mutable `selected` after the await.
|
||||
- SSH automation cannot reconnect or needs credentials: rerun still goes through `runNow`; existing dispatch code records `skipped_unavailable` or `skipped_needs_interactive_auth` as a new run.
|
||||
- The user clicks rerun repeatedly in the same window: disable the rerun button while the request is pending.
|
||||
- The user clicks rerun in two windows: the renderer guard will not prevent duplicate manual runs across windows. Do not claim backend idempotency unless a service-level dedupe key is added.
|
||||
- Refresh after rerun may leave the failed run detail selected because `selectedAutomationRunPageId` still points at the old run. That is acceptable for this change, but the run list count and rows must refresh so the new manual run is visible after navigating back.
|
||||
- `runNow` itself does not broadcast `orca:automations-changed`; the initiating page must call `refresh()` after the IPC resolves. Other windows update only when dispatch result handling fires the existing event or when focus/visibility refresh runs.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add a small predicate/helper for rerun action visibility and unit tests if it can live near automation run view state without mixing responsibilities.
|
||||
2. Add rerun pending state, error handling, and handler in `AutomationsPage`.
|
||||
3. Render the new action in the selected Orca run detail header.
|
||||
4. Run focused tests, then `pnpm typecheck` and `pnpm lint`.
|
||||
@@ -1,73 +0,0 @@
|
||||
# Floating Terminal Panel Position Persistence
|
||||
|
||||
## Problem
|
||||
|
||||
The floating workspace panel appears in a different default location after app restart instead of returning to the user's last dragged or resized placement.
|
||||
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:132` initializes panel `bounds` from `getDefaultFloatingTerminalBounds()` on every renderer mount.
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:349` only normalizes that in-memory initial state when the panel opens; it does not read or write durable position state.
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:1039` updates bounds while dragging, and `src/renderer/src/components/floating-terminal/FloatingTerminalResizeHandles.tsx:98` updates bounds while resizing, but both changes remain React state only.
|
||||
- The toggle button already persists its own location through localStorage in `src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx:33`, so the inconsistency is isolated to the larger panel.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Panel geometry is transient renderer state. Restarting Orca remounts `FloatingTerminalPanel`, so the panel recomputes from the current viewport instead of restoring the last user placement. The component also has no source tracking, so its legacy right-gap normalization cannot distinguish a default position from an intentional user drag.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change terminal, browser, or markdown tab persistence.
|
||||
- Do not persist remote or SSH-specific state; panel geometry is local renderer chrome.
|
||||
- Do not persist maximized panel bounds as the normal restored size.
|
||||
- Do not add new settings UI or change design tokens.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add floating panel bounds persistence helpers beside the existing panel bounds math:
|
||||
- use a panel-specific versioned key, e.g. `orca-floating-terminal-panel-bounds-v1`;
|
||||
- parse only finite `left`, `top`, `width`, and `height` numbers;
|
||||
- distinguish `default` versus `user` bounds sources;
|
||||
- expose a panel viewport-usability guard so saved user bounds are not clamped against Electron's transient zero-sized startup viewport;
|
||||
- expand clamping to normalize both position and size. The current `clampFloatingTerminalBounds` only clamps `left` and `top`; persisted restores also need the resize-handle width/height caps.
|
||||
2. Read and write storage defensively:
|
||||
- `window` absence or `localStorage` get/set failures fall back to in-memory behavior for the session;
|
||||
- malformed, partial, non-finite, or non-object JSON falls back to default bounds.
|
||||
3. Initialize `FloatingTerminalPanel` once from persisted bounds when present, otherwise from the current default. Store the initial source in a ref, mirroring the toggle button pattern.
|
||||
4. Reconcile bounds in `useLayoutEffect` before first paint and on viewport resize:
|
||||
- default-sourced bounds re-anchor to the current bottom-right default;
|
||||
- user-sourced bounds clamp into the visible viewport and persist the clamped result only after the viewport is usable;
|
||||
- remove `normalizedInitialBoundsRef` and the `rightGap > 160` reset. That heuristic is superseded by source-aware reconciliation and would otherwise wipe valid saved left-side placements.
|
||||
5. Replace direct `setBounds` calls for user-driven placement changes with a panel-local geometry updater:
|
||||
- drag and resize pointer moves should clamp and update React state for smooth feedback, but stage the latest bounds in a ref;
|
||||
- pointer up and pointer cancel commit the staged bounds to storage only after movement produced staged geometry. `localStorage.setItem` is synchronous, so do not write on every pointer move or after a titlebar click with no movement;
|
||||
- the panel-level `onMouseUp` size capture must not convert a default-sourced panel into a user-sourced panel after an ordinary click. Commit measured dimensions only after a real geometry interaction, or when the panel was already user-sourced;
|
||||
- resize handles should receive explicit preview/commit callbacks instead of the raw React setter, so resize, drag, and restore use the same persistence rules.
|
||||
6. When entering maximized mode, store the pre-maximized bounds and source in memory only. Do not persist the maximized rectangle. While maximized, viewport resize should recompute maximized bounds without touching the stored normal bounds or source. On restore, return to the stored normal bounds, reconcile them through the same source-aware rules, and persist only if the restored source is `user`.
|
||||
|
||||
## Consistency Model
|
||||
|
||||
- Persistence is a restart seed, not live cross-window synchronization.
|
||||
- Multiple renderer windows share the same localStorage key and therefore use last-writer-wins across restarts/reloads. Do not subscribe to `storage` events for live updates; another window or DevTools edit should not move an open panel mid-drag.
|
||||
- External storage mutations are picked up on the next renderer mount or reload.
|
||||
- The state is local renderer chrome. It must not go through worktree settings, terminal state, or SSH-backed runtime APIs.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Malformed or partial localStorage JSON falls back to default bounds.
|
||||
- Unavailable localStorage should not break the floating workspace; persistence simply becomes best-effort.
|
||||
- Startup can briefly report a zero-sized renderer; saved user bounds must not be clamped to that unusable viewport.
|
||||
- A saved position from a larger monitor should be clamped back on-screen on the current monitor.
|
||||
- A saved size larger than the current viewport should shrink to the largest size that still leaves a small visible margin while respecting minimum panel dimensions.
|
||||
- A viewport smaller than the minimum panel size should keep the panel at the minimum size and keep its top-left corner reachable.
|
||||
- Maximized mode should not overwrite the normal saved size and position.
|
||||
- Plain clicks inside a default-positioned panel should not make that default position sticky forever.
|
||||
- Pointer cancellation should commit the last valid drag or resize bounds, matching pointer-up behavior.
|
||||
- SSH-backed terminals keep using the same floating workspace UI; geometry persistence stays local and does not assume local command execution.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Extend `floating-terminal-panel-bounds.ts` with parse, source, viewport usability, resolve, and size-aware clamp helpers plus focused unit coverage.
|
||||
2. Wire `FloatingTerminalPanel` to read, reconcile, and persist panel bounds through those helpers; remove the legacy right-gap normalization.
|
||||
3. Update `FloatingTerminalResizeHandles` to use preview/commit callbacks so drag, resize, measured-size capture, and maximize restore share one persistence path.
|
||||
4. Add component coverage for persisted startup, bad storage, zero-sized startup deferral, default re-anchoring, user clamping, and the "plain click does not persist default" case.
|
||||
5. Run focused Vitest coverage, then `pnpm typecheck` and `pnpm lint`.
|
||||
6. Verify in Electron by moving/resizing the panel, reloading or restarting the dev app, checking maximized restore, and confirming evidence screenshots are not committed.
|
||||
@@ -1,137 +0,0 @@
|
||||
# GitHub Tasks Close Reason Parity
|
||||
|
||||
## Problem
|
||||
|
||||
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1025) renders `GHStatusCell` with only `Open` and `Closed`.
|
||||
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1064) sends `{ state: 'closed' }`, so the list path cannot choose GitHub close reasons.
|
||||
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1137) styles closed issues with rose/destructive color, while GitHub treats closing as a neutral/purple completion action rather than a delete/error action.
|
||||
- [GitHubItemDialog.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/GitHubItemDialog.tsx:4960) has a separate issue-detail status popover; it must expose the same close reasons so opening an issue from Tasks does not fall back to the old Open/Closed-only UI.
|
||||
- [GitHubIssueCommentComposer.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx:188) already supports close reasons in the detail composer, but the Tasks list does not.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the GitHub Tasks list status menu closer to GitHub:
|
||||
|
||||
1. Closed issue UI must stop using destructive red styling.
|
||||
2. Open issues must expose close-as-completed, close-as-not-planned, and close-as-duplicate from the status menu.
|
||||
3. Duplicate closes must require a target issue number and pass it as `duplicateOf`.
|
||||
4. Reopening still works from the same status menu.
|
||||
5. GitHub issue detail metadata must use the same close-reason and duplicate-picker behavior as the Tasks row.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No provider-generic changes for GitLab, Linear, or Jira.
|
||||
- No network-backed GitHub issue search picker in this pass; the duplicate picker uses the loaded Tasks cache plus an exact issue-number fallback.
|
||||
- No schema changes or cache migration.
|
||||
- No change to PR status behavior.
|
||||
- No automatic cross-window broadcast beyond the existing cache/list refresh mechanics; this pass updates the current renderer optimistically and relies on normal refetch in other windows.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a small TaskPage GitHub status menu model in a named module, including option metadata, duplicate-target validation, and payload construction for close reasons.
|
||||
2. Update `GHStatusCell` so `handleStateChange` accepts close options, optimistically patches only the issue `state`, and sends `stateReason` plus optional `duplicateOf`.
|
||||
3. Route issue-state mutations by issue identity:
|
||||
- If the row URL parses to `owner/repo`, use `github.project.updateIssueBySlug` / runtime `github.project.updateIssueBySlug`. This avoids closing the wrong repository for GitHub project/custom-source rows whose issue repo can differ from the caller repo.
|
||||
- Otherwise keep the existing `github.updateIssue` path with `repoPath`/`repoId`/`sourceContext`.
|
||||
4. Render the status popover with `Open`, `Close as completed`, `Close as not planned`, and `Close as duplicate` in both the Tasks row and issue-detail metadata sidebar. The duplicate row advances to a second-step picker with a back button, search field, loaded issue candidates, and an exact-number fallback.
|
||||
5. Replace rose closed-state classes with token-based primary/ring color-mix classes so the closed pill is completion-like, not destructive. Keep the open-state green treatment for parity with the existing Tasks page unless a broader status-color pass changes it.
|
||||
6. Keep the payload serializable for IPC/runtime/SSH: `{ state, stateReason, duplicateOf }` only contains strings and numbers.
|
||||
|
||||
## API Notes
|
||||
|
||||
- The local path in [issues.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/issues.ts:231) already closes issues with `gh issue close`. Preserve that model and pass:
|
||||
- `--reason completed` for `stateReason: 'completed'`
|
||||
- `--reason "not planned"` for `stateReason: 'not_planned'`
|
||||
- `--duplicate-of <number>` for `stateReason: 'duplicate'` with `duplicateOf`
|
||||
- The slug path in [mutations.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/project-view/mutations.ts:160) must not implement duplicates by PATCHing `duplicate_of` on `repos/{owner}/{repo}/issues/{n}`. `gh issue close --duplicate-of` is the supported CLI surface already used by the path-based mutation, so slug-addressed state changes should use `gh issue close/reopen --repo owner/repo` for state and reserve REST PATCH for title/body.
|
||||
- When `stateReason: 'duplicate'` is sent without a valid `duplicateOf`, reject it before dispatch. Do not silently downgrade to plain closed or `--reason duplicate`, because GitHub's duplicate UX expects the target issue to be recorded.
|
||||
- Keep `state_reason` REST PATCH only for non-duplicate close reasons if the implementation deliberately stays on REST for completed/not-planned. The simpler consistency target is to route all slug-addressed state changes through `gh issue close/reopen`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- User opens a GitHub issue status pill.
|
||||
- Completed/not-planned click -> `handleStateChange('closed', { stateReason })`.
|
||||
- Duplicate click -> second-step picker -> select a loaded issue or exact target number -> `handleStateChange('closed', { stateReason: 'duplicate', duplicateOf })`.
|
||||
- Cell optimistically patches local state and store row state.
|
||||
- Mutation goes through local IPC or runtime RPC with the same repo/source context.
|
||||
- Failure reverts the local/store state and shows the existing error toast.
|
||||
- Success records the existing `github-tasks` feature interaction and should invalidate or refresh the relevant work-items cache entry if the current filter can hide the row after the state changes (for example, an `is:open` filter). Without that, a closed row can remain visible until a later refetch.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Duplicate target is blank, zero, negative, decimal, or the same issue number: keep the picker open and show inline validation after Enter.
|
||||
- Duplicate target can be a different repository's issue only when the implementation accepts a URL; this pass takes a loaded same-repository issue or number, so validation and copy should describe it as an issue in the same repository.
|
||||
- The row refreshes while the menu is open: reconcile the optimistic draft before paint as the current status cell already does.
|
||||
- Rapid repeated choices: retain the existing request id guard so stale responses cannot revert a newer choice.
|
||||
- SSH/runtime target: payload must remain serializable and use the existing runtime method.
|
||||
- Multi-window consistency: do not assume another window's in-memory `workItemsCache` observes the optimistic patch. Ensure the mutation path updates GitHub authoritatively and that normal cache invalidation/refetch behavior eventually corrects other windows.
|
||||
- Project/custom-source rows: prefer the parsed issue URL slug over the selected local repo when available, because a GitHub Project can contain issues from repositories other than the current worktree.
|
||||
- PR rows and rows without a repo continue to render a non-editable status pill.
|
||||
- Rows without a parsed slug and without a local repo/source context should stay non-editable; do not attempt a mutation from only an issue number.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: menu model builds `{ state: 'closed', stateReason: 'completed' }`, `{ state: 'closed', stateReason: 'not_planned' }`, and duplicate payloads only for valid duplicate targets.
|
||||
- Unit: duplicate validation rejects missing/self/non-positive/non-integer targets.
|
||||
- Existing unit: optimistic status draft reconciliation remains unchanged.
|
||||
- Main unit: `updateIssue` emits `gh issue close --reason completed`, `gh issue close --reason "not planned"`, `gh issue close --duplicate-of <n>`, and `gh issue reopen`.
|
||||
- Main unit: `updateIssueBySlug` uses `gh issue close/reopen --repo owner/repo` for state changes, rejects duplicate without `duplicateOf`, and does not send unsupported `duplicate_of` REST fields.
|
||||
- Renderer unit: `GHStatusCell` uses slug-addressed mutation when `item.url` contains an owner/repo and falls back to `github.updateIssue` only when slug parsing is unavailable.
|
||||
- Store/cache unit: successful close from an open-filtered Tasks list invalidates or refreshes the affected work-items cache so hidden-by-filter rows do not linger indefinitely.
|
||||
- Electron validation: Tasks GitHub issue row status menu shows the new close options and closed issue pill is not red.
|
||||
- Electron validation: duplicate close picker appears after the duplicate action and supports issue search/exact-number fallback. Do not submit a real close mutation against user data.
|
||||
- Electron validation: GitHub issue detail status sidebar shows the same close options and duplicate picker as the list row.
|
||||
- Electron/runtime smoke: same menu opens and validation works when the active runtime target is an SSH/environment source; avoid a real close mutation unless using disposable test data.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
- Popover should feel like an action menu, not an error/destructive confirmation.
|
||||
- Closed issue pill must be visually distinct from open but not red/destructive.
|
||||
- Duplicate picker must fit in the popover at desktop and narrow widths without clipping.
|
||||
- Hover, focus, disabled, and inline validation states use existing shadcn primitives/tokens.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. GitHub Tasks issue row with status popover open showing all close reasons.
|
||||
2. Duplicate close second-step picker with search and issue candidates.
|
||||
3. Closed issue row/pill showing non-red completion styling.
|
||||
4. GitHub issue detail sidebar status popover showing all close reasons.
|
||||
5. GitHub issue detail duplicate close second-step picker.
|
||||
6. Adjacent smoke: GitHub Tasks header/search area still renders normally.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add status menu model and tests.
|
||||
2. Update `GHStatusCell` UI and mutation payload.
|
||||
3. Run focused tests, typecheck, and lint.
|
||||
4. Run UI quality review.
|
||||
5. Validate in Electron and capture screenshots.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: kept to the GitHub Tasks status cell plus a small model module; no provider-wide or network-backed search-picker work.
|
||||
- Architecture/data flow: preserves current renderer -> IPC/runtime mutation boundaries, but must route rows with parseable GitHub slugs through `updateIssueBySlug` so project/custom-source issues mutate the issue's own repository rather than the selected worktree repo.
|
||||
- API feasibility: path-based mutations already use the supported `gh issue close --duplicate-of` CLI path; slug-addressed mutations need the same CLI close/reopen treatment instead of REST `duplicate_of`.
|
||||
- Failure modes covered:
|
||||
- Invalid duplicate targets stay local and do not mutate.
|
||||
- Failed mutations revert optimistic state using the existing request id guard.
|
||||
- Runtime/SSH remains on the existing serializable RPC method.
|
||||
- Unsupported duplicate API shape is avoided by keeping duplicate close on `gh issue close --duplicate-of`.
|
||||
- Rows filtered by open/closed status do not rely solely on optimistic patching; the relevant work-items cache must be invalidated/refetched after success.
|
||||
- Test coverage required:
|
||||
- Unit tests for close update payloads and duplicate validation.
|
||||
- Main-process tests for both `updateIssue` and `updateIssueBySlug` close/reopen command generation.
|
||||
- Renderer routing tests for slug-addressed versus repo-path-addressed issue rows.
|
||||
- Cache invalidation/refetch coverage for status-filtered Tasks lists.
|
||||
- Existing status draft tests for optimistic reconciliation.
|
||||
- Electron screenshots for menu, duplicate validation, closed styling, and adjacent header smoke.
|
||||
- Performance/blast radius: no material concern; one extra tiny model import and no new polling/IPC.
|
||||
- UI quality bar: compact GitHub-like action menu, token-based non-destructive closed styling, no clipping or overlap.
|
||||
- Required review screenshots:
|
||||
1. Status popover with close reasons.
|
||||
2. Duplicate second-step picker with search and issue candidates.
|
||||
3. Non-red closed status pill.
|
||||
4. Tasks header/search smoke.
|
||||
- Residual risks:
|
||||
- Duplicate target search is limited to loaded Tasks cache candidates, with exact-number fallback for unloaded same-repository issues.
|
||||
- Other open Orca windows may show stale status until their normal GitHub Tasks data refresh runs.
|
||||
@@ -1,119 +0,0 @@
|
||||
# Image Viewer Pinch Zoom
|
||||
|
||||
## Problem
|
||||
|
||||
- `src/renderer/src/components/editor/ImageViewer.tsx:27` stores viewer zoom and exposes toolbar zoom controls.
|
||||
- `src/renderer/src/components/editor/ImageViewer.tsx:106` renders the inline image surface without a wheel handler.
|
||||
- `src/renderer/src/components/editor/ImageViewer.tsx:198` renders the popup image surface without a wheel handler.
|
||||
- On Chromium/Electron, trackpad pinch gestures arrive as `wheel` events with `ctrlKey: true`; the viewer does not intercept them, so users cannot pinch to zoom the image in or out.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`ImageViewer` only changes zoom through button clicks. The image surfaces let ctrl-wheel events bubble/default, so pinch gestures never update `zoom` and may be consumed by Chromium-level page zoom instead. The inline surface is scrollable only in `fill` layout; in `intrinsic` layout it is `overflow-visible`, so the fix must not depend on scrollability.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not redesign the image viewer chrome or add new controls.
|
||||
- Do not change PDF handling; `application/pdf` still delegates to `PdfViewer`.
|
||||
- Do not implement pan/drag or persistent per-file zoom.
|
||||
- Do not change image loading, file IPC, or SSH/runtime preview fetching.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add pure zoom helpers in a concrete renderer module such as `image-viewer-zoom.ts` so the math can be tested under the repo's node-based Vitest setup:
|
||||
- `clampZoom(next)` clamps to the existing `MIN_ZOOM` and `MAX_ZOOM`.
|
||||
- `shouldHandleImageZoomWheel(eventLike)` returns true only for ctrl-wheel input.
|
||||
- `getPinchZoomFactor(deltaY, deltaMode)` normalizes pixel/line/page wheel deltas and returns a bounded multiplier; `deltaY === 0` should not change zoom.
|
||||
- `getNextWheelZoom(currentZoom, deltaY, deltaMode)` applies the factor and clamp without touching React state.
|
||||
2. Add an `applyZoomChange(fn)` wrapper inside `ImageViewer` that uses functional `setZoom`, and reset `zoom` to `1` when `filePath`, `mimeType`, or `cleanedContent` changes. `EditorContent` can reuse the same `ImageViewer` component position across image files, so local zoom must not accidentally carry across file switches or external reloads.
|
||||
3. Add refs for the inline image surface and popup image surface. Bind native `wheel` listeners with `{ passive: false }`, not React `onWheel`; this repo already uses native non-passive wheel listeners where Chromium default prevention must be reliable. Keep the listener callback stable enough that it is not rebound on every zoom tick.
|
||||
4. The wheel handler must:
|
||||
- ignore ordinary wheel/trackpad scroll;
|
||||
- handle only `event.ctrlKey` wheel events, which Chromium/Electron uses for trackpad pinch and Ctrl+wheel zoom;
|
||||
- call `preventDefault()` and `stopPropagation()` for every handled ctrl-wheel event, including when already clamped, so browser/app zoom does not also change;
|
||||
- map negative `deltaY` to zoom in and positive `deltaY` to zoom out;
|
||||
- use the bounded delta-scaled multiplier instead of applying the full toolbar `ZOOM_STEP` once per wheel event.
|
||||
5. Attach the listener to both image surfaces, not to `document` or `window`, so editor/terminal/browser panes outside the image viewer keep their existing wheel behavior. The popup listener must attach after the Radix dialog content mounts and clean up when it unmounts; a callback ref or an effect keyed by `isPopupOpen` is acceptable.
|
||||
6. Keep existing toolbar buttons wired through the same clamp helper.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Trackpad pinch over image surface
|
||||
- Chromium emits `WheelEvent` with `ctrlKey`
|
||||
- Native non-passive `wheel` listener intercepts it
|
||||
- `zoom` state updates
|
||||
- Existing inline and popup image transforms render at the new scale
|
||||
- Footer percent updates from `zoomPercent`
|
||||
- When the rendered file changes, `zoom` resets to `1` while object URL creation/revocation stays on the existing path
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Ordinary scrolling without `ctrlKey` must still scroll the image surface.
|
||||
- Pinch gestures at `MIN_ZOOM` or `MAX_ZOOM` must stay clamped.
|
||||
- Ctrl-wheel at `MIN_ZOOM` or `MAX_ZOOM` must still prevent default browser zoom.
|
||||
- Popup and inline surfaces within one `ImageViewer` must stay in sync because they share one `zoom` state.
|
||||
- Image diff panes each mount their own `ImageViewer`; pinch over one side should only zoom that side. Do not introduce cross-pane sync in this fix.
|
||||
- `layout="intrinsic"` must receive pinch events even though the inline surface is not an overflow scroller.
|
||||
- Ctrl-wheel over the footer toolbar should keep existing button behavior and not become a hidden zoom target; the listener belongs on the image surface only.
|
||||
- PDF previews must remain unaffected.
|
||||
- The behavior must work for local and SSH-backed images because it is renderer-only after bytes are loaded.
|
||||
- Windows/Linux touchpads that also surface pinch as `ctrlKey` wheel events should work without platform-specific branches.
|
||||
- File switches, file reloads, external file mutations, and multi-window sessions should not add shared state: zoom remains local to the mounted viewer, resets for new loaded image content, object URL cleanup remains unchanged, and native wheel listeners must be removed on unmount/remount.
|
||||
- Existing CSS transform zoom does not resize the scrollable layout box. Do not try to solve transform-origin panning or full-image scroll extents in this change; keep pinch behavior consistent with the existing toolbar zoom.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit tests: cover the extracted zoom helpers under the existing node Vitest config; this repo does not currently include jsdom, happy-dom, or Testing Library, so do not promise DOM component tests unless that tooling is deliberately added.
|
||||
- Unit tests: assert negative/positive/zero `deltaY`, pixel/line/page `deltaMode` normalization, min/max clamping, and bounded per-event zoom factors.
|
||||
- Unit tests: assert the wheel decision helper ignores non-ctrl wheel events and treats ctrl-wheel as handled even when the resulting zoom is already clamped or `deltaY` is `0`.
|
||||
- Electron validation: open a PNG in Orca, dispatch a cancelable `WheelEvent` with `ctrlKey` on the inline image surface, verify the displayed percent and visual scale change, and verify an ordinary wheel still scrolls where applicable.
|
||||
- Electron validation: open popup and repeat after the dialog mounts; close/reopen the popup and repeat once to catch duplicate or stale native listeners.
|
||||
- Electron validation: open an image diff and verify ctrl-wheel on one pane changes only that pane's percent.
|
||||
- Electron validation: switch from one image file to another or reload changed image content and verify zoom returns to `100%`.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
Pinch zoom should feel like an existing viewer capability rather than a new UI surface: no layout shift, no new visible controls, footer percent remains stable, ordinary scrolling remains available, zoom changes are not jumpy under high-frequency trackpad events, and the popup chrome matches the current image viewer styling.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Inline image viewer at default `100%`.
|
||||
2. Inline image viewer after pinch zoom in, showing a higher footer percent.
|
||||
3. Popup image viewer after pinch zoom out/in, showing the same percent as the inline viewer for that `ImageViewer`.
|
||||
4. Image diff viewer after pinch zooming only one pane, showing the other pane unchanged.
|
||||
5. Adjacent smoke state: normal editor/file surface still renders after closing the popup.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add wheel/pinch zoom handling to `ImageViewer`.
|
||||
2. Add focused tests for the pinch handler behavior.
|
||||
3. Run typecheck, lint, and relevant renderer tests.
|
||||
4. Validate in Electron and capture screenshots.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: kept to `ImageViewer` gesture handling and focused tests; no new viewer state model, persistence, or chrome changes.
|
||||
- Architecture/data flow: renderer-only DOM wheel handling updates existing `zoom` state; no main-process, IPC, SSH, runtime, or persistence boundary changes.
|
||||
- Failure modes covered:
|
||||
- ordinary wheel scroll accidentally blocked;
|
||||
- browser/app zoom also responding to pinch;
|
||||
- zoom exceeding existing min/max;
|
||||
- popup and inline surfaces diverging;
|
||||
- diff panes being accidentally synchronized;
|
||||
- native listener leaks or duplicate listeners after popup open/close;
|
||||
- stale zoom state under rapid wheel event bursts;
|
||||
- accidental zoom carryover across image file switches or external reloads;
|
||||
- PDF previews accidentally receiving image gesture behavior.
|
||||
- Test coverage required:
|
||||
- node unit tests for extracted zoom math and wheel-decision helpers;
|
||||
- Electron validation for native listener default-prevention behavior, popup wheel handling, diff-pane isolation, and zoom reset on image content changes;
|
||||
- screenshot validation for inline default, inline zoomed, popup zoomed, image-diff isolation, and adjacent editor state.
|
||||
- Performance/blast radius: low but not free; trackpads emit many wheel events, so the handler must do O(1) work, use functional state updates, avoid layout reads, and avoid document/window listeners. Blast radius stays inside mounted `ImageViewer` instances.
|
||||
- UI quality bar: no new visible UI; existing footer percent and scrollable image surface should remain visually stable.
|
||||
- Required review screenshots:
|
||||
1. Inline image viewer at `100%`.
|
||||
2. Inline image viewer after pinch-equivalent zoom in.
|
||||
3. Popup image viewer after pinch-equivalent zoom.
|
||||
4. Image diff viewer after pinch-equivalent zoom on one pane only.
|
||||
5. Editor/file surface after closing popup.
|
||||
- Residual risks: automated Electron validation can dispatch cancelable ctrl-wheel DOM events against the listener, but it does not fully prove OS-level physical trackpad pinch behavior or Chromium's browser-zoom default path. A manual trackpad smoke test is still the final confidence check when hardware is available.
|
||||
@@ -1,95 +0,0 @@
|
||||
# VS Code WSL Workspace Launch
|
||||
|
||||
## Problem
|
||||
|
||||
On Windows, choosing **Open in VS Code** for a workspace stored under a WSL UNC path opens the folder in a Windows VS Code environment instead of a Remote - WSL window ([issue #7649](https://github.com/stablyai/orca/issues/7649)). The renderer passes the workspace path and configured editor command unchanged (`src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx:103-121`), and the main process delegates launch argument construction to `resolveExternalEditorLaunchSpec` (`src/main/ipc/shell.ts:101-110`). The builder currently gives every non-Cursor executable only the original path (`src/main/external-editor-launch.ts:109-116`).
|
||||
|
||||
The deterministic reproduction for `\\wsl.localhost\Ubuntu\home\aliuq\project` produces `code <UNC path>` with no remote authority. VS Code's supported Windows CLI form is `code --remote wsl+<distro> <Linux path>`.
|
||||
|
||||
## Root cause
|
||||
|
||||
`resolveExternalEditorLaunchSpec` does not distinguish a VS Code launch targeting a WSL UNC workspace. Orca already has a shared parser for both modern `\\wsl.localhost\...` and legacy `\\wsl$\...` paths (`src/shared/wsl-paths.ts:1-20`), but the editor launcher never uses it. VS Code therefore receives the Windows-visible UNC folder and correctly opens it as a local Windows workspace.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Change the Open in menu, settings UI, IPC payload, file-manager behavior, or SSH/remote-runtime behavior.
|
||||
- Infer WSL identity for ordinary drive-letter paths from project runtime settings.
|
||||
- Rewrite user-defined compound shell commands or add remote flags to Cursor, VSCodium, or arbitrary editors.
|
||||
- Install or configure the VS Code WSL extension.
|
||||
|
||||
## Design
|
||||
|
||||
1. In the external-editor launch-spec builder, parse the target path with the shared WSL UNC parser when the host platform is Windows.
|
||||
2. For direct/executable VS Code Stable or Insiders launchers only, reuse the existing normalized launcher-basename check and translate a recognized WSL target into `['--remote', 'wsl+<distro>', '<linuxPath>']`. The exact allowlist recognizes Stable's `code`, Insiders' `code-insiders`, and the direct `Code - Insiders.exe` basename without matching unrelated `code-*` editors. Matching is case-insensitive and strips every Windows launcher suffix already supported by Orca (`.cmd`, `.exe`, and `.bat`).
|
||||
3. Keep local paths, non-Windows hosts, non-VS-Code applications, and compound commands on their existing argument paths. The existing main-process spawn and Windows shim handling remain unchanged.
|
||||
4. Replace the temporary reproduction harness with focused regression cases in `src/main/external-editor-launch.test.ts` covering modern and legacy WSL UNC forms plus unaffected local/custom-editor behavior.
|
||||
|
||||
## Data flow
|
||||
|
||||
- Worktree menu selects VS Code and sends `(workspacePath, 'code')` over existing IPC.
|
||||
- Main process validates the absolute existing host path.
|
||||
- Launch-spec builder resolves the VS Code executable.
|
||||
- On Windows + WSL UNC + VS Code, the builder emits the Remote - WSL authority and Linux-native folder path.
|
||||
- Existing Windows spawn wrapping launches VS Code with those arguments.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Modern `\\wsl.localhost\<distro>\...` and legacy `\\wsl$\<distro>\...` paths both preserve the distro spelling and convert separators to a POSIX path.
|
||||
- Distro names and Linux folder paths containing spaces remain single arguments because launch-spec construction and Windows shim wrapping preserve argument-array boundaries.
|
||||
- A WSL distro root maps to `/`.
|
||||
- Windows drive-letter and ordinary UNC paths stay local.
|
||||
- macOS/Linux behavior stays unchanged even for strings that resemble WSL UNC paths.
|
||||
- Cursor retains `--new-window`; other custom editors retain their existing single path argument.
|
||||
- Compound commands remain user-owned and are not rewritten because inserting flags safely would require parsing arbitrary shell syntax.
|
||||
- SSH and remote-runtime workspaces remain blocked from local path opening by the existing renderer guard; this change does not alter that boundary.
|
||||
- If the VS Code WSL extension is unavailable, launch behavior is left to VS Code and Orca retains its existing spawn-success contract.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: demonstrate the pre-fix launch spec lacks `--remote` for a modern WSL UNC path.
|
||||
- Unit: assert the fixed modern UNC launch is `--remote`, `wsl+Ubuntu`, `/home/...`.
|
||||
- Unit: assert legacy `\\wsl$` and distro-root paths produce the same Remote - WSL form.
|
||||
- Unit: assert direct and resolved Windows VS Code Stable and Insiders launchers are matched case-insensitively across `.exe`, `.cmd`, and `.bat` suffixes.
|
||||
- Unit: assert distro names and Linux folder paths containing spaces remain intact arguments through launch-spec construction and Windows shim forwarding.
|
||||
- Unit: assert a Windows local path remains unchanged and explicit `darwin` and `linux` hosts do not acquire WSL remote arguments.
|
||||
- Unit: assert Cursor and another custom editor are not given VS Code remote arguments.
|
||||
- Integration/Electron: create a throwaway repo in the installed Ubuntu WSL distro, add/open it in Orca, choose Open in VS Code, and verify the VS Code remote indicator and an integrated-terminal Linux probe.
|
||||
- Adjacent smoke: open a local Windows workspace in VS Code and verify it remains a local Windows window.
|
||||
- Repository gates: focused Vitest files, `pnpm typecheck`, `pnpm lint`, and `pnpm check:max-lines-ratchet`.
|
||||
|
||||
## UI quality bar
|
||||
|
||||
Not UI-visible in Orca. The existing menu, labels, loading behavior, and errors do not change. The user-visible acceptance criterion is external: the launched VS Code window must identify the selected WSL distro and its terminal must run Linux.
|
||||
|
||||
## Review screenshots
|
||||
|
||||
1. Golden path: VS Code opened from a throwaway WSL workspace, showing the Remote - WSL indicator and a terminal Linux probe.
|
||||
2. Adjacent local path: VS Code opened from a local Windows workspace, showing a local Windows terminal/environment.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add launch-spec regression tests and observe the WSL case fail against the desired arguments.
|
||||
2. Add the scoped VS Code + Windows + WSL argument translation.
|
||||
3. Run focused and repository-wide static/test gates.
|
||||
4. Validate WSL and local launches end to end, retaining local screenshots outside the PR.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to the launch-spec seam; no renderer, IPC, persistence, runtime-routing, or settings changes are needed because WSL filesystem identity is encoded in the UNC path.
|
||||
- Architecture/data flow: Reuse `parseWslUncPath` in the main-process pure argument builder, then leave executable resolution, Windows shim wrapping, spawn lifecycle, and renderer guards unchanged.
|
||||
- Failure modes covered:
|
||||
- Modern and legacy WSL UNC aliases route to the correct distro and Linux path.
|
||||
- Local/ordinary UNC paths and non-Windows platforms do not acquire remote flags.
|
||||
- Case variants and supported Windows VS Code shim suffixes are recognized without classifying custom editors or compound shell commands as VS Code.
|
||||
- Distro and folder names containing spaces survive the Windows shim as distinct arguments.
|
||||
- Missing VS Code WSL support still follows the existing launch contract rather than adding a new partial-failure protocol.
|
||||
- Test coverage required:
|
||||
- Pure launch-spec regression cases in `src/main/external-editor-launch.test.ts` for WSL aliases/root, launcher case/suffix variants, and unaffected branches.
|
||||
- `src/main/ipc/shell.test.ts` plus the existing Windows shim contract to prove remote arguments, including spaces, are forwarded as distinct values.
|
||||
- Live Windows + Ubuntu WSL + VS Code smoke for the actual environment boundary.
|
||||
- Performance/blast radius: One regex parse per external-editor click only; no startup, polling, watcher, terminal, or renderer cost. Blast radius is limited to direct VS Code launches of WSL UNC paths on Windows.
|
||||
- UI quality bar: Not UI-visible in Orca; VS Code must visibly attach to the requested WSL distro and run a Linux terminal.
|
||||
- Required review screenshots:
|
||||
1. WSL VS Code window with distro indicator and Linux terminal probe.
|
||||
2. Local Windows VS Code window demonstrating unchanged local launch behavior.
|
||||
- Residual risks: VS Code without the WSL extension may reject or prompt on the valid remote launch; this is external dependency behavior and should not cause Orca to fall back silently to the wrong Windows environment.
|
||||
@@ -1,203 +0,0 @@
|
||||
# Kill all sessions also closes terminal tabs
|
||||
|
||||
GitHub: https://github.com/stablyai/orca/issues/8001
|
||||
Branch: `bug-kill-all-sessions-doesnt-kill-terminals`
|
||||
|
||||
## Problem
|
||||
|
||||
Settings → Manage Sessions and Resource Manager expose **Kill all sessions**. The action currently calls only `window.api.pty.management.killAll()`; it does not remove renderer terminal tabs or dispose xterm instances.
|
||||
|
||||
The main handler is narrower than the UI wording:
|
||||
|
||||
- `pty:management:killAll` snapshots sessions from the current and legacy **daemon** adapters, shuts them down in parallel, then polls only those initial IDs.
|
||||
- It does not inventory SSH or runtime-hosted terminals. A degraded local provider is also outside the management adapter set.
|
||||
- The nominal poll sleep budget is 6.5 seconds (65 × 100 ms), but adapter round-trip time is additional. Daemon requests time out after 30 seconds and connection setup has separate 5-second steps, so total latency can be much longer.
|
||||
- Failed adapter listings are dropped by `collectSessions`, so counts are not authoritative when an adapter is unreachable. This applies during polling too: an adapter that listed an initial session and then becomes unreachable can make that session disappear from `remainingCount` and be counted as killed without a confirming listing.
|
||||
|
||||
Renderer exit handling then preserves two important startup-failure states:
|
||||
|
||||
- a sole, freshly spawned pane that exits before any user input; and
|
||||
- a freshly split pane that exits before input or output.
|
||||
|
||||
Those guards are correct for direnv/shell setup failures, but they cannot distinguish an explicit bulk kill. A sole fresh-spawned pane remains if the user never typed, even if it produced output or lived for a while; typed/reattached panes normally close through the existing exit path.
|
||||
|
||||
## Goal and invariant
|
||||
|
||||
After confirmation, every terminal tab that existed in the invoking desktop renderer at that moment is removed, including dead/no-PTY tabs, and its renderer resources are disposed. Terminal tabs created later are not targeted. Initial daemon sessions still receive the existing management shutdown request, while current non-runtime PTY bindings owned by the targeted tabs receive exact per-ID shutdown requests.
|
||||
|
||||
The target is the confirmed terminal **surface ID**, not a liveness inference. If the same targeted tab rebinds before cleanup completes, it is still closed; a newly created tab ID survives.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing **Restart daemon**, which intentionally leaves panes available to reopen.
|
||||
- Deleting or sleeping worktrees, closing browser/editor tabs, or killing the daemon process.
|
||||
- Changing the sole-pane/fresh-split startup-failure guards.
|
||||
- Promising an immediate OS working-set drop; GC and allocator behavior are nondeterministic. The deterministic contract is that tabs/xterms are released and observable daemon/exact-binding shutdown-request failures are surfaced. Runtime-host result propagation remains an accepted gap below.
|
||||
- Turning this issue into a new cross-provider global kill API. Exact `pty.kill(id)` calls for current bindings of the confirmed tabs are in scope; inventorying and killing unrelated provider sessions is not.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Snapshot terminal surfaces once
|
||||
|
||||
At confirmation, before `onKillAllStart` and before the first `await`, collect and deduplicate terminal entity IDs from both `tabsByWorktree` and terminal entries in `unifiedTabsByWorktree`. Include the floating-terminal workspace.
|
||||
|
||||
Keep this immutable target set through the async daemon call:
|
||||
|
||||
- a target already closed elsewhere becomes a no-op;
|
||||
- a target moved between groups/worktrees is resolved from current state and still closes;
|
||||
- a terminal created after the snapshot is never closed.
|
||||
|
||||
Do not snapshot all terminal IDs again after the daemon call, and do not retain the confirmation-time worktree as cleanup authority. The target ID is immutable; ownership and active-last ordering are resolved again from current state immediately before cleanup.
|
||||
|
||||
### 2. Keep daemon shutdown semantics, then close the snapshot
|
||||
|
||||
Call the existing `window.api.pty.management.killAll()` once. After it settles, resolve the still-present targets and deduplicate their current `ptyIdsByTabId` entries. These are the renderer's current binding ownership records, not independent liveness proof; do not pull IDs from `tab.ptyId`, deferred SSH restore state, or `terminalLayoutsByTabId.ptyIdsByLeafId`, which can be restore hints rather than current ownership.
|
||||
|
||||
Force-close every snapshotted surface through `closeTerminalTab(id, { force: true })`, then await one existing `window.api.pty.kill(id)` call for each captured non-`remote:` PTY ID. Run both phases for management success, partial success, and IPC rejection because the confirmation explicitly covers closing the terminal surfaces. Use per-target/per-PTY settlement so one unexpected store or provider failure does not stop the remaining cleanup. Already-gone PTYs are successful no-ops in the main handler; other exact-kill rejections are reported separately.
|
||||
|
||||
Close targets outside the cleanup-time `activeWorktreeId` first and targets currently owned by it last. A tab moved into the active worktree or a workspace selected while the daemon call was pending must therefore move to the last partition. Let `closeTerminalTab` choose the existing post-state for the last local terminal: editor, then browser, then `setActiveWorktree(null)`. Do not pre-clear `activeWorktreeId`; the sleep flow needs that ordering because it leaves tab records mounted while clearing PTYs, whereas this flow removes the tabs. Add active-switch and active-last regression tests and only adopt sleep-intent/deactivate-first if those tests prove a respawn.
|
||||
|
||||
This is not one backend call end-to-end. The coordinator issues one daemon-management IPC, one renderer close per unique target, and at most one exact kill IPC per unique current non-runtime PTY binding. The explicit exact kills make local/SSH shutdown requests settle before caller refresh instead of relying on React unmount timing. A mounted transport may race that request and issue an idempotent duplicate during unmount; count that separately in live performance evidence. A still-bound daemon session can receive the exact retry after the management poll, so daemon toast counts remain the management handler's earlier reported snapshot, not final process truth. Runtime-hosted tabs additionally issue one `session.tabs.close` flow per tab.
|
||||
|
||||
The coordinator must live outside the hook (for example, `kill-all-terminal-surfaces.ts`) and must not gate cleanup on `mountedRef`. Navigating away or unmounting the invoking popover/settings component while IPC is pending does not revoke an already confirmed destructive action; only React callbacks and hook state updates remain mount-gated.
|
||||
|
||||
### 3. Do not add a post-kill orphan sweep
|
||||
|
||||
Do not call `pty.listSessions()` and kill everything returned after the management call. Exact kills captured from `ptyIdsByTabId` for the immutable target surfaces are allowed and required; they do not discover or cross into unrelated sessions.
|
||||
|
||||
The management handler already targets daemon orphans in its initial snapshot. A later broad sweep would cross the confirmation boundary by killing local/SSH sessions or fresh sessions created while the existing poll was running. It would also add provider inventory fan-out to a recovery action and conflict with the main handler's initial-ID accounting.
|
||||
|
||||
The existing explicit **Kill orphan terminals** action remains separate.
|
||||
|
||||
### 4. Make completion and failure copy honest
|
||||
|
||||
Have the renderer cleanup return a bounded summary: target count, targets absent at completion, failed close attempts, exact PTY kill requests accepted/rejected, and daemon result/error. Verify absence in both terminal and unified state after each close attempt. Toasts must report renderer cleanup separately from daemon counts; do not show **No sessions running** when terminal tabs were closed.
|
||||
|
||||
Because the management API suppresses adapter-list failures, copy must describe daemon counts as reported, not claim that every daemon process was verified dead. Returning adapter errors is a follow-up unless this PR chooses to widen the API. Exact `pty.kill` rejection counts may be stated as failed shutdown requests, not as proof that the processes remain alive.
|
||||
|
||||
Examples of states, not required wording:
|
||||
|
||||
- all daemon targets were reported exited, exact shutdown requests were accepted, and tabs closed: success;
|
||||
- `remainingCount > 0`: warning that management reported daemon processes not exited before exact binding cleanup;
|
||||
- management IPC rejected: error that tabs closed, with daemon shutdown unverified and any exact PTY kill failures called out;
|
||||
- no daemon sessions and no terminal tabs: existing informational state.
|
||||
|
||||
Run `onKillAllSettled` only after surface removal and exact PTY kill settlement. `closeTerminalTab` returns before React unmount effects run, so surface removal alone is not a sufficient boundary for Resource Manager's `pty.listSessions()` refresh. Keep one confirmation dialog and update its description to say that terminal tabs across workspaces close and unsaved terminal work is lost.
|
||||
|
||||
### 5. Provider limits
|
||||
|
||||
`closeTerminalTab` synchronously prunes host-backed local mirrors but currently returns `void` and discards the promise from `closeWebRuntimeSessionTab`. The underlying helper is already awaitable: its close request has a 15-second timeout and, after success, it awaits an eager list refresh with another 15-second timeout. Its boolean can therefore settle after roughly 30 seconds, while close-intent suppression expires after 10 seconds. This change must not claim verified runtime-host completion unless the bulk helper propagates that existing result and aligns the intent lifetime.
|
||||
|
||||
For this Windows/local bug, runtime-host cleanup is best-effort and an explicit accepted gap. A follow-up may propagate the existing async close result through terminal-tab actions and align the RPC timeout/intent lifetime. SSH must be exercised if the fixture is available; otherwise record live SSH process absence as an accepted gap. No provider-specific local filesystem or process assumptions are added.
|
||||
|
||||
## Data flow
|
||||
|
||||
```text
|
||||
confirm
|
||||
→ snapshot unique terminal surface IDs
|
||||
→ pty.management.killAll() daemon current + legacy; existing polling
|
||||
→ resolve current target ownership + bound PTY IDs
|
||||
→ close snapshotted tabs, active last renderer/xterm cleanup; runtime-host close starts
|
||||
→ await exact non-runtime pty.kill IDs local/SSH acknowledgement; no provider sweep
|
||||
→ toast daemon result + surface/exact-kill summary
|
||||
→ caller refresh
|
||||
```
|
||||
|
||||
## Concurrency and consistency
|
||||
|
||||
| Case | Required behavior |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| No daemon sessions; dead/empty tabs exist | Close target tabs and report tab cleanup, not “No sessions.” |
|
||||
| Daemon session refuses to exit | Close the confirmed tab; report that management saw it remaining before the exact retry. Refresh may expose it as an orphan. |
|
||||
| Adapter listing fails | Close target tabs; do not claim all processes were verified dead. Existing management counts may read as zero. |
|
||||
| Management IPC rejects | Still close confirmed tabs and issue exact current-binding kills; report daemon uncertainty and exact-kill errors. |
|
||||
| New tab/session during the wait | New tab ID survives. No post-snapshot provider sweep. |
|
||||
| Target closes or moves during the wait | Missing target no-ops; moved target closes by current ownership. |
|
||||
| Target tab rebinds to a new PTY | Close it because the confirmed surface, not the old PTY snapshot, is the authority. |
|
||||
| Pinned or split terminal | `force: true` bypasses a second prompt; closing the tab closes all of its panes. |
|
||||
| Active last terminal | Existing close routing selects editor/browser or deactivates; no replacement terminal spawns. |
|
||||
| Two overlapping invocations | Cleanup is idempotent by tab ID. Each main call retains its own initial daemon snapshot/counts. |
|
||||
| Binding changes after exact-ID capture | Surface close still wins; transport unmount kills the replacement, but that late kill is not in the awaited count. |
|
||||
| Runtime-host snapshot races close | Existing close intent suppresses stale snapshots temporarily; host completion remains an accepted gap. |
|
||||
| External/mobile session creation | Sessions created after the main/renderer snapshots are not chased. Existing PTY-exit publication updates mirrors. |
|
||||
|
||||
Orca's production UI currently tracks a single desktop `mainWindow`/renderer. The main daemon action is process-global while renderer cleanup is renderer-local. If multiple desktop windows become supported, this action must be broadcast from main with an action ID and per-window acknowledgement; “shared store probably mirrors it” is not sufficient.
|
||||
|
||||
## Tests and reliability gate
|
||||
|
||||
Add an experimental, `protection: "partial"` `terminal-session.kill-all-surface-cleanup` entry to `config/reliability-gates.jsonc`, owned by `terminal-runtime` at the renderer/main contract layer. The entry must name the focused coordinator and `pty-management` test files/commands and include non-empty assertion refs; otherwise the manifest checker rejects a partial gate.
|
||||
|
||||
- **Invariant:** confirmed terminal surfaces close exactly once; later-created surfaces survive; no broad post-snapshot kill occurs.
|
||||
- **Failure source:** issue #8001 plus the sole-fresh/fresh-split exit guards that intentionally preserve dead startup-failure panes.
|
||||
- **Deterministic oracle:** the initial tab IDs disappear from both terminal/unified state, non-terminal and later-created tabs remain, active state is valid, exact kills are issued only for deduplicated current target bindings, all exact-kill promises settle before the caller callback, and no `pty.listSessions` sweep occurs. Provider inventory absence is live validation, not the renderer-unit oracle.
|
||||
- **Diagnostics:** emit one content-free bounded summary with target/absent/failed-close counts, exact-kill accepted/rejected counts, and the daemon result or error. Runtime-host completion remains unknown until terminal-tab actions propagate the existing async close result.
|
||||
|
||||
Required deterministic coverage:
|
||||
|
||||
- snapshot/dedup across multiple worktrees, unified-only terminals, splits, and floating terminals;
|
||||
- pinned close with `force`, while browser/editor tabs remain;
|
||||
- terminal added after the snapshot survives; missing/moved target behavior;
|
||||
- active-last post-state for editor, browser, and no-other-content cases, including no auto-spawn;
|
||||
- coordinator ordering for success, partial result, zero sessions, and rejected management IPC;
|
||||
- exact binding capture excludes restore hints and `remote:` IDs; late-created tab bindings are not captured;
|
||||
- exactly one management call, zero `pty.listSessions` calls, one close attempt per unique target, and one coordinator-issued exact kill per unique captured non-runtime PTY ID;
|
||||
- invoking component unmount during the management wait does not cancel store/provider cleanup or invoke mount-gated callbacks;
|
||||
- existing `src/main/ipc/pty-management.test.ts` cases remain green.
|
||||
|
||||
Electron validation on Windows must reproduce an empty shell plus an established terminal, invoke each entry point, and prove:
|
||||
|
||||
- target terminal tabs and `.xterm` surfaces are gone;
|
||||
- initial local/daemon PTY IDs are absent after settle, except daemon IDs reported remaining and exact IDs whose shutdown request failed;
|
||||
- a terminal created after confirmation survives;
|
||||
- Resource Manager and Manage Sessions refresh to the same result.
|
||||
|
||||
Planned provider/platform accounting:
|
||||
|
||||
| Row | Planned status |
|
||||
| --- | --- |
|
||||
| Local PTY / daemon / Windows | Covered by deterministic coordinator/main tests plus the required live Electron run. |
|
||||
| macOS / Linux local and daemon | Shared code is covered deterministically; live process absence remains an accepted gap until run there. |
|
||||
| SSH | Exact request/settlement is covered by provider-contract mocks; live remote-process absence is covered only when the SSH fixture runs, otherwise accepted-gap. |
|
||||
| WSL | Uses the local exact-ID path, but real WSL process absence is an accepted gap unless included in the Windows run. |
|
||||
| Remote runtime | Local mirror removal is covered; host completion is accepted-gap while `closeTerminalTab` discards the async result. |
|
||||
| Mobile/relay | No newly created session is chased; shared host-tab disappearance and live relay shutdown remain accepted gaps unless exercised with their fixtures. |
|
||||
|
||||
Screenshots are UI evidence only; they do not prove PTY or memory cleanup.
|
||||
|
||||
## Performance and blast radius
|
||||
|
||||
No interval, watcher, or extra provider listing is added. The existing management call can wait for 65 sleeps plus adapter latency. The coordinator adds at most one existing exact-kill IPC per unique current non-runtime PTY binding; record both those calls and any idempotent unmount duplicates as part of the performance evidence.
|
||||
|
||||
Repeated `closeTerminalTab` calls are not O(tabs): target resolution and store cascades scan/clone multiple maps per close, so worst-case renderer work is superlinear. Measure the synchronous close phase with a representative high-tab fixture (at least 100 terminal tabs). Record close attempts, Zustand writes, duration, and long tasks. If it creates a >50 ms renderer task, add a focused bulk reducer or yield-safe batching rather than declaring the rare action free.
|
||||
|
||||
For resource reclamation, prefer deterministic counts: zero target `TerminalPane`/xterm instances, released listeners/transports, and no initial PTY IDs. A repeated open/kill cycle may supplement this with heap/working-set evidence, but absolute memory deltas are not a stable pass/fail oracle.
|
||||
|
||||
## UI quality bar and screenshots
|
||||
|
||||
No new layout or visual token is needed. Keep the existing shadcn `Dialog`/destructive `Button`, compact typography, dismissal lock while busy, and accurate consequence-first copy. The post-state must never be a blank/crashed TerminalPane or a wall of dead empty panes.
|
||||
|
||||
Required review screenshots:
|
||||
|
||||
1. Resource Manager before kill with an empty terminal and an established terminal visible in the workspace.
|
||||
2. Resource Manager kill-all confirmation with updated close-tabs copy.
|
||||
3. Matched post-kill workspace showing target terminal tabs gone and refreshed counts.
|
||||
4. Manage Sessions confirmation or post-state, proving the second entry point uses the same behavior.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- **Scope:** renderer surface cleanup layered on the existing daemon management action; no daemon redesign and no broad orphan/provider sweep.
|
||||
- **Architecture/data flow:** main owns its initial daemon-session shutdown; renderer owns the confirmed terminal-surface snapshot, exact current-binding shutdown, and tab/xterm disposal; runtime-host close remains behind existing routing.
|
||||
- **Failure modes:** partial/rejected daemon kill, exact provider-kill failure, no-session cleanup, pinning, splits, cleanup-time active selection, new/moved/disappeared tabs, same-tab rebind, overlapping actions, invoking-component unmount, stale runtime snapshots, adapter-list uncertainty, and future multi-window ownership are explicit above.
|
||||
- **Tests:** deterministic state/coordinator and main-handler tests plus Windows Electron proof; a new experimental partial reliability gate records provider gaps.
|
||||
- **Performance/blast radius:** one existing management IPC, bounded exact per-PTY kill IPCs, no new listing/polling, but N close cascades require measurement because current helpers are not linear or atomic.
|
||||
- **UI quality bar:** existing dialog primitives and truthful copy; screenshots prove both entry points and the terminal-free post-state, not memory reclamation.
|
||||
- **Residual risks:** daemon adapter listing can stall until client timeouts and failures are treated as empty; terminal-tab actions discard the runtime-host close result and its 10-second intent is shorter than the possible two-request close flow; a binding created after exact-ID capture is killed only by transport unmount; live SSH/WSL/mobile behavior may remain accepted gaps until fixtures prove it.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the snapshot/coordinator and deterministic tests.
|
||||
2. Wire it only through `useDaemonActions.runKillAll`; keep the coordinator outside the hook and update toast/dialog copy.
|
||||
3. Add the experimental reliability gate and performance count/latency evidence.
|
||||
4. Run focused tests, typecheck, lint, and max-lines ratchet checks.
|
||||
5. Validate both entry points in Electron on Windows and capture the required screenshots.
|
||||
6. Force-add this doc when staging because `docs/**` is ignored by default.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Linear Issues Load More
|
||||
|
||||
## Problem
|
||||
|
||||
- `src/renderer/src/components/TaskPage.tsx:288` defines `LINEAR_ITEM_LIMIT = 36`.
|
||||
- The plain Linear Issues tabs fetch with that fixed limit in `TaskPage.tsx:4678` and store only `LinearIssue[]`, so All/Assigned/Created/Completed have no `hasMore` state and no in-app way to request more rows.
|
||||
- `src/main/linear/issues.ts:291` returns `Promise<LinearIssue[]>` from `listIssues`. The current list GraphQL queries also do not request `pageInfo`, so the main process cannot infer whether Linear has more results.
|
||||
- `src/main/ipc/linear.ts:115` and `src/main/runtime/orca-runtime.ts:12415` clamp plain issue list reads to 50. A 36-row "Load more" step would request 72 but receive at most 50 unless these clamps change.
|
||||
- Project and custom-view issue reads already return `LinearCollectionResult<LinearIssue>` and render `LinearCollectionNotice`, but their backend helpers cap at 50 and only expose a passive "Search or open Linear" message.
|
||||
|
||||
## Goal
|
||||
|
||||
Let users browse more issues from the Linear Issues All tab inside Orca with an explicit "Load more" action. Apply the same plain-list behavior to Assigned, Created, and Completed because they share the same store/runtime/main read path.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add cursor-based infinite scrolling.
|
||||
- Do not change Linear search behavior; search can stay capped and relevance ordered.
|
||||
- Do not add load-more behavior to project/custom-view issue lists in this change.
|
||||
- Do not change Linear auth, workspace selection, team filtering semantics, or issue mutation behavior.
|
||||
|
||||
## Design
|
||||
|
||||
1. Return collection metadata from plain issue list reads.
|
||||
- Change main `listIssues` to return `LinearCollectionResult<LinearIssue>`.
|
||||
- Add `pageInfo { hasNextPage }` to `ALL_ISSUES_QUERY`, `VIEWER_ASSIGNED_ISSUES_QUERY`, and `VIEWER_CREATED_ISSUES_QUERY`, and update `LinearIssueConnectionResponse` accordingly.
|
||||
- Keep `searchIssues` returning `LinearIssue[]`; it relies on Linear relevance order and should not get the load-more affordance.
|
||||
|
||||
2. Raise and align list limits deliberately.
|
||||
- Replace the current 50-item plain-list clamps in IPC and runtime with a named plain-issue-list max that is higher than one load-more step, for example 180 or 216. Keep the search clamp at 50.
|
||||
- Use the same clamp in local IPC and remote runtime paths. Do not let the renderer cache a request under limit 72 while the backend silently serves 50.
|
||||
- Main `listIssues` should clamp/floor its own `first` value too, so direct callers and future RPC paths cannot send unbounded GraphQL reads.
|
||||
- Keep project/custom-view clamps unchanged unless this feature explicitly expands those lists too.
|
||||
|
||||
3. Compute `hasMore` accurately.
|
||||
- For each workspace result, set `hasMore` from the connection `pageInfo.hasNextPage`.
|
||||
- For any multi-workspace plain-list read, also set `hasMore` when the merged, sorted list is clipped to the effective limit. This is not limited to the All preset; Assigned, Created, and Completed can also fan out across selected workspace `all`.
|
||||
- Sort and cap the merged list the same way the current implementation does. This remains a first-N refetch design, not cursor pagination.
|
||||
|
||||
4. Thread the envelope through IPC, runtime RPC, preload, and the renderer store.
|
||||
- Update `window.api.linear.listIssues`, `linearListIssues`, runtime RPC typing, preload API types, and `LinearSlice.listLinearIssues` to return `LinearCollectionResult<LinearIssue>`.
|
||||
- Plain list entries currently live in `linearSearchCache` under `linearListCacheKey`. Either keep that cache bucket and change the value type to support both arrays and collection results, or introduce a dedicated `linearListCache`. Do not mix raw arrays and envelopes under one declared `CacheEntry<LinearIssue[]>` type.
|
||||
- Split or retarget `inflightListRequests`/`InflightLinearListRequest` separately from search in-flight requests. The current shared in-flight type is `Promise<LinearIssue[]>`, which will be wrong once only list reads return an envelope.
|
||||
- Keep cache identity based on workspace, filter, and the effective clamped limit. Compute that effective limit before request signatures and cache keys; do not key a request by an unclamped value that the backend will silently reduce.
|
||||
- Existing generation and in-flight entry checks protect cache writes for the same key, but different limits are different keys. `TaskPage` still needs a latest-request guard so a slower 36-row promise cannot replace a newer 72-row window in component state.
|
||||
- Update `getCachedLinearIssues` or add a collection-aware cached read so `TaskPage` can keep existing rows visible while a larger window loads.
|
||||
- If plain lists keep using `linearSearchCache`, update every array-shaped consumer of that bucket, including `findTaskPageLinearIssue` and its `LinearSearchCache` alias in `task-page-cache-selectors.ts`, `patchLinearIssue`, and any `LinearSearchCache`/`LinearIssueReadArgs` test helpers. Otherwise a list envelope stored beside search arrays will break drawer lookup, row reconciliation, and optimistic patch propagation.
|
||||
|
||||
5. Add first-N "Load more" state in `TaskPage`.
|
||||
- Replace the fixed list-read limit with `linearIssueLimit` state initialized to `LINEAR_ITEM_LIMIT`.
|
||||
- Reset the limit to `LINEAR_ITEM_LIMIT` when workspace, preset, search query, Linear mode context, or selected project/custom-view changes back to the plain issue list.
|
||||
- For plain list reads, store `result.items` and `result.hasMore`; for search reads, keep the existing array path and no button.
|
||||
- On "Load more", increase by `LINEAR_ITEM_LIMIT`, fetch the larger first-N window, and leave current rows visible during the request.
|
||||
- The request signature and landing-refresh key must include the effective limit; otherwise a 36-row landing probe and a 72-row load-more request can be treated as the same request. Compare the resolving request's signature to the latest signature before setting `linearIssues` or `hasMore`.
|
||||
|
||||
6. Make the shared notice optionally actionable.
|
||||
- Extend `LinearCollectionNotice` with optional `onLoadMore`, `loading`, and label props.
|
||||
- Only render the button when `hasMore` and `onLoadMore` are both present.
|
||||
- Preserve the existing passive copy for project/view notices that do not pass a handler.
|
||||
- Use existing shadcn button primitives and styleguide tokens; keep the footer compact and keyboard reachable.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- User opens Tasks -> Linear -> Issues -> All.
|
||||
- `TaskPage` reads `linearIssueLimit`.
|
||||
- Store calls `linearListIssues(settings, filter, linearIssueLimit, workspaceId)`.
|
||||
- Local IPC or remote runtime calls main `listIssues(filter, effectiveLimit, workspaceId)`.
|
||||
- Main requests `first: effectiveLimit` per selected workspace and returns `{ items, hasMore }`.
|
||||
- `TaskPage` renders `items` and passes `hasMore` plus `onLoadMore` to `LinearCollectionNotice`.
|
||||
- User clicks "Load more" -> `linearIssueLimit += 36` -> the effect refetches first N+36 and replaces the visible window when the newer request resolves.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Search query is non-empty: do not show Load more; Linear search remains capped and relevance ordered.
|
||||
- User switches preset, workspace, Linear mode, or selected project/view context: reset the plain issue limit to 36.
|
||||
- Cached 36-row result exists and user requests 72: use a distinct cache key and keep 36 rows visible while fetching 72.
|
||||
- Refresh while at 72 rows: force-refresh the 72-row window, not the initial 36-row window.
|
||||
- Backend clamp reached: hide or disable Load more once the current limit equals the configured max, even if Linear reports `hasNextPage`.
|
||||
- Multi-workspace reads: if any workspace has more pages or the merged rows are clipped, show Load more for any plain preset.
|
||||
- Workspace failure in `all`: current plain `listIssues` swallows failures into `[]`; adding `errors` would be a behavior change. Either keep swallowing for this feature or deliberately adopt the project/view `errors` behavior and update UI/tests.
|
||||
- Auth failure for a concrete workspace should still throw after clearing the token when `shouldThrowAuthError` says the whole request should fail.
|
||||
- Team filtering happens after fetch and can hide rows. The notice count should use the unfiltered fetched count so users understand the loaded window size.
|
||||
- External Linear changes between clicks can reorder first-N results because sorting is by `updatedAt`. This is acceptable, but the UI should replace the window rather than append blindly.
|
||||
- Issue mutations currently patch cached rows they can find; they do not invalidate every first-N list window or pull newly eligible issues into the list. Manual refresh remains the consistency boundary.
|
||||
- A lower-limit request already in flight can resolve after the user clicks Load more. It must not replace the larger visible window or clear the larger request's loading state.
|
||||
- SSH/remote runtime: all reads must continue through runtime RPC or preload; no local-only Electron calls should be added.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `src/main/linear/issues.test.ts`: `listIssues` returns `{ items, hasMore }`, requests `pageInfo`, propagates connection `hasNextPage`, and marks `hasMore` when multi-workspace merged rows are clipped.
|
||||
- Clamp tests for local IPC/runtime paths: plain list max is above 50 and aligned across local and remote; search remains capped at 50.
|
||||
- `src/renderer/src/store/slices/linear.test.ts`: list cache stores the envelope shape, serves higher-limit cache independently, keeps lower-limit rows visible during higher-limit fetch, preserves drawer lookup and optimistic patching for cached list envelopes, and forced refresh still blocks stale overwrites.
|
||||
- Store in-flight tests: list in-flight requests use the collection-result promise shape without changing search in-flight request behavior.
|
||||
- `TaskPage` request-state test if available: a slower lower-limit response does not replace a newer higher-limit response or clear its loading state.
|
||||
- `src/renderer/src/runtime/runtime-linear-client.test.ts`: local and remote `linear.listIssues` expect `LinearCollectionResult<LinearIssue>`.
|
||||
- Component test if available: `LinearCollectionNotice` renders button, disabled/loading state, and no-handler passive copy.
|
||||
- Electron validation: connected Linear All tab with Load more, pending state, post-load state, search mode without a button, and project/custom-view footer still using passive copy.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
- The notice stays a compact footer matching existing bordered, muted Linear collection notices.
|
||||
- The load-more action is visually secondary, keyboard reachable, and does not resize or overlap the issue list on narrow widths.
|
||||
- While loading more, existing rows remain visible and the button clearly shows progress/disabled state.
|
||||
- Copy is direct: users should understand Orca can fetch more without opening Linear.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Linear Issues All tab showing Load more available.
|
||||
2. Linear Issues All tab while a load-more request is pending.
|
||||
3. Linear Issues All tab after more rows are loaded.
|
||||
4. Linear Issues search results with no load-more button.
|
||||
5. Linear project or custom-view issues footer still showing the old passive message.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update shared/preload/runtime types for plain `listIssues` collection results.
|
||||
2. Add `pageInfo` to main Linear issue list queries and return `items` plus `hasMore`.
|
||||
3. Raise and align plain-list clamps in main, IPC, and runtime without changing search clamps.
|
||||
4. Update renderer store caches and tests for the new list result shape.
|
||||
5. Update `TaskPage` state/effects/rendering for first-N load more.
|
||||
6. Update `LinearCollectionNotice` to accept an optional load-more action.
|
||||
7. Run focused tests, `pnpm typecheck`, `pnpm lint`, and Electron screenshot validation.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Correctly keeps the feature to first-N load more for plain issue tabs. It must not imply cursor pagination, because there is no cursor state or merge logic in the current store.
|
||||
- Architecture/data flow: Main owns Linear `pageInfo` extraction and multi-workspace merge semantics; runtime/preload carry the envelope; the store owns envelope caching and stale-write guards; `TaskPage` owns visible window size and load-more UI.
|
||||
- Failure modes: Current auth/network handling differs between plain issue lists and project/view collections. Preserve that difference unless explicitly changing it, and test whichever behavior is chosen.
|
||||
- Concurrency: Include limit in request signatures, cache keys, landing probes, and force-refresh paths. A lower-limit response must not overwrite a newer higher-limit window.
|
||||
- Consistency: First-N refetches can reorder rows after external updates, and optimistic issue patches do not make all list windows complete. This is acceptable only if the UI replaces the full window and refresh remains available.
|
||||
- Performance/blast radius: No startup cost. Each click refetches first N per selected workspace, so multi-workspace All can multiply request cost. Keep a finite plain-list max and do not treat this as a free one-call operation.
|
||||
- UI quality bar: Compact secondary action, stable layout, no overlap/clipping, clear loading state, and preserved passive project/view copy.
|
||||
- Required screenshots: All tab available, pending, after load, search no-button, and project/view passive footer.
|
||||
@@ -1,126 +0,0 @@
|
||||
# Linear Scope Selector
|
||||
|
||||
## Problem
|
||||
|
||||
- `src/renderer/src/components/TaskPage.tsx:4368` renders workspace selection, "open in Linear", and team selection as separate controls even though they define one Linear issue-list scope.
|
||||
- `src/renderer/src/components/ui/team-multi-combobox.tsx:127` offers only "All teams" plus team rows. When a team is absent, there is no affordance that explains whether the key is team-limited, the user lacks private-team access, the team is archived/retired, or the backend failed to fetch every page.
|
||||
- Settings and onboarding both say a workspace is connected, but the connect copy only points to personal API-key settings. It does not explain full-access keys, team-limited keys, member API-key restrictions, or private-team access.
|
||||
- The backend already supports the core scope model: `workspaceId === 'all'` fans out across stored Linear clients, and team list calls are routed through the same runtime APIs used for local and SSH sessions. The gaps are renderer UX plus stale/incomplete data handling.
|
||||
|
||||
## Goal
|
||||
|
||||
Make Linear task scoping feel like one selector:
|
||||
|
||||
1. Replace the separate workspace dropdown and team dropdown in the Tasks toolbar with one Linear scope popover.
|
||||
2. Preserve the existing state model: `selectedWorkspaceId` is one workspace id or `all`; `defaultLinearTeamSelection` remains a nullable global list where `null` means sticky-all and an array means an explicit subset of team IDs.
|
||||
3. Add an "Add team access" path from the selector. It opens a reusable API-key dialog. Pasting a key for an already connected workspace replaces that workspace token because the workspace id is stable.
|
||||
4. Make the copy accurate: one full-access personal API key for a Linear workspace can cover every team the key owner can access in that workspace; restricted/team-limited keys only expose permitted teams; private teams require the key owner to be a member or otherwise have access.
|
||||
5. Link to the most specific Linear settings URL Orca can build from `organizationUrlKey`, with global fallbacks when the workspace is unknown or `all`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change Linear authentication storage, encryption, IPC/RPC method names, or the `LinearWorkspaceSelection` type.
|
||||
- Do not switch Linear auth to OAuth.
|
||||
- Do not create, join, unarchive, or grant access to Linear teams from Orca. "Add team access" means paste a key whose owner/scope can already see the team.
|
||||
- Do not make team selection per-workspace in this change.
|
||||
- Do not touch provider-generic review code.
|
||||
|
||||
## Required Fixes Before UI
|
||||
|
||||
1. Fix team-list completeness.
|
||||
- `src/main/linear/teams.ts` currently calls `entry.client.teams()` once per workspace. The SDK query is paginated, so this can omit teams in larger workspaces.
|
||||
- Fetch all pages before the selector relies on team absence as a meaningful signal. Use `teams({ first })` plus `fetchNext()` until `pageInfo.hasNextPage` is false, or an equivalent raw GraphQL query. Keep the existing `MAX_CONCURRENT` limiter and do not set `includeArchived`; retired teams should stay absent.
|
||||
|
||||
2. Invalidate caches when a Linear key is connected or replaced.
|
||||
- Replacing a team-limited key with a full-access key must not leave the old `linearTeamCache` visible for up to `TEAM_CACHE_TTL`.
|
||||
- On successful `connectLinear`, synchronously clear Linear issue/search/team caches, in-flight issue/search/list/team requests, and metadata. Do this before publishing success, then await a forced status refresh before resolving so callers see the selected workspace and workspace list that came from the new key. The renderer `linearConnect` result is typed as `{ viewer }` only; do not rely on the main-process-only `workspace` field.
|
||||
- Guard `fetchLinearIssue()` and `listLinearTeams()` cache writes with a request generation or entry identity check. Clearing their in-flight maps is not enough today: those promises write unconditionally after resolution and can repopulate old issue/team data after key replacement. `searchLinearIssues()` and `listLinearIssues()` already use in-flight entry identity checks for forced refreshes; keep them covered by the same generation if connect/disconnect clears their maps.
|
||||
- Force a team refetch for the selected/connected workspace if the Linear Tasks view is open.
|
||||
- Issue list caches also need invalidation because changing key scope can add or remove visible issues.
|
||||
|
||||
3. Handle auth-driven token removal explicitly.
|
||||
- Backend calls can clear a workspace token on auth errors. In `all` workspace mode, `listTeams()`, `listIssues()`, and `searchIssues()` may return partial results instead of throwing.
|
||||
- After any auth-driven clear, the renderer must get a forced Linear status refresh or a typed signal so disconnected workspaces disappear without waiting for a later manual check. The current array-returning issue/search/team APIs have no signal for `all`-mode partial auth clears, so either add an auth-cleared result envelope end-to-end or force a metadata-only status refresh after `all`-workspace issue/search/team reads. Do not set `linearStatus` to globally disconnected from a single request error; refresh status because multi-workspace failures may only remove one workspace.
|
||||
- Strengthen `checkLinearConnection()` status equality. It currently compares workspace count, effective selected id, connected state, and viewer email. It misses active workspace id and workspace metadata changes when the count is unchanged. Compare a stable workspace signature including id, organization id/name/url key, display name, and email.
|
||||
- When a forced status refresh observes a changed selected workspace or workspace signature, clear issue/search/team caches, in-flight issue/search/list/team requests, and metadata. `checkLinearConnection()` currently only updates `linearStatus`, so removed workspaces can leave stale rows in `all` caches.
|
||||
|
||||
4. Be honest about team filtering.
|
||||
- Current Linear issue reads do not accept team IDs; team selection filters the already-limited issue/search result in the renderer after the `LINEAR_ITEM_LIMIT` page arrives. In `all` workspace mode, each workspace may fetch up to the limit, but the backend aggregates and trims back to `LINEAR_ITEM_LIMIT` before the renderer filters by team.
|
||||
- This UI change should not imply exhaustive server-side team scope. Empty task states must say no fetched/current issues match the selected teams. True server-side team scoping needs a separate change to add team IDs to cache keys, runtime RPC params, and Linear GraphQL filters.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add URL helpers in `src/shared/linear-links.ts`.
|
||||
- `buildLinearPersonalApiKeySettingsUrl(organizationUrlKey?: string | null)` returns `https://linear.app/<slug>/settings/account/security` when a slug exists, otherwise `https://linear.app/settings/account/security`.
|
||||
- `buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string | null)` returns `https://linear.app/<slug>/settings/api` when a slug exists, otherwise `https://linear.app/settings/api`.
|
||||
- Encode path segments consistently with `buildLinearTeamUrl`.
|
||||
|
||||
2. Add `LinearApiKeyDialog` in `src/renderer/src/components/linear-api-key-dialog.tsx`.
|
||||
- Props: `open`, `onOpenChange`, optional `workspace`, optional `title`, optional `description`, optional `connectLabel`, optional `onConnected`, optional `overlayClassName`/`contentClassName`.
|
||||
- Own input, connecting/error state, Enter-to-submit, and `connectLinear`. Fire `onConnected` only after `connectLinear` resolves with refreshed store status.
|
||||
- Use org-specific URLs only when a single workspace context is known. For `all`, use the global fallback and copy that tells the user to choose the intended Linear workspace.
|
||||
- Guidance:
|
||||
- Create a Personal API key from Account > Security & Access.
|
||||
- Prefer full access when Orca should show every team the account can access in that workspace.
|
||||
- If member API keys are blocked, ask a workspace admin to allow them from workspace API settings. Do not imply the workspace API page creates the personal key.
|
||||
- A key never grants teams the owner cannot access.
|
||||
- Make storage copy runtime-aware. Local runtime keys use the local OS keychain when Electron encryption is available; SSH/remote-runtime keys are stored by that runtime, not necessarily on this machine.
|
||||
|
||||
3. Replace the Tasks toolbar Linear controls with `LinearScopeSelector`.
|
||||
- Suggested file: `src/renderer/src/components/linear-scope-selector.tsx`.
|
||||
- Keep state updates in `TaskPage`: workspaces, selected workspace id, teams, selected team ids, callbacks for workspace select, team select, select-all teams, open selected team URL, and open API-key dialog.
|
||||
- Trigger label examples:
|
||||
- One workspace + sticky-all: `All teams`
|
||||
- One workspace + subset: `ENG, STA +1`
|
||||
- Multiple workspaces + `all` + sticky-all: `All workspaces`
|
||||
- Multiple workspaces + one workspace + sticky-all: `<Workspace name> / All teams`
|
||||
- Multiple workspaces + subset: `<Workspace name> / ENG, STA +1` or `All workspaces / ENG +1`
|
||||
- If the active scope is `all` and selected team keys are duplicated or span multiple workspaces, prefer `All workspaces / 2 teams` over an ambiguous key list.
|
||||
- Popover content:
|
||||
- Search filters team rows by team name, team key, and workspace name.
|
||||
- Workspace section appears only when more than one workspace is connected. It includes "All workspaces" and each workspace.
|
||||
- Selecting a workspace must call the existing `selectLinearWorkspace` flow, clear selected issue/list/error/loading state the same way `TaskPage` does now, and must not mutate `defaultLinearTeamSelection`.
|
||||
- Team section includes "All teams" and selectable team rows. "All teams" persists `defaultLinearTeamSelection: null`; explicit subsets persist an array. Normalize a selection equal to all available teams back to `null`, even if the user selected teams one by one. Never persist an empty array; keep the last team selected or save `null`.
|
||||
- Team rows show name, key, and workspace name when active workspace is `all` or when multiple workspaces exist.
|
||||
- Footer has "Add team access" with a key/link icon. Close the popover before opening the API-key dialog.
|
||||
- Keep the external-link button as an icon-only sibling. It remains an action, and should stay enabled only when exactly one selected team has a URL.
|
||||
|
||||
4. Keep team selection reconciliation, but document its limits.
|
||||
- `reconcileLinearTeamSelection()` preserves sticky-all and drops stale team IDs.
|
||||
- Because the saved team subset is global, switching workspaces may temporarily select all teams in the new workspace while preserving the old saved subset for later. Do not introduce per-workspace persistence here.
|
||||
|
||||
5. Update Settings and onboarding to use `LinearApiKeyDialog`.
|
||||
- Rename "Add workspace" to "Add Linear access" when disconnected and "Update access" or "Add workspace access" when connected.
|
||||
- Replace "Each workspace uses its own locally stored API key" with copy that says each connected Linear workspace has one key stored by the active runtime; a full-access key can cover all visible teams in that workspace, and a restricted key can be replaced any time.
|
||||
- Keep existing per-workspace Test and Disconnect controls.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- No Linear workspaces connected: no Tasks selector; Settings/onboarding still show Connect.
|
||||
- One workspace connected: hide the workspace section, but keep "Add team access" visible.
|
||||
- Multiple workspaces + `all`: team keys/names can repeat across workspaces; always show workspace names in rows.
|
||||
- Empty team result: show an empty state plus footer action. Do not claim the key is the only possible cause; absence can also come from private-team membership, archived teams, permissions, or a fetch failure.
|
||||
- Empty issue result after team selection: say no fetched/current issues match the selected teams. Do not imply Linear was queried exhaustively for those teams.
|
||||
- Legacy or missing `organizationUrlKey`: use global Linear settings URLs and keep the paste flow usable.
|
||||
- Replacement key for an existing workspace: select that workspace, invalidate caches, refresh status, and refetch teams immediately.
|
||||
- Auth errors: a backend call may clear a token. The selector must tolerate stale rows during the refresh, then drop disconnected workspaces when status updates.
|
||||
- Multi-window/external mutations: there is no real-time renderer broadcast. Force `checkLinearConnection(true)` after connect/disconnect/test flows and when opening the selector if stale status would be visible. Forced refresh must still update state and invalidate caches when workspace metadata changed but the workspace count did not. A same-workspace key replacement with identical viewer/workspace metadata is invisible to status today; do not claim that case is handled unless this change adds a non-secret credential revision or a renderer broadcast.
|
||||
- Remote runtime/SSH: continue routing through `connectLinear`, `selectLinearWorkspace`, `listLinearTeams`, and runtime RPC. Do not call local Electron APIs directly except for opening Linear URLs.
|
||||
- Accessibility: trigger is a combobox-style button, rows are keyboard-selectable, icon buttons have labels/tooltips, and the dialog traps focus.
|
||||
|
||||
## Tests
|
||||
|
||||
- `src/shared/linear-links.test.ts`: URL helpers, fallback behavior, and path encoding.
|
||||
- `src/main/linear/teams.test.ts`: paginated team fetching and `all` workspace aggregation.
|
||||
- `src/renderer/src/store/slices/linear.test.ts`: successful connect invalidates issue/search/team caches, drops stale in-flight writes, awaits refreshed status, and does not mark all Linear disconnected for one workspace auth failure.
|
||||
- `src/renderer/src/store/slices/linear.test.ts`: forced status refresh detects changed workspace ids/metadata with the same workspace count and invalidates Linear caches when the scope signature changes.
|
||||
- Component-independent label and selection-persistence helper tests for `LinearScopeSelector`, including selecting every visible team persisting sticky-all (`null`) rather than a frozen full-team array.
|
||||
- Avoid brittle popover DOM tests unless matching an existing selector test pattern.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Fix backend/store correctness: team pagination, connect invalidation, and auth-clear status refresh.
|
||||
2. Add Linear settings URL helpers and tests.
|
||||
3. Add `LinearApiKeyDialog`; migrate Settings, onboarding, and the existing Tasks connect dialog.
|
||||
4. Add `LinearScopeSelector` and replace only the Linear toolbar selector block in `TaskPage`.
|
||||
5. Run focused Linear tests, then `pnpm typecheck` and `pnpm lint`.
|
||||
@@ -1,217 +0,0 @@
|
||||
# Native Chat Codex TUI Parity
|
||||
|
||||
This note maps Codex TUI behavior to Orca native chat on branch
|
||||
`inspect/pr-5824-native-chat`. It is intentionally concrete: the current Orca
|
||||
surface is a PTY harness around the running TUI, while real native parity should
|
||||
move selected paths to Codex app-server protocol v2.
|
||||
|
||||
## Source Map
|
||||
|
||||
- Codex TUI composer: `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/chat_composer.rs`
|
||||
- Slash command parsing and popup:
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/prompt_args.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/slash_commands.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/slash_command.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/chatwidget/slash_dispatch.rs`
|
||||
- Skills and mentions:
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/skill_popup.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/skills_helpers.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/loader.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/root_loader.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/injection.rs`
|
||||
- Structured input and native protocol:
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/protocol/src/user_input.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/v2/turn.rs`,
|
||||
`/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
|
||||
|
||||
## Current Orca Architecture
|
||||
|
||||
Orca native chat currently sends through the hosted terminal PTY. The composer
|
||||
builds paste bytes, writes them through `sendRuntimePtyInput`, then sends a
|
||||
delayed Enter. This preserves local and SSH behavior because it uses the same
|
||||
runtime path as terminal typing.
|
||||
|
||||
That architecture is useful for incremental adoption, but it means native chat
|
||||
does not own Codex state. It cannot directly set model, reasoning, permissions,
|
||||
skills, or session lifecycle. It can only type commands into the TUI and observe
|
||||
agent hooks/transcripts after the fact.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- The parser accepts a first-line command of `/name <rest>`.
|
||||
- The slash popup uses Codex's `SlashCommand` enum order as presentation order.
|
||||
- Enter on a selected popup row dispatches the command. Tab completes it into
|
||||
the draft.
|
||||
- Some commands accept inline args: `review`, `rename`, `plan`, `goal`, `ide`,
|
||||
`keymap`, `mcp`, `raw`, `usage`, `pets`, `side`, `resume`, and
|
||||
`sandbox-add-read-dir`.
|
||||
- Commands are control actions, not ordinary user chat turns. For example
|
||||
`/clear` sends `AppEvent::ClearUi`; `/compact` starts compaction; `/model`
|
||||
opens the model picker; `/skills` opens skill management.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Slash commands are still typed into the TUI over PTY.
|
||||
- Native optimistic chat bubbles are suppressed for slash drafts so `/clear`
|
||||
does not render as a fake queued user message.
|
||||
- The Codex slash catalog now mirrors the visible TUI command list much more
|
||||
closely, but it is still a copied catalog, not a live TUI query.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Short term: keep PTY dispatch for slash commands, but treat them as command
|
||||
submissions. No optimistic chat bubbles. Enter dispatches; Tab completes.
|
||||
- Medium term: route commands with app-server equivalents directly. Examples:
|
||||
`thread/compact/start`, `thread/list`, `thread/archive`, `thread/delete`,
|
||||
`model/list`, permissions/config reads and writes, `skills/list`.
|
||||
- Long term: stop maintaining a renderer-side Codex command catalog. Either ask
|
||||
Codex for the command inventory or host the Codex composer state machine.
|
||||
|
||||
## Skills And `$`
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- `$` opens the skill popup. Rows show display name, description, category tags,
|
||||
selection state, filtering, sorting, and scrolling.
|
||||
- Codex discovers skills from repo, user, system, admin, and plugin roots. Repo
|
||||
scope sorts before user/system/admin. Exact duplicate paths are deduped.
|
||||
- Skill selection is structured. `UserInput` has `Skill { name, path }`, and
|
||||
app-server protocol v2 mirrors it. Text `$skill` mentions are only the
|
||||
fallback path and must be unambiguous.
|
||||
- Skill injection reads the selected `SKILL.md` by path, records telemetry, and
|
||||
avoids double-injecting already provided host skill prompts.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat discovers skills through Orca's skills IPC with the active
|
||||
terminal tab's cwd. This is important for worktree symlinks like
|
||||
`.agents/skills`.
|
||||
- `$` autocomplete inserts plain `$skillName` text. That can work through the
|
||||
TUI's text fallback, but it is not equivalent to structured
|
||||
`UserInput::Skill { name, path }`.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- PTY mode: keep `$skill` text insertion, but preserve Codex-like filtering,
|
||||
scrolling, dedupe, and active-cwd discovery.
|
||||
- Native mode: retain the selected skill's path and submit
|
||||
`UserInput::Skill { name, path }` through app-server `turn` input. This avoids
|
||||
ambiguity when multiple skills share a name and lets Codex inject the exact
|
||||
file the user selected.
|
||||
|
||||
## Files, Mentions, And Images
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- User input supports `Text` with text elements, `Image`, `LocalImage`,
|
||||
`Skill`, and `Mention`.
|
||||
- The TUI has file search/mentions and image placeholders. Large pastes become
|
||||
placeholders so text element ranges stay aligned.
|
||||
- Remote image rows are first-class composer attachments and can be removed with
|
||||
keyboard navigation.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- File attach inserts a path/reference into the draft and relies on the TUI to
|
||||
interpret it.
|
||||
- Image paste saves a temp file, then inserts the agent-specific reference.
|
||||
- Local attachments are blocked for remote sessions because the local path may
|
||||
not exist on the SSH target.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- PTY mode: keep conservative path insertion and remote-session blocking.
|
||||
- Native mode: send structured `LocalImage` or `Image` input through Codex
|
||||
protocol and use remote runtime file transfer semantics for SSH.
|
||||
|
||||
## Model, Reasoning, Permissions
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- `/model`, `/permissions`, `/keymap`, `/vim`, `/experimental`, and related
|
||||
commands are stateful TUI/app-server surfaces.
|
||||
- App-server v2 already exposes model listing, config requirements, approval
|
||||
policies, permission profiles, and reasoning effort fields.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat does not know or set Codex model/reasoning directly. Typing
|
||||
`/model` opens Codex's TUI picker.
|
||||
- Earlier UI controls for model/thinking were removed because they were not
|
||||
wired to real Codex state.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Do not re-add model or reasoning dropdowns until they read from and write to
|
||||
Codex app-server state.
|
||||
- In PTY mode, expose `/model` as a command shortcut only.
|
||||
|
||||
## Approvals, Elicitations, And Tool UI
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- Approval overlays cover exec approval, permission approval, file change
|
||||
approval, network approval, MCP elicitation, and request-user-input forms.
|
||||
- App-server notifications include thread status, waiting-on-approval/user-input
|
||||
flags, item start/completion, diff/plan updates, and skill changes.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat has interactive cards sourced from Orca's existing agent status
|
||||
hooks. This is good for common question/approval flows, but it is not the full
|
||||
Codex approval overlay model.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Keep PTY fallback for anything not represented in Orca hooks.
|
||||
- For Codex-native mode, subscribe to app-server notifications and render
|
||||
approvals/tool calls from protocol events rather than scraping terminal text.
|
||||
|
||||
## Session And History
|
||||
|
||||
Codex behavior:
|
||||
|
||||
- `/new`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, and `/clear`
|
||||
are session lifecycle commands.
|
||||
- The composer has local and persistent history; Up/Down recall, Ctrl+R reverse
|
||||
search, Esc edit/interrupt behavior, Ctrl+J newline, Ctrl+T transcript, and
|
||||
Ctrl+C quit/interrupt behavior.
|
||||
|
||||
Current Orca behavior:
|
||||
|
||||
- Native chat has small in-memory draft history and Enter/Shift+Enter.
|
||||
- Session commands are typed into the hosted TUI.
|
||||
|
||||
Recommended route:
|
||||
|
||||
- Short term: keep TUI command dispatch and avoid fake optimistic bubbles for
|
||||
lifecycle commands.
|
||||
- Native mode: use app-server thread APIs for lifecycle and expose real thread
|
||||
transitions in the Orca UI.
|
||||
|
||||
## Priority
|
||||
|
||||
1. Fix PTY-command correctness: Enter dispatches slash commands, Tab completes,
|
||||
slash commands never render as queued chat turns, interrupt clears working UI.
|
||||
2. Make `$` skill popup match Codex basics: active cwd, dedupe, scrolling,
|
||||
filtering, source labels, and no product-specific hardcoding.
|
||||
3. Keep fake model/thinking controls out until backed by Codex app-server state.
|
||||
4. Add an app-server integration spike for Codex native mode: `skills/list`,
|
||||
structured `UserInput::Skill`, model list/settings, and thread lifecycle.
|
||||
5. Move approvals/tool rendering from hook approximations to protocol events.
|
||||
|
||||
## Test Targets
|
||||
|
||||
- `/clear` from native slash popup dispatches immediately and produces no
|
||||
pending user bubble.
|
||||
- `/compact`, `/model`, `/skills`, `/resume`, `/diff`, `/status`, and unknown
|
||||
slash commands behave like the hosted TUI.
|
||||
- `$ref-oss` appears exactly once when the worktree has `.agents/skills` as a
|
||||
symlink.
|
||||
- Down-arrow in `$` suggestions scrolls the popup window.
|
||||
- Interrupt during work returns the composer from Stop to Send after the agent
|
||||
status settles.
|
||||
- SSH sessions never insert local-only attachment paths as if they were remote
|
||||
files.
|
||||
@@ -1,79 +0,0 @@
|
||||
# New Worktree Sidebar Reveal
|
||||
|
||||
## Problem
|
||||
|
||||
Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options.
|
||||
- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`).
|
||||
- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`.
|
||||
|
||||
Result: off-screen targets animate by default, including freshly created worktrees.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`activateAndRevealWorktree` conflates two intents:
|
||||
|
||||
1. activate existing worktree navigation (smooth reveal is fine);
|
||||
2. activate a just-created worktree (must jump immediately).
|
||||
|
||||
Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`.
|
||||
|
||||
## Scope and Non-goals
|
||||
|
||||
- Add an opt-in reveal behavior at activation call sites.
|
||||
- Apply `'auto'` only where the worktree is newly created/added in that flow.
|
||||
- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in.
|
||||
- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth).
|
||||
- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling.
|
||||
|
||||
## Design
|
||||
|
||||
1. Extend `activateAndRevealWorktree` options:
|
||||
- `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`.
|
||||
2. In `activateAndRevealWorktree`, call:
|
||||
- `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided;
|
||||
- otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged.
|
||||
3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows:
|
||||
- `useComposerState` full-create path;
|
||||
- `useComposerState` quick-create path;
|
||||
- `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree;
|
||||
- `launch-work-item-direct`;
|
||||
- folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path).
|
||||
4. Keep existing navigation activations smooth, including:
|
||||
- `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created);
|
||||
- all existing `activateAndRevealWorktree(...)` callers that do not opt in.
|
||||
5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`.
|
||||
|
||||
## Correctness Notes and Edge Cases
|
||||
|
||||
- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded.
|
||||
- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending.
|
||||
- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution.
|
||||
- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior.
|
||||
- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued).
|
||||
- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations.
|
||||
- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally.
|
||||
- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators.
|
||||
|
||||
## Tests
|
||||
|
||||
Add/adjust focused tests in `worktree-activation` coverage:
|
||||
|
||||
- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`;
|
||||
- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth).
|
||||
|
||||
Add call-site regression tests (recommended, small):
|
||||
|
||||
- one composer create path passes `'auto'`;
|
||||
- one non-created navigation path stays default (no behavior option).
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`.
|
||||
2. Update created-worktree callers to pass `'auto'`.
|
||||
3. Add tests above.
|
||||
4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`.
|
||||
5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
||||
# Orchestration Reset Scope Validation
|
||||
|
||||
## Problem
|
||||
|
||||
`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024).
|
||||
|
||||
## Root Cause
|
||||
|
||||
`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Redesign orchestration storage or reset semantics.
|
||||
- Add confirmation prompts to the RPC protocol.
|
||||
- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior.
|
||||
- Change unrelated orchestration commands or provider-specific behavior.
|
||||
|
||||
## Design
|
||||
|
||||
1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`.
|
||||
2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state.
|
||||
3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls.
|
||||
4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method.
|
||||
5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain.
|
||||
6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`.
|
||||
- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects.
|
||||
- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `{}` rejects and leaves messages and tasks unchanged.
|
||||
- `{ all: false }` rejects and leaves messages and tasks unchanged.
|
||||
- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged.
|
||||
- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged.
|
||||
- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes.
|
||||
- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present.
|
||||
- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work.
|
||||
- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state.
|
||||
- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions.
|
||||
- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior.
|
||||
- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly.
|
||||
- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI.
|
||||
- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files.
|
||||
- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
Not UI-visible.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Tighten `ResetParams` validation.
|
||||
2. Simplify the reset handler to trust validated single-scope params.
|
||||
3. Make CLI no-flag reset pass `all: true` explicitly.
|
||||
4. Add RPC regression tests for invalid params preserving state.
|
||||
5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough.
|
||||
6. Run targeted tests, typecheck, and lint.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes.
|
||||
- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`.
|
||||
- Failure modes covered:
|
||||
- Empty reset params cannot mutate state.
|
||||
- Contradictory scopes cannot partially apply the first truthy branch.
|
||||
- Failed validation happens before any database reset method runs.
|
||||
- CLI shorthand cannot rely on a dangerous RPC fallback.
|
||||
- CLI multi-flag input fails through the same RPC validation as any other caller.
|
||||
- Test coverage required:
|
||||
- `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state.
|
||||
- `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing.
|
||||
- `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented.
|
||||
- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked.
|
||||
- UI quality bar: Not UI-visible.
|
||||
- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior.
|
||||
- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller.
|
||||
- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change.
|
||||
@@ -1,93 +0,0 @@
|
||||
# Persist Source Control Tree View Choice
|
||||
|
||||
## Problem or Goal
|
||||
|
||||
The Source Control sidebar lets the user toggle changes between list and tree views, but the choice is session-local. After remount or app restart, it falls back to list view. Persist this as a per-user setting, not per-workspace state, so a user's preferred source-control layout follows them across repos and worktrees.
|
||||
|
||||
## Current Behavior
|
||||
|
||||
- `SourceControlViewMode` is a local union in `src/renderer/src/components/right-sidebar/SourceControl.tsx:127`.
|
||||
- `SourceControlInner` already reads global settings from the Zustand store at `src/renderer/src/components/right-sidebar/SourceControl.tsx:329`.
|
||||
- The source-control view mode is initialized with component-local React state at `src/renderer/src/components/right-sidebar/SourceControl.tsx:486`, hard-coded to `'list'`.
|
||||
- The tree/list toggle only calls `setSourceControlViewMode` at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2454` and `src/renderer/src/components/right-sidebar/SourceControl.tsx:2457`.
|
||||
- The chosen mode controls uncommitted entries at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2859` and branch comparison entries at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2967`.
|
||||
- Settings are typed in `GlobalSettings` at `src/shared/types.ts:1271`, defaulted by `getDefaultSettings()` at `src/shared/constants.ts:154`, loaded with default merging at `src/main/persistence.ts:1217`, exposed through `settings:get` / `settings:set` at `src/main/ipc/settings.ts:29`, and updated in the renderer settings slice at `src/renderer/src/store/slices/settings.ts:236`.
|
||||
- `PersistedUIState` exists at `src/shared/types.ts:1704`, but the request specifically asks for the user's setting rather than workspace-specific UI state.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Add a new global user setting:
|
||||
|
||||
```ts
|
||||
sourceControlViewMode: 'list' | 'tree'
|
||||
```
|
||||
|
||||
Implementation details:
|
||||
|
||||
- Add a shared `SourceControlViewMode = 'list' | 'tree'` type and the field to `GlobalSettings` in `src/shared/types.ts`.
|
||||
- Add the default value to `getDefaultSettings()` in `src/shared/constants.ts`; use `'list'` to preserve existing behavior for new and upgraded users.
|
||||
- Reuse the existing persistence path. `src/main/persistence.ts` already merges `defaults.settings` with `parsed.settings`, so older profiles automatically hydrate the new field without a bespoke migration.
|
||||
- In `SourceControl.tsx`, stop using local `useState('list')` as the durable source of truth. Read the persisted value through a small guard such as `normalizeSourceControlViewMode(settings?.sourceControlViewMode)`, returning `'list'` for missing or invalid values.
|
||||
- Keep a narrowly scoped optimistic mode in `SourceControlInner`: `optimisticSourceControlViewMode: SourceControlViewMode | null`. The rendered mode is `optimisticSourceControlViewMode ?? normalizedSettingsMode`.
|
||||
- Select `updateSettings` from the store and update the toggle handler to compute the next value from the current rendered mode, set the optimistic mode immediately, and persist the next value through `updateSettings({ sourceControlViewMode: next })`.
|
||||
- Track a monotonically increasing write sequence with a ref. Only the latest in-flight write may clear or revert optimistic state, so out-of-order `settings:set` responses cannot make an older click win over the user's latest intent.
|
||||
- When `settings.sourceControlViewMode` changes from outside this component, clear the optimistic value if there is no newer in-flight write. This lets the authoritative settings snapshot take back over after hydration, Settings import, or another renderer path updates settings.
|
||||
- Keep tree expansion/collapse state (`collapsedTreeDirs`) local and session-only. Directory expansion is path/worktree-content-specific, while the requested setting is only the global list/tree layout preference.
|
||||
- Do not add a Settings pane control unless product wants one later. The existing toolbar icon remains the natural place where the user makes the choice; persisting that click is enough for this request.
|
||||
|
||||
The optimistic state is local UI state only; it is not a second persistence channel. It exists because `updateSettings` crosses async renderer/main IPC, and the toolbar should still behave as a normal toggle under slow writes or rapid clicks.
|
||||
|
||||
### Interaction and Data Flow
|
||||
|
||||
```text
|
||||
Toolbar click
|
||||
-> derive next mode from current rendered mode
|
||||
-> optimistic SourceControlInner state updates immediately
|
||||
-> updateSettings({ sourceControlViewMode: next })
|
||||
-> settings:set persists user settings in main
|
||||
-> renderer store receives authoritative GlobalSettings
|
||||
-> SourceControlInner clears optimistic state when the latest write settles
|
||||
```
|
||||
|
||||
- Happy path: the toolbar flips immediately, `settings:set` returns the saved settings object, and the optimistic value is cleared once the authoritative mode matches the latest requested mode.
|
||||
- Missing setting: defaults merge in `'list'`; upgraded profiles do not need a migration.
|
||||
- Invalid persisted value: the renderer guard treats it as `'list'` for display and for the next toggle write, which self-heals persistence on the next user action.
|
||||
- Write failure: keep the previous authoritative settings object, clear only the latest optimistic value, and let the UI fall back to the last saved mode. Existing `updateSettings` logs the failure; the component does not need a new error surface for this preference toggle.
|
||||
- Out-of-order writes: if write 1 sets `tree` and write 2 sets `list`, write 1 resolving after write 2 must not clear or overwrite the optimistic `list` intent in the component.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Existing profiles with no `sourceControlViewMode` should behave exactly as before: list view.
|
||||
- Settings may be `null` before hydration; render should not crash. Disable the toggle until settings hydrate so the fallback `'list'` value is never persisted over an existing saved `'tree'` preference.
|
||||
- If the body renders before settings hydrate, use the guarded list fallback only as a temporary display value. Do not seed optimistic state until the real settings snapshot is available.
|
||||
- Corrupt or unknown persisted values should normalize to `'list'` at the component boundary. A broader settings migration is unnecessary for this narrowly scoped string preference.
|
||||
- A settings write failure should not corrupt local or persisted state. Existing `updateSettings` logs errors and leaves the previous settings object intact.
|
||||
- Rapid toggles should be last-intent-wins from the user's perspective, even if individual `settings:set` IPC responses resolve out of order.
|
||||
- The choice must apply globally across active worktree switches, repo switches, and local/SSH runtime targets.
|
||||
- Directory collapse state should not persist globally because tree node keys are derived from file paths and sections; persisting them would leak one repo's shape into another.
|
||||
- Source control can render both local and remote/SSH worktrees. This setting is renderer/user preference only and must not depend on filesystem paths, runtime target IDs, or workspace IDs.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: add a focused test for `getDefaultSettings()` in `src/shared/constants.test.ts` asserting `sourceControlViewMode` defaults to `'list'`.
|
||||
- Unit: add or extend a renderer test around `SourceControl` to verify clicking the tree/list toolbar calls `updateSettings({ sourceControlViewMode: 'tree' })` from the default list state, and then `updateSettings({ sourceControlViewMode: 'list' })` when currently tree.
|
||||
- Unit: cover the optimistic write sequence with delayed mocked `updateSettings` promises. A rapid `list -> tree -> list` interaction should leave the rendered mode and latest requested update at `list`, even if the earlier `tree` write resolves last.
|
||||
- Unit: cover `settings === null` hydration behavior: the toggle is disabled before hydration, then reflects a hydrated `'tree'` preference without persisting the fallback `'list'`.
|
||||
- Unit: cover the normalization helper for invalid values, missing values, and both valid modes.
|
||||
- Unit: if direct `SourceControl` rendering setup is too heavy, extract tiny pure helpers such as `getNextSourceControlViewMode(mode)` and `normalizeSourceControlViewMode(value)` near the component and test those helpers plus a shallow mocked component interaction.
|
||||
- Integration-light: `src/renderer/src/store/slices/settings.test.ts` already verifies rebasing local settings to the authoritative `settings:set` response. No new store behavior is required unless implementation changes `updateSettings`.
|
||||
- Manual/Electron: open Source Control, switch to tree view, switch worktrees and confirm the view remains tree, restart the app and confirm Source Control still opens in tree view, then switch back to list and confirm restart preserves list.
|
||||
|
||||
Playwright coverage is optional for this change. The behavior crosses app restart and local user-data persistence, which is better covered by Electron validation unless there is already a reliable e2e fixture for persistent settings across relaunch.
|
||||
|
||||
## Rollout Order
|
||||
|
||||
1. Add the `GlobalSettings` field and default.
|
||||
2. Wire `SourceControl` to read a normalized `settings.sourceControlViewMode` with a safe list fallback before hydration.
|
||||
3. Add optimistic last-intent-wins toggle handling and persist toolbar toggles through `updateSettings`.
|
||||
4. Add focused unit coverage for the default, normalization, hydration, and async toggle writes.
|
||||
5. Run `pnpm typecheck`, `pnpm lint`, targeted tests, then manual Electron validation.
|
||||
|
||||
## Ref-OSS
|
||||
|
||||
Not used. The change follows Orca's existing per-user settings pipeline and does not need external editor behavior to resolve the design.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,279 +0,0 @@
|
||||
# Daemon lifecycle retirement for issue #9138
|
||||
|
||||
## Status
|
||||
|
||||
Implemented and locally validated for PR #9277.
|
||||
|
||||
The earlier full ownership/audit prototype is preserved at:
|
||||
|
||||
- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot`
|
||||
- commit: `7c915909bd26670b8af36aa683cff85077395dd1`
|
||||
- GitHub: <https://github.com/stablyai/orca/tree/Jinwoo-H/issue-9138-full-ownership-audit-snapshot>
|
||||
|
||||
That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup
|
||||
audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future
|
||||
legacy enforcement. None of those systems ship in this PR.
|
||||
|
||||
Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was
|
||||
in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation
|
||||
alongside v22 and older versions.
|
||||
|
||||
## Inputs and scope decision
|
||||
|
||||
Issue: <https://github.com/stablyai/orca/issues/9138>
|
||||
|
||||
Reviewed design comment by AmethystLiang:
|
||||
<https://github.com/stablyai/orca/issues/9138#issuecomment-5006601124>
|
||||
|
||||
The reviewed comment correctly identifies two different problems:
|
||||
|
||||
1. current-generation daemons have no daemon-owned empty lifecycle;
|
||||
2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act.
|
||||
|
||||
This PR solves only the first problem. The second problem is much larger, adds steady-state
|
||||
persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement.
|
||||
Keeping it out makes the user-visible fix small enough to review and benchmark independently.
|
||||
|
||||
The narrow implementation retains these important rules from AmethystLiang's design:
|
||||
|
||||
- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it;
|
||||
- live sessions always win over cleanup;
|
||||
- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence;
|
||||
- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation;
|
||||
- the daemon, not app startup, makes the atomic empty decision;
|
||||
- pre-v24 daemons stay reattachable and are not automatically shut down;
|
||||
- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged.
|
||||
|
||||
The narrow implementation changes one policy from the original comment: it does not use a blanket
|
||||
30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle
|
||||
event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime
|
||||
inactivity heuristics and periodic ownership work.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
```text
|
||||
Before
|
||||
|
||||
app disconnects ──> daemon stays forever
|
||||
├── live sessions stay (wanted)
|
||||
└── zero sessions also stay (leak)
|
||||
|
||||
After
|
||||
|
||||
clean app detach ──> daemon atomically checks itself
|
||||
├── any live session/work/client ──> stay alive
|
||||
└── exactly empty ──> exit immediately
|
||||
|
||||
unexpected drop ──> daemon atomically checks itself
|
||||
├── live session/work/connection ──> stay alive
|
||||
└── exactly empty ──> exit immediately
|
||||
|
||||
v23 and older ──> existing reattach behavior; no automatic retirement
|
||||
```
|
||||
|
||||
An end user with live terminals should notice no change. An end user who quits with no daemon-backed
|
||||
terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket,
|
||||
live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later
|
||||
app restart launches a fresh daemon instead of reusing an empty process.
|
||||
|
||||
## Protocol and lifecycle design
|
||||
|
||||
### Endpoint identity
|
||||
|
||||
Protocol v24 hello responses include:
|
||||
|
||||
```ts
|
||||
type DaemonEndpointIdentity = {
|
||||
pid: number
|
||||
startedAtMs: number
|
||||
launchNonce: string
|
||||
}
|
||||
```
|
||||
|
||||
The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes
|
||||
the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client
|
||||
sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps
|
||||
the previous identity-free handshake.
|
||||
|
||||
PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a
|
||||
write failure terminates the new child and fails launch instead of leaving an untracked daemon.
|
||||
|
||||
### Clean detach
|
||||
|
||||
At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a
|
||||
v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared
|
||||
250 ms budget. v23 and older adapters skip it.
|
||||
|
||||
Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is
|
||||
opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still
|
||||
receive clean retirement on quit. If startup fallback has already won, the late daemon is not
|
||||
installed; it instead receives the same bounded retirement attempt, which an adopted live session
|
||||
will reject.
|
||||
|
||||
The daemon accepts retirement only when, in one event-loop turn:
|
||||
|
||||
- the requesting authenticated client has both control and stream sockets;
|
||||
- it is the only authenticated client;
|
||||
- every accepted transport belongs to that client;
|
||||
- no `createOrAttach` operation is in flight;
|
||||
- the terminal host has zero sessions.
|
||||
|
||||
When all conditions hold, the daemon synchronously closes the listening server before replying. That
|
||||
is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then
|
||||
runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to
|
||||
the same event-driven empty check when the authenticated sockets close.
|
||||
|
||||
### Initial adoption watchdog
|
||||
|
||||
A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated
|
||||
client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the
|
||||
normal launch handoff before the parent could connect; without a bound, a parent crash during that
|
||||
handoff would orphan the new daemon forever.
|
||||
|
||||
A complete pair permanently cancels this watchdog, and terminal admission requires that complete
|
||||
pair. Raw and partial transports pause it without extending its original deadline. It is never
|
||||
rearmed after adoption and is not a terminal inactivity or crash-reconnect timer.
|
||||
|
||||
### Unexpected disconnect
|
||||
|
||||
When the last client that completed both authenticated sockets loses its control connection, the
|
||||
daemon records an event-driven retirement request with no wall-clock grace.
|
||||
|
||||
- A complete authenticated reconnect cancels the request if existing work or a transport kept the
|
||||
daemon alive long enough to reconnect.
|
||||
- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request
|
||||
before admission can begin.
|
||||
- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but
|
||||
cannot erase evidence that the last fully connected app left.
|
||||
- Replacing a client ID first records the old full connection's loss; completing the replacement
|
||||
stream cancels that evidence, while an incomplete replacement only blocks retirement.
|
||||
- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately
|
||||
rechecks every guard and retires only if it is then exactly empty.
|
||||
|
||||
The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the
|
||||
token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is
|
||||
not broadened into destructive or respawn authority.
|
||||
|
||||
### Artifact cleanup
|
||||
|
||||
The daemon removes only artifacts it can claim as its own:
|
||||
|
||||
- token contents must match the daemon's in-memory token;
|
||||
- PID and launch nonce must match the daemon process and launch nonce;
|
||||
- cleanup first renames the canonical entry to a unique claim, validates that claim, and never
|
||||
overwrites or unlinks a replacement installed at the canonical path.
|
||||
|
||||
Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24
|
||||
PID/socket artifacts. Legacy cleanup behavior is unchanged.
|
||||
|
||||
## Performance design
|
||||
|
||||
There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or
|
||||
steady-state persistence write in this PR.
|
||||
|
||||
The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one
|
||||
two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its
|
||||
connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection
|
||||
attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect
|
||||
bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one
|
||||
unref'ed startup watchdog, which is canceled permanently on adoption.
|
||||
|
||||
Validation compares current main and the branch for:
|
||||
|
||||
- daemon connect plus two-socket hello latency;
|
||||
- repeated `listSessions` RPC latency;
|
||||
- terminal echo/stream throughput through a real socket daemon;
|
||||
- clean empty-detach latency;
|
||||
- event-loop delay under repeated RPC/stream work;
|
||||
- idle CPU/RSS and timer count where observable.
|
||||
|
||||
The acceptance target is no statistically meaningful terminal throughput regression and no new
|
||||
steady-state disk writes. Results are recorded in the PR body.
|
||||
|
||||
### Local performance regression screen
|
||||
|
||||
The final local host was not quiet enough for publication-grade absolute numbers: load averages were
|
||||
17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired
|
||||
same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24
|
||||
branch samples; medians across sample medians were:
|
||||
|
||||
| Measure | `main` v23 | branch v24 |
|
||||
| -------------------- | ---------: | ---------: |
|
||||
| two-socket connect | 1.38 ms | 1.32 ms |
|
||||
| `listSessions` RPC | 0.0366 ms | 0.0374 ms |
|
||||
| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s |
|
||||
|
||||
All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and
|
||||
event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression
|
||||
screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work
|
||||
runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY
|
||||
byte and no steady-state persistence or polling.
|
||||
|
||||
## Verification and validation
|
||||
|
||||
### Focused unit and integration tests
|
||||
|
||||
- v24 requires valid matching endpoint identity on both sockets.
|
||||
- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols.
|
||||
- production launch passes PID path and nonce and writes the exact readiness identity.
|
||||
- incomplete readiness identity or failed exclusive PID publication kills and rejects the child.
|
||||
- clean empty detach exits immediately.
|
||||
- a never-used current adapter connects and retires cleanly on quit.
|
||||
- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old
|
||||
deadline.
|
||||
- a live session, another client, raw transport, or in-flight admission rejects clean retirement.
|
||||
- a control-only overlapping client blocks but cannot erase the last full-client retirement request.
|
||||
- a same-client-ID control replacement cannot erase the prior full connection's retirement request.
|
||||
- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed
|
||||
request.
|
||||
- startup fail-open performs bounded empty retirement without installing a late provider.
|
||||
- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later.
|
||||
- the synchronous listener fence rejects post-fence connections as retryable.
|
||||
- an unexpected empty disconnect retires immediately without a runtime inactivity timer.
|
||||
- a real reconnect cancels pending retirement while live work keeps the daemon available.
|
||||
- raw and health probes block but cannot erase pending retirement.
|
||||
- final session exit triggers an immediate guarded retirement check.
|
||||
- token/PID cleanup preserves malformed, stale, and replacement artifacts.
|
||||
- authenticated token disappearance performs one coalesced respawn; initial token absence does not.
|
||||
|
||||
### Process/E2E tests
|
||||
|
||||
- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts;
|
||||
- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts
|
||||
exit;
|
||||
- prove a live session rejects retirement and remains reattachable;
|
||||
- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable;
|
||||
- never target a production runtime directory or signal a process not created by the fixture.
|
||||
|
||||
### Repository validation
|
||||
|
||||
- Node typecheck;
|
||||
- oxlint and repository max-lines policy;
|
||||
- focused daemon, adapter, launcher, restart, and legacy-routing suites;
|
||||
- full daemon test suite;
|
||||
- desktop and web production builds;
|
||||
- `git diff --check` and review of every changed file against `origin/main`;
|
||||
- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`;
|
||||
- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication.
|
||||
|
||||
Final local results on macOS arm64 after the event-driven policy revision:
|
||||
|
||||
- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped;
|
||||
- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned
|
||||
artifacts retired immediately after its final authenticated client disconnected;
|
||||
- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed;
|
||||
- full desktop, web, and native production build passed with existing build warnings;
|
||||
- three independent post-revision review tracks covering architecture/state machines,
|
||||
ownership/adoption, and process lifecycle ended clean after actionable findings were fixed.
|
||||
|
||||
The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed
|
||||
successfully, and repository CI remains responsible for the supported Node/platform matrix.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves
|
||||
v24 as another legacy generation and does not give an older app authority to shut it down.
|
||||
|
||||
If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated
|
||||
ownership/audit prototype remains recoverable from the archived branch and commit above; it should
|
||||
return only as a separately reviewed, benchmarked follow-up.
|
||||
@@ -1,545 +0,0 @@
|
||||
# Windows ConPTY Startup Query and Focus Authority Design
|
||||
|
||||
Date: 2026-07-19
|
||||
|
||||
Status: Implemented with the 2026-07-20 ownership amendment below
|
||||
|
||||
## 2026-07-20 Ownership Amendment
|
||||
|
||||
Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for
|
||||
the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so
|
||||
renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder.
|
||||
|
||||
The PTY-owning process now classifies the backend from its own platform and the shell that actually
|
||||
won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11
|
||||
queries before model, replay, or view delivery. During the bounded startup window it replies once
|
||||
when validated theme colors are available; after the deadline, or without colors, it consumes the
|
||||
query without replying. A consuming-view handshake does not transfer this authority. Split query
|
||||
candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a
|
||||
candidate that proves malformed is released unchanged.
|
||||
|
||||
WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the
|
||||
startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime
|
||||
PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is
|
||||
selected only by the authoritative owner backend, never by renderer or connection metadata.
|
||||
|
||||
## Problem
|
||||
|
||||
On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user
|
||||
text:
|
||||
|
||||
```text
|
||||
]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\
|
||||
```
|
||||
|
||||
An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal
|
||||
focus-in report `CSI I`.
|
||||
|
||||
The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The
|
||||
agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only
|
||||
about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in
|
||||
addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent
|
||||
to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order
|
||||
records that cooked echo in the authoritative runtime model before any renderer-bound filtering.
|
||||
|
||||
The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits
|
||||
DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native
|
||||
PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was
|
||||
consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol;
|
||||
the failure requires an additional agent, timing, input-mode, or replay condition.
|
||||
|
||||
## Root Cause
|
||||
|
||||
OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY,
|
||||
WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners
|
||||
([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session`
|
||||
advances sequence state, writes its emulator, persists pending output, and fans out data before
|
||||
Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only
|
||||
filter can therefore misclassify paired runtimes and hide the symptom without removing it from the
|
||||
authoritative model, daemon history, snapshots, or remote delivery.
|
||||
|
||||
The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
|
||||
downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
|
||||
that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness
|
||||
without pre-approving a speculative focus workaround.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
node-pty / remote relay PTY
|
||||
-> shell-ready marker scan
|
||||
-> source-owned serialized ingress transaction
|
||||
-> consume an early OSC query and write one canonical reply
|
||||
-> match or losslessly release the measured native-Windows echo projection
|
||||
-> authoritative emulator + persistence/history
|
||||
-> runtime side effects and mobile/remote stream
|
||||
-> renderer delivery or hidden-drop decision
|
||||
-> live terminal / snapshot restore
|
||||
```
|
||||
|
||||
Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed
|
||||
spans advance sequence and flow-control state without writing bytes into an emulator or view.
|
||||
|
||||
## Rejected Prototype
|
||||
|
||||
The current working-tree prototype is not the implementation of this design:
|
||||
|
||||
- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus
|
||||
semantics for every Windows PTY, including programs unrelated to agents.
|
||||
- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a
|
||||
hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage.
|
||||
- Its echo state activates only after both color slots are answered. An echo of the first response
|
||||
can escape if it arrives before the second query.
|
||||
- Its partial-match buffer can be lost on timeout, and its substring search can remove a later
|
||||
legitimate string that happens to equal a reply.
|
||||
|
||||
Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked
|
||||
echo filtering. Existing unrelated Windows focus-idle safeguards remain.
|
||||
|
||||
## Existing Contracts Preserved
|
||||
|
||||
This design extends, rather than replaces:
|
||||
|
||||
- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query
|
||||
authority, authoritative model restore, and raw sequence ordering;
|
||||
- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus-
|
||||
hidden-dropped ownership and replay silence;
|
||||
- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY
|
||||
bytes once in main before renderer delivery.
|
||||
|
||||
The startup responder is a bounded exception needed before normal delivered/dropped ownership can be
|
||||
established. It must join the same ingestion decision, not operate as an unrelated renderer scrub.
|
||||
|
||||
## Decision 1: Source-Owned PTY Ingress Transaction
|
||||
|
||||
Sanitization must run on the host that owns the PTY, before that host mutates any authoritative
|
||||
model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced
|
||||
its sequence, written its emulator, recorded pending output, and broadcast the data before main's
|
||||
provider callback runs.
|
||||
|
||||
The transaction therefore has three installations of the same shared state machine:
|
||||
|
||||
- in `LocalPtyProvider`, before its configured runtime callback and data listeners;
|
||||
- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and
|
||||
attached-client fanout;
|
||||
- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's
|
||||
`SshPtyProvider` is only an RPC proxy and never sanitizes relay output.
|
||||
|
||||
Electron main receives already-classified daemon data. A remote-runtime desktop client does not
|
||||
reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual
|
||||
provider owner but never enable the native-Windows compatibility projection.
|
||||
|
||||
Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach`
|
||||
receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed
|
||||
color attributes. It first decides fresh versus reattach; only a fresh result constructs the
|
||||
transaction before releasing the subprocess's already-buffered early output. A reattach discards
|
||||
the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any
|
||||
PTY id can be reused.
|
||||
|
||||
Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn`
|
||||
carries the same fresh-session intent and installs it before releasing relay PTY output; relay
|
||||
`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is
|
||||
not armed and normal query authority handles the query.
|
||||
|
||||
Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold
|
||||
predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported
|
||||
legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a
|
||||
separate versioned spawn/data capability because daemon protocol support says nothing about relay
|
||||
support.
|
||||
|
||||
An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots
|
||||
or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive
|
||||
the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached
|
||||
legacy session remains explicitly outside it.
|
||||
|
||||
### Composition with shell-ready preprocessing
|
||||
|
||||
The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold
|
||||
transport bytes and removes its private ready marker; marker bytes have never belonged to terminal
|
||||
model or delivery sequence space and continue not to count. Its released non-marker bytes enter the
|
||||
new ingress transaction in original order.
|
||||
|
||||
Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its
|
||||
non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model,
|
||||
persistence, and views observe the resulting emissions. No later stage may introduce an unmetered
|
||||
string transform.
|
||||
|
||||
### Raw sequence and emission contract
|
||||
|
||||
The state machine accepts source chunks with an explicit raw half-open range and returns zero or
|
||||
more ordered emissions:
|
||||
|
||||
```ts
|
||||
type PtyIngressSourceChunk = {
|
||||
data: string
|
||||
rawStartSeq: number
|
||||
rawEndSeq: number
|
||||
}
|
||||
|
||||
type PtyIngressEmission = {
|
||||
data: string
|
||||
rawStartSeq: number
|
||||
rawEndSeq: number
|
||||
transformed: boolean
|
||||
}
|
||||
```
|
||||
|
||||
The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when
|
||||
`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without
|
||||
overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once
|
||||
from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the
|
||||
buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it.
|
||||
|
||||
The ingress raw high-water advances when a source chunk is accepted. Model-applied and
|
||||
view-delivered high-waters advance only through ordered emissions, including empty transformed
|
||||
emissions that consume a raw range. Runtime/mobile listener metadata carries
|
||||
`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span
|
||||
cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching
|
||||
the existing `rawLength !== data.length` safety rule.
|
||||
|
||||
This metadata is end-to-end, not local to the state machine. Implementation changes the complete
|
||||
path:
|
||||
|
||||
1. daemon `Session` or relay `PtyHandler` emission callback;
|
||||
2. daemon/relay batching, coalescing, splitting, and wire notification;
|
||||
3. daemon adapter or `SshPtyProvider` decode;
|
||||
4. `OrcaRuntimeService` model and runtime/mobile listeners;
|
||||
5. main renderer batching, pending/drop accounting, and preload payload;
|
||||
6. renderer reconciliation and remote-runtime binary/live frame decoding.
|
||||
|
||||
The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A
|
||||
span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing
|
||||
its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted
|
||||
only for contiguous spans and sums raw lengths independently of string lengths. A transformed
|
||||
emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush
|
||||
it as its own frame or request snapshot reconciliation instead of slicing it.
|
||||
|
||||
If a snapshot is requested while a partial candidate is held, the transaction first abandons that
|
||||
candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and
|
||||
snapshot sequence therefore describe the same raw high-water.
|
||||
|
||||
### Serialization and teardown
|
||||
|
||||
Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a
|
||||
nested callback, but that callback is appended after the current source chunk rather than delivered
|
||||
ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue.
|
||||
|
||||
On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model
|
||||
and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay
|
||||
disposal must flush both ingress prefixes and its existing pending output batches before clearing
|
||||
them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate
|
||||
state after teardown. Ordinary chunks pass through as one unchanged emission.
|
||||
|
||||
## Decision 2: Startup OSC Queries Are Consumed Authoritatively
|
||||
|
||||
The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup
|
||||
and the agent's short color-query timeout. Its implementation moves into the source-owned ingress
|
||||
transaction.
|
||||
|
||||
When registered for an agent spawn, it:
|
||||
|
||||
1. recognizes exact OSC 10/11 query grammar across provider chunks;
|
||||
2. builds replies from validated renderer-pushed foreground/background attributes;
|
||||
3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority,
|
||||
regardless of whether the query ended with BEL or ST;
|
||||
4. records each reply transaction before writing it to the provider;
|
||||
5. consumes the answered query from the authoritative model and view emissions so neither the
|
||||
hidden model nor a delivered renderer can answer it a second time;
|
||||
6. begins echo recognition as soon as each individual reply is written.
|
||||
|
||||
If attributes or a provider are unavailable, native ConPTY consumes the query without replying;
|
||||
WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers
|
||||
startup response state, matching current behavior, but native ConPTY ownership still prevents a
|
||||
downstream reply.
|
||||
|
||||
### Exact authority transfer
|
||||
|
||||
Startup query response authority opens only for a fresh session whose atomic creation installed the
|
||||
transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of:
|
||||
|
||||
- both OSC 10 and OSC 11 slots have been answered;
|
||||
- the startup deadline expires;
|
||||
- main sends an ordered authority-close control after either the consuming-view handshake or the
|
||||
hidden-runtime ownership mark is established;
|
||||
- the spawn fails, is cancelled, reattaches, or exits.
|
||||
|
||||
Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source
|
||||
replies, while complete queries remain consumed for the life of the PTY. Closing response authority
|
||||
does not discard already-written reply candidates. Echo recognition has
|
||||
its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
|
||||
provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
|
||||
a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control
|
||||
method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered
|
||||
boundary are either consumed at source or removed from emissions; queries after it pass unchanged
|
||||
to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority.
|
||||
|
||||
The regular authority rules continue after the bounded startup window:
|
||||
|
||||
- delivered live bytes are answered by the live view;
|
||||
- hidden-dropped live bytes are answered by the runtime model;
|
||||
- replayed, seeded, and snapshot bytes are answered by nobody;
|
||||
- the daemon's persistence emulator never writes replies.
|
||||
|
||||
Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to
|
||||
name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a
|
||||
real third responder class, not an undocumented exception.
|
||||
|
||||
## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model
|
||||
|
||||
Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a
|
||||
compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do
|
||||
not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and
|
||||
capability gate.
|
||||
|
||||
The harness records the exact projection ConPTY returns for the canonical ST reply, including its
|
||||
chunking and any console transformation. The implementation must not assume that the projection is
|
||||
always merely "remove ESC", and BEL or other reply forms are not added without separate evidence.
|
||||
For every written reply, the transaction records that exact expected projection. Recognition is:
|
||||
|
||||
- FIFO in reply-write order;
|
||||
- anchored at the next possible output position, not an unbounded substring search;
|
||||
- active immediately for each reply instead of waiting for both color slots;
|
||||
- bounded by the startup deadline and maximum reply length;
|
||||
- streaming across chunk boundaries.
|
||||
|
||||
If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged
|
||||
and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is
|
||||
buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A
|
||||
match advances the raw span but emits no bytes to the model, persistence, or views.
|
||||
|
||||
Projection matching cannot prove provenance. An application can print the same projected text at
|
||||
the candidate position, causing a false-positive removal while the later real echo remains visible.
|
||||
This is the central drawback of the workaround. It is accepted only if the real provider harness
|
||||
shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise
|
||||
Orca disables the projection and keeps the visible output rather than risking deletion.
|
||||
|
||||
When the projection is enabled, the exact-collision behavior is explicit: the first identical
|
||||
anchored candidate is removed and a later real echo is allowed through. The test fixture must assert
|
||||
that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance
|
||||
is knowable; the release gate must document the observed timing window and justify that tradeoff.
|
||||
Unknown, delayed, or interleaved transformations pass through visibly.
|
||||
|
||||
## Decision 4: Preserve ConPTY Focus Protocol
|
||||
|
||||
Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It
|
||||
must not remove, reorder, or fabricate transport bootstrap modes.
|
||||
|
||||
The `[I` investigation gets a deterministic harness before a behavior change. The harness must
|
||||
exercise the actual renderer focus callback and provider write path, not only inject `CSI I`
|
||||
directly into node-pty. It records:
|
||||
|
||||
- raw provider output and its order;
|
||||
- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004,
|
||||
or replayed snapshot state;
|
||||
- the exact bytes written to the provider;
|
||||
- the exact bytes returned by the provider and stored in the model;
|
||||
- agent lifecycle state when the report was emitted.
|
||||
|
||||
The implementation gate is strict:
|
||||
|
||||
- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode
|
||||
is not persisted as application ownership.
|
||||
- If a live agent receives transport focus before it owns terminal focus reporting, record that
|
||||
evidence without shipping a suppression heuristic from this document.
|
||||
- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize
|
||||
that transformation at the same pre-model ingress boundary used for OSC replies.
|
||||
|
||||
No global `?1004h` filter ships under any outcome.
|
||||
|
||||
If the harness proves a focus-ownership race, a follow-up design is required before implementation.
|
||||
That design must define the output-to-input ownership signal, distinguish xterm focus events from
|
||||
identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any
|
||||
snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document
|
||||
does not pre-approve an ownership state machine whose input provenance cannot yet be represented.
|
||||
|
||||
## Snapshot and Replay Rules
|
||||
|
||||
Interactive modes in a snapshot are capabilities of the live application, not proof that a new
|
||||
view should emit input immediately.
|
||||
|
||||
- Cold restore into a fresh shell keeps the existing full mode reset.
|
||||
- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only
|
||||
bootstrap ownership.
|
||||
- Snapshot serialization/replay must not mutate live ownership trackers.
|
||||
- A snapshot containing an OSC query or an old Orca reply never produces a provider write.
|
||||
- Model and renderer snapshots must both be free of matched cooked reply projections.
|
||||
|
||||
Any future focus ownership metadata must be explicit; it cannot be inferred from serialized
|
||||
`?1004h` text.
|
||||
|
||||
## Failure Policy
|
||||
|
||||
Safety is asymmetric:
|
||||
|
||||
- This design does not intentionally suppress a focus notification; evidence of a focus-ownership
|
||||
race triggers a follow-up design instead.
|
||||
- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the
|
||||
application.
|
||||
- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate
|
||||
replies are forbidden.
|
||||
- Losing buffered output on timeout or teardown is forbidden.
|
||||
|
||||
## Cross-Platform and Remote Scope
|
||||
|
||||
- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility
|
||||
path; focus investigation only until a proven root cause has its own complete design.
|
||||
- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround.
|
||||
- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the
|
||||
remote host protocol later supplies explicit equivalent evidence.
|
||||
- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there;
|
||||
desktop local main does not reinterpret its stream.
|
||||
- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query
|
||||
response authority through the existing terminal-driver election.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- A query or echo split at any byte boundary, including OSC ST split across chunks.
|
||||
- The first reply echo arriving before the second color query.
|
||||
- Unrelated output, an exact application-text collision, or a partial match before the real echo.
|
||||
- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held.
|
||||
- A provider write causing a synchronous nested output callback.
|
||||
- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing
|
||||
with output.
|
||||
- BEL-terminated queries still receiving the canonical ST reply.
|
||||
- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers.
|
||||
- Old daemon/relay protocol versions and sessions that survive an application upgrade.
|
||||
- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds.
|
||||
- Focus gained through normal terminal input versus the separate explicit reattach-focus write path.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Deterministic provider harness
|
||||
|
||||
- Capture the native ConPTY bootstrap across natural and forced chunk boundaries.
|
||||
- Reproduce the OSC reply echo with the real provider and an agent query fixture.
|
||||
- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11
|
||||
queries and combined OSC 10 `?;?`.
|
||||
- Force the first reply echo before the second query.
|
||||
- Split every byte boundary in query and echo fixtures.
|
||||
- Print the expected projection immediately before the real echo and assert that the first
|
||||
identical candidate is removed while the later echo passes through.
|
||||
- Exercise focus gain/loss through the actual xterm focus path.
|
||||
|
||||
### Ingress integration
|
||||
|
||||
- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive
|
||||
classified data before storing it.
|
||||
- Assert renderer and mobile delivery receive the same visible output.
|
||||
- Assert raw start/end spans advance by original provider length, producer ACK contribution is
|
||||
counted exactly once, and mobile/runtime metadata reports the raw span rather than string length.
|
||||
- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move,
|
||||
and teardown, including a prefix held from an earlier callback.
|
||||
- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the
|
||||
write.
|
||||
- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather
|
||||
than slicing cleaned text.
|
||||
- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam;
|
||||
assert `SshPtyProvider` does not reinterpret relay data.
|
||||
- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection.
|
||||
- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold
|
||||
restore; assert capable relay replay/history has the same property. Assert old daemon and relay
|
||||
versions are detected and never represented as sanitized.
|
||||
|
||||
### Authority and restore
|
||||
|
||||
- Assert an early-consumed query is answered once by its source owner and never by renderer or
|
||||
hidden model.
|
||||
- Assert normal delivered/dropped query authority resumes after startup state clears.
|
||||
- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do
|
||||
not contain cooked OSC text.
|
||||
- Assert replay never sends OSC or focus replies.
|
||||
- Assert ordinary native console focus behavior remains enabled.
|
||||
- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any
|
||||
later ownership implementation defines its tests in the required follow-up design.
|
||||
|
||||
### End-to-end acceptance
|
||||
|
||||
On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions.
|
||||
Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the
|
||||
focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops
|
||||
at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus
|
||||
behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain
|
||||
PowerShell terminal and a native focus-event consumer to prove no global regression.
|
||||
|
||||
Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint,
|
||||
formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also
|
||||
require the repository's SSH end-to-end procedure.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must
|
||||
look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a
|
||||
clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and
|
||||
focus presentation.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. A fresh native-Windows agent prompt after startup, with no OSC reply text.
|
||||
2. The same session after hide/reveal restore, still clean and without duplicated output.
|
||||
3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state.
|
||||
4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress
|
||||
state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready
|
||||
preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay
|
||||
`PtyHandler` before their models, persistence/replay, and fanout.
|
||||
2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates,
|
||||
carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query
|
||||
authority documents with exact transfer events.
|
||||
3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical
|
||||
ST replies, consume answered queries for model and views, and retain raw sequence accounting.
|
||||
4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO
|
||||
anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage,
|
||||
and authoritative daemon/model/snapshot tests.
|
||||
5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop
|
||||
for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed
|
||||
only with a failing regression test that selects that branch.
|
||||
6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL,
|
||||
and SSH scenarios before removing the old startup implementation.
|
||||
|
||||
Each slice must keep query authority singular. The compatibility projection does not ship without a
|
||||
model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness
|
||||
fails on the old behavior and passes on the new behavior.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global
|
||||
focus filtering and an unproven ownership state machine remain out of scope.
|
||||
- Architecture/data flow: classification belongs at each PTY source owner after shell-ready
|
||||
preprocessing and before every authoritative model, persistence, replay, or delivery consumer.
|
||||
- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes,
|
||||
authority races, snapshot and teardown drains, protocol-version skew, reattach, and host
|
||||
isolation.
|
||||
- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay,
|
||||
runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and
|
||||
renderer-focus harnesses; Electron and SSH end-to-end validation.
|
||||
- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one
|
||||
startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery
|
||||
path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory.
|
||||
- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage
|
||||
disappears, without cursor/focus regressions or restore flashes.
|
||||
- Required review screenshots: the four terminal states in `Review Screenshots`.
|
||||
- Residual risks: the native projection may be too unstable to enable; exact projected application
|
||||
output can collide; `[I` may require a separately reviewed focus-ownership design.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing xterm's general query parser.
|
||||
- Filtering arbitrary escape-looking terminal output.
|
||||
- Disabling ConPTY focus or Win32 input mode globally.
|
||||
- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows.
|
||||
- Solving unrelated MCP startup warnings reported beside the terminal garbage.
|
||||
|
||||
## Final Invariants
|
||||
|
||||
1. Provider bytes are classified once before model ingestion and view delivery.
|
||||
2. A live query has exactly one responder; replay has none.
|
||||
3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched
|
||||
startup-reply projection.
|
||||
4. Raw provider sequence accounting survives sanitization.
|
||||
5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data.
|
||||
6. ConPTY bootstrap modes reach live emulators unchanged.
|
||||
7. No focus suppression ships from this design; a proven ownership race requires a follow-up design.
|
||||
8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state.
|
||||
@@ -1,620 +0,0 @@
|
||||
# Windows daemon-generation safety investigation (#9749)
|
||||
|
||||
Investigation and completion snapshot: 2026-07-21 16:59 PDT
|
||||
(2026-07-21 23:59 UTC)
|
||||
|
||||
Branch baseline: `OrcaWin/issue-9749-windows-daemon-generation-safety` at
|
||||
`937a2015e`, 40 commits after `v1.4.148-rc.1`. This document is the hard gate
|
||||
before a reproduction harness or production change. GitHub access during the
|
||||
investigation was read-only, and no installed Orca daemon, pipe, token, process,
|
||||
or terminal session was contacted or changed.
|
||||
|
||||
## Findings
|
||||
|
||||
The reported incident is a composition of three lifecycle paths, not one:
|
||||
|
||||
1. **Survival and adoption:** normal app quit deliberately disconnects from
|
||||
daemon clients without shutting down their PTYs. Protocol-specific named
|
||||
pipes let multiple generations coexist, and legacy adapters intentionally
|
||||
reconnect to them so live PTYs remain warm and reattachable across upgrades.
|
||||
2. **Broad current-generation replacement:**
|
||||
`cleanupDaemonForProtocol` sends the only production
|
||||
`shutdown { killSessions: true }` request found in the repository. Its
|
||||
production callers replace or explicitly restart the current generation;
|
||||
legacy discovery does not call it. A failed `listSessions` is currently
|
||||
converted to an empty list before this broad shutdown, which makes its
|
||||
reported kill count untrustworthy but does not suppress the shutdown.
|
||||
3. **Reconnect-triggered per-session destruction:** #8871 provides the strongest
|
||||
causal evidence. A reconnecting renderer restores stale remote handles,
|
||||
synthesizes `pty-exit`, drops that reason at `session.tabs.close`, and the
|
||||
authoritative host interprets the close as user intent. Its PTY router then
|
||||
forwards individual `kill` requests to whichever current or legacy adapter
|
||||
claimed each reusable session ID during discovery.
|
||||
|
||||
`client-hello` is necessary to discover and route legacy sessions, but neither
|
||||
the hello handler nor legacy discovery sends a kill or shutdown request. The
|
||||
~3.3-second renderer-bootstrap-to-kill evidence in #8871, repeated renderer
|
||||
spawn/burst correlations, and the untyped close path are substantially stronger
|
||||
than #9749's inference that greeting an old pipe causes that daemon to kill its
|
||||
table autonomously.
|
||||
|
||||
The immediate safety boundary is therefore destructive request authority and
|
||||
provenance. Cross-profile, sleeping-session, and generation-retirement policy
|
||||
remains the larger #9138/#9229 problem.
|
||||
|
||||
## Deterministic native-Windows reproduction
|
||||
|
||||
Reproduction snapshot: 2026-07-21 14:36 PDT (2026-07-21 21:36 UTC).
|
||||
|
||||
`tests/e2e/daemon-generation-reconnect-safety.spec.ts` now constructs an
|
||||
isolated `orca-9749-dg-*` runtime under the Windows temporary directory. It
|
||||
starts three real daemon-server processes on versioned v21/v22/v23 named
|
||||
pipes, with a live canary and a stale-mirror canary in each generation. Each
|
||||
canary records the PTY-root and descendant PID/start identity independently.
|
||||
The fixture refuses cleanup outside its exact temporary root and terminates
|
||||
only recorded fixture process incarnations; it never enumerates or connects to
|
||||
installed Orca endpoints.
|
||||
|
||||
The Electron-as-Node reconnect client performs three production
|
||||
`DaemonPtyAdapter`/`DaemonPtyRouter.discoverLegacySessions` bursts, reattaches
|
||||
every original PID, pings every canary, and opens a simultaneous second client
|
||||
to each generation. It then sends duplicate `session.tabs.close` RPCs carrying
|
||||
`reason: 'pty-exit'` through the real schema, `OrcaRuntimeService`, and routed
|
||||
daemon adapters while the reconnect-client process remains alive.
|
||||
|
||||
The current-main run failed at the intended external invariant:
|
||||
|
||||
- before the close, all three daemons, six PTY roots, and six descendants were
|
||||
alive;
|
||||
- each daemon accepted eight new control/stream hellos across the reconnect and
|
||||
parallel-client cycles;
|
||||
- v21, v22, and v23 each logged two `session-killed` requests for the same
|
||||
stale-mirror session ID within 1–4 ms;
|
||||
- all three stale-mirror PTY roots and descendants exited, while all three
|
||||
unrelated live canaries, all daemon processes, and reconnect client PID 26876
|
||||
remained alive;
|
||||
- the final assertion requires those stale-mirror roots to remain alive, so it
|
||||
is RED before the fix and will become GREEN only when lifecycle-originated
|
||||
closes are adjudicated non-destructively at the host.
|
||||
|
||||
Focused command:
|
||||
|
||||
```text
|
||||
pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1
|
||||
```
|
||||
|
||||
The captured per-generation timestamp/PID/hello/kill/liveness report is written
|
||||
to the Playwright test output as `daemon-generation-reconnect-events.json`.
|
||||
|
||||
## Release and protocol chronology
|
||||
|
||||
| Release or branch | Date | Daemon protocol | Relevant lifecycle behavior |
|
||||
| --------------------- | ---------- | --------------: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `v1.4.141` | 2026-07-14 | 21 | Legacy adoption already intentional; #8871 observed here. |
|
||||
| `v1.4.142` | 2026-07-15 | 22 | New versioned endpoint; v21 can remain reattachable. |
|
||||
| `v1.4.143` | 2026-07-16 | 22 | #9138 macOS accumulation reported. |
|
||||
| `v1.4.144` | 2026-07-17 | 22 | #9195 Windows survival reported. |
|
||||
| `v1.4.145` | 2026-07-18 | 22 | No generation-retirement change. |
|
||||
| `v1.4.146` | 2026-07-19 | 23 | #9749's old current generation; already contains #8661's continue-shutdown-on-dispose-failure behavior. |
|
||||
| `v1.4.147` | 2026-07-20 | 24 | First stable release containing #9277: authenticated identity and atomic empty-daemon retirement. |
|
||||
| `v1.4.148` | 2026-07-21 | 24 | Reporter current release; legacy v23 and older remain intentionally adoptable. |
|
||||
| `main` at `937a2015e` | 2026-07-21 | 25 | Protocol bumped by #9651 (`cc44acaaa`) for PTY startup ingress; v24 is now legacy. No stable tag contains this commit yet. |
|
||||
|
||||
`PREVIOUS_DAEMON_PROTOCOL_VERSIONS` is cumulative rather than one-version-only.
|
||||
At the investigated `main`, it contains versions 1 through 24.
|
||||
|
||||
## Investigation matrix
|
||||
|
||||
Confidence is **proven** when the issue evidence and source/diff establish the
|
||||
mechanism, **supported** when multiple observations fit a reachable source path,
|
||||
and **suspected** where attribution is missing.
|
||||
|
||||
| Item | State; dates; releases/protocols | Exact symptom | Mechanism and relevant code/commits | Shipped status; unresolved #9749 relevance; scope boundary |
|
||||
| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [#9749](https://github.com/stablyai/orca/issues/9749) | Open; 2026-07-21; Windows 11; daemon-host 1.4.146→1.4.148; old v21/v22/v23 and current v24 observed | Three surviving daemons were greeted at `16:41:32.277Z`–`.286Z`; later bursts logged 55/30/65/60/60 v22 kills and 10/32 v23 kills, including repeated IDs, while 191,266 main-process spans showed no coincident app death. One v23 broad `shutdown reason:rpc killSessions:true` and repeated `shutdown-dispose-failed` were present. | **Supported composition, reporter causality not proven.** Versioned legacy adoption explains hellos; `cleanupDaemonForProtocol` explains broad shutdown; #8871's stale-mirror close echo best explains reconnect-time individual kills. `daemon-server.ts` logs `session-killed` before awaiting `host.kill`, so repeated records prove repeated requests, not repeated physical death. | No fix shipped for the destructive initiator. #9277 mitigates empty v24+ accumulation only. Skipping all legacy hello or killing legacy generations on sight would destroy the promised warm-reattach behavior and is unsafe scope creep. |
|
||||
| [#9138](https://github.com/stablyai/orca/issues/9138) | Open; 2026-07-17; macOS 1.4.143/v22; observed v18/v20/v21/v22 | UI showed 8 sessions while about 46 Claude processes remained; removing stale trees removed about 370 processes and reduced swap from 25 GB to under 5 GB. | **Proven accumulation, ownership policy deliberately conservative.** Normal disconnect, versioned endpoints, incomplete cross-profile ownership, sleeping/cold restore, and legacy wildcard claims prevent safe blind retirement. Prototype commit `7c915909b` implements an audit/journal/incarnation design but is unmerged. | No complete fix shipped. #9277 safely handles only empty current-generation v24+ daemons. The broader ownership journal, grace period, all-profile evidence, and enforcement rollout belong here, not the immediate #9749 close-authority patch. |
|
||||
| [#9211](https://github.com/stablyai/orca/issues/9211) | Closed duplicate 2026-07-17; v21/v22 | A v21 daemon remained four days beside v22, and each app launch greeted both. | **Proven intentional legacy discovery with missing retirement.** `createLegacyDaemonAdapters` plus `discoverLegacySessions`. | Consolidated into #9138; no separate shipped fix. Same accumulation input as #9749, but not its destructive initiator. |
|
||||
| [#9229](https://github.com/stablyai/orca/issues/9229) | Open P1; 2026-07-17; Linux headless | Failed shutdown left a 15.55 GiB/81-process old runtime beside a 1.21 GiB/4-process replacement. | **Supported reconciliation design.** Requires provenance, one destructive authority, exact daemon/session PID+start incarnations, complete evidence, and fail-closed missing reads. | Unshipped. Supplies invariants for later reconciliation. Implementing its full profile migration and journal in #9749 would be unsafe scope expansion. |
|
||||
| [#9195](https://github.com/stablyai/orca/issues/9195) | Open; 2026-07-17; Windows 1.4.144/v22; comments through #9277 release read | `orca-terminal-daemon.exe` remains after app exit; later comments observed v21/v22/v23 and invisible sessions. | **Proven intentional for non-empty daemons.** Normal quit calls `disconnectDaemon`, not `shutdownDaemon`; the child is detached/unref'd. Empty accumulation was an unhandled gap before #9277. | #9277 shipped in 1.4.147 for empty v24+ only. Treating every survivor as a bug would regress warm reattachment. |
|
||||
| [PR #9277](https://github.com/stablyai/orca/pull/9277) | Merged 2026-07-20 as `7adda25b0`; first stable `v1.4.147`; v23→v24 | Empty current-generation daemons accumulated after clean app disconnect or failed adoption. | **Proven by diff/tests.** Hello returns PID/start-time/launch-nonce identity; control/stream identity must match. `shutdownIfIdle` atomically checks one complete client, no unknown transport, no sessions, and no admission in flight. Shutdown fences admission before disposal. A two-minute watchdog covers only never-adopted startup. | Shipped in `v1.4.147-rc.4`, `v1.4.147`, `v1.4.148-rc.1`, and `v1.4.148`. It explicitly does not perform startup reaping, retire legacy daemons, infer ownership, or kill reattachable PTYs. Strong identity/capability foundation; partial mitigation only. |
|
||||
| [#8871](https://github.com/stablyai/orca/issues/8871) | Open P0; 2026-07-15; first observed 1.4.141/v21; all corrections/comments read | First reconnect kill followed renderer bootstrap by 3.348 s. Three later fresh renderer processes correlated within about 1 s with bursts killing 4, 7, and 7 worktrees while app and daemon PIDs remained stable. | **Supported-to-proven request path.** Persisted remote handle → subscribe returns `no_connected_pty` → synthetic `pty-exit` → `closeTerminalTab(reason:'pty-exit')` → reason dropped from `session.tabs.close` → `closeMobileSessionTab` kills host PTY. Early pre-connect kills were correctly downgraded to unattributed. Close propagation intersects landed #8628 and later #8958. | Not fixed on main. This is the strongest immediate #9749 foundation. Trigger repair alone is insufficient; the authoritative host must adjudicate intent. |
|
||||
| [PR #8872](https://github.com/stablyai/orca/pull/8872) | Open; 2026-07-15; head `bb69fac775`; rebased 2026-07-21 | Prevents mirror `pty-exit`/cleanup evidence from killing a host PTY while retaining real user close and dead-tab retirement. | **Actual diff/tests inspected.** Threads typed `user`/`pty-exit`/`cleanup`; host refuses non-user closes if any parent leaf has a connected PTY, republishes an unchanged snapshot with a guarded replay marker, and handles dead-leaf/live-sibling loops. Adds an optional daemon `kill intent:'auto'` guard, but intentionally sends no auto intent until protocol/capability negotiation exists. | Unshipped. The earlier Windows/WSL live matrix passed on an older head and was explicitly invalidated by the rebase, so native validation must be rerun. This is the best narrow safety foundation, but reasonless old clients remain destructive on a new host. |
|
||||
| [PR #8888](https://github.com/stablyai/orca/pull/8888) | Open; 2026-07-15; head `b13235211`; reviews/follow-ups read | Ambiguous paired-runtime close RPCs could destroy host tabs with no requester attribution. | **Actual diff/tests inspected.** Default-denies intent-less paired-runtime `session.tabs.close`, `terminal.close`, and `terminal.closeTab`; validates user source/target, dedupes request IDs per device across reconnects, rate-limits, restricts create rollback to its connection, and traces device/connection/source/decision without bearer tokens. Adds local daemon-control client ID to `session-killed`. | Unshipped. Useful defense-in-depth and logging foundation. Its “old paired client gets successful no-op” behavior is safely conservative but needs explicit compatibility/product acceptance; taking the entire policy is broader than the smallest #9749 fix. |
|
||||
| [#9414](https://github.com/stablyai/orca/issues/9414) | Open tracking issue; 2026-07-19 | Recognition-dependent terminal lifecycle actions can destroy or strand arbitrary processes. | **Proven design boundary.** Host owns adjudication; stale client transport state and agent-name recognition are not kill authority. Tracks #8872/#8888 and lifecycle-state work. | Unshipped tracker. Aligns with #9749 invariants; generic agent recognition work is separate scope. |
|
||||
| [PR #8628](https://github.com/stablyai/orca/pull/8628) | Merged 2026-07-14 as `36cd8a334`; releases after that date | Tab close did not durably retire all associated terminal/session state. | **Actual merge diff inspected.** Centralized terminal retirement/ownership and ensured remote-owned closes reach the authoritative host. The remote call still carried no close reason, so lifecycle and user closes were indistinguishable. | Shipped and important context, not a regression to revert wholesale. #9749 must preserve its durable explicit-user close while adding authority. |
|
||||
| [#8878](https://github.com/stablyai/orca/issues/8878) / [PR #9098](https://github.com/stablyai/orca/pull/9098) | Issue and PR open; 2026-07-15/17; PR head `9f3abd778` | A reconnecting paired client resumes a provider session still live on the host, producing duplicate TUIs. | **Proven companion feedback loop.** Client-local resume runs before authoritative mirror arrival; a #8871 kill also leaves a resume record. PR gates resume for runtime-owned worktrees while preserving the record. | Unshipped. Relevant reconnect stress case, but provider-session resume dedupe is not needed to stop #9749 kills. |
|
||||
| [#9352](https://github.com/stablyai/orca/issues/9352) / [#9585](https://github.com/stablyai/orca/issues/9585) | Open; 2026-07-18/20; remote macOS and Windows | Closed or killed remote tabs return as `ptyId:null` phantoms; host snapshots keep dead terminal surfaces, and repeated host restarts accumulate them. | **Proven stale mirror/snapshot state.** Host `touchMobileSessionSnapshotsForPty` republishes the same tab; viewer mirrors every terminal surface. Remote transport also leaks the explicit-kill error. | Unshipped. Explains repeated stale close inputs and must be in stress coverage, but pruning dead surfaces is a separate lifecycle fix requiring sleeping-session care. |
|
||||
| [#9217](https://github.com/stablyai/orca/issues/9217) / [#8970](https://github.com/stablyai/orca/issues/8970) | #9217 closed duplicate 2026-07-19; #8970 open; v1.4.143 | Agent/sidebar rows remain after local close or SSH relay connection loss. | **Proven UI/status lifecycle gaps.** SSH teardown intentionally preserves PTY ownership for reattach but omitted status clearing; renderer lacks a sweep. | Separate non-destructive roster cleanup. Combining sidebar cleanup with #9749 would be scope creep. |
|
||||
| [#8851](https://github.com/stablyai/orca/issues/8851) / [PR #8825](https://github.com/stablyai/orca/pull/8825) | Issue closed; PR merged 2026-07-15 as `c8986ca52`; 1.4.143 notes | Finished named Claude children remained as idle sidebar rows. | **Proven Claude roster mechanism, not PTY lifecycle.** Working-only roster and hydration cleanup. | Shipped, but #8970 proves top-level/session residuals. Unrelated to destructive daemon authority. |
|
||||
| [#8275](https://github.com/stablyai/orca/issues/8275) / [#8276](https://github.com/stablyai/orca/issues/8276) | #8275 open `cannot_repro`; #8276 closed duplicate; 2026-07-11; v20→v22 attempts | Rapid worktree removal was followed by daemon death and unrelated split panes exiting `-1`; no shutdown record. | **Different mechanism.** Shared daemon process dies during PTY teardown; current v22 attempt did not reproduce. | Not the #9749 initiator: #9749 daemons stay alive and log explicit shutdown/kill requests. Keep daemon-death regression coverage, but do not merge root causes. |
|
||||
| [PR #8140](https://github.com/stablyai/orca/pull/8140) | Merged 2026-07-10 as `03a673708`/PR head `1710325e0`; fixes #8048 | Graceful then immediate Windows PTY teardown double-closed the same ConPTY handle and killed the shared daemon. | **Proven by actual native harness/diff.** `nodePtyKillIssued` makes later Windows force a no-op. Harness uses unique pipe/temp state, 25 victims, and a witness PTY/daemon survival assertion. | Shipped. Prevents one daemon-death path; does not authorize reconnect closes and does not retire descendants. Reuse its isolation/witness patterns only. |
|
||||
| [PR #8284](https://github.com/stablyai/orca/pull/8284) | Closed unmerged 2026-07-16; head `ebe0b6523` | Proposed serial worktree PTY admission and verified fail-closed teardown across Windows/POSIX/SSH. | **Actual 50-file diff/tests inspected.** Retained shutdown ownership, admission fences, physical-exit proof, and Windows relay ConPTY ownership. | Contrary to the issue prompt, it did **not** merge. Landed work was split/superseded by #8661 and #8706. Its teardown patterns are useful, but adopting its broad branch is unsafe. |
|
||||
| [PR #8661](https://github.com/stablyai/orca/pull/8661) | Merged before 1.4.146; key commit `a635ff9a7` | Disposal failure could prevent orderly runtime/daemon termination. | **Proven current source behavior.** Shutdown RPC catches `host.dispose`, logs `shutdown-dispose-failed`, then continues fencing, client destruction, and server close; resource disposal is retried. | Shipped before #9749. Corrects the report's inference that the failure necessarily leaves the endpoint authoritative forever. A native handle may still keep a process alive, but the server intends to close. |
|
||||
| [#9045](https://github.com/stablyai/orca/issues/9045) | Open; 2026-07-16; Windows | Worktree deletion fails because agent descendants retain filesystem handles after PTY teardown. | **Supported descendant ownership gap.** Root PTY exit is not full tree exit on Windows. | Unresolved. It concerns cleanup after a legitimate kill, not who may initiate it. |
|
||||
| [#9704](https://github.com/stablyai/orca/issues/9704) | Open; 2026-07-21; Windows 1.4.147/v24 | Killed PTY descendants survive (six trees/18 processes/~1.1 GB, later ~2 GB); runtime lists dead sessions as connected. | **Proven descendant leakage plus stale registry.** Explicitly asks for initiator attribution rather than assuming the kill was valid. | Unresolved. Different from #9749 because the root session was killed; #9704 concerns what survives afterward. |
|
||||
| [PR #9752](https://github.com/stablyai/orca/pull/9752) | Open; 2026-07-21; head `6be947317` | Windows agent descendants survive explicit or natural PTY-root exit. | **Actual native patch and tests inspected.** Suspends recognized native-Windows agent ConPTY roots, creates/configures/assigns a kill-on-close Job Object before resume, owns the handle atomically, and falls back to direct-root termination. Plain terminals, WSL, POSIX, SSH relay, and unrelated sessions are excluded; Windows process-table commands are removed. | Unshipped. Correct post-authorization cleanup, explicitly not an initiator fix. Combining it into the #9749 authority patch would obscure causality; compose/test separately if it lands first. |
|
||||
| [PR #9266](https://github.com/stablyai/orca/pull/9266) / [PR #9612](https://github.com/stablyai/orca/pull/9612) | Both open; 2026-07-18/20 | Alternative Windows descendant tree termination using process enumeration/taskkill-style sweeps. | **Competing cleanup approaches.** Carry PID-reuse, access-denied, cost, and partial-tree risks that native Job ownership avoids. | Unshipped. Do not duplicate inside #9749. |
|
||||
| [PR #8706](https://github.com/stablyai/orca/pull/8706) | Merged 2026-07-15 as `40d015992`/merge `6dbeeda3e`; stable thereafter | POSIX agent descendants survived root teardown. | **Proven POSIX snapshot-before-root-kill.** Windows intentionally returns no snapshot. | Shipped for macOS/Linux only. No #9749 authority effect. |
|
||||
| [#9193](https://github.com/stablyai/orca/issues/9193) / [PR #9288](https://github.com/stablyai/orca/pull/9288) | Both open; 2026-07-17/18 | `terminal close --tab` cannot address live floating/tabless PTYs; pane close can kill, and Windows may retain a stale entry. | **Proven addressing/registry gap.** PR routes tabless PTY through existing pane close. | Unshipped. Diagnostic and stale-registry relevance only; making more sessions closeable is not authority fencing. |
|
||||
| [#9563](https://github.com/stablyai/orca/issues/9563) / [PR #9634](https://github.com/stablyai/orca/pull/9634) | Both open; 2026-07-20/21; headless macOS | LaunchAgent host invokes update, disconnects clients, old binary respawns, and ShipIt reports “App Still Running.” | **Supported updater/headless ownership race.** PR defers installation while serving headlessly. | Unshipped. Must be a relaunch scenario in the harness, but updater policy is separate from close authority. |
|
||||
| [#8261](https://github.com/stablyai/orca/issues/8261) | Open; 2026-07-11; macOS/Linux headless comments | Silent update installation kills active PTYs; one headless log has `shutdown reason:rpc killSessions:true`. | **Supported broad shutdown during update.** | Unresolved adjacent caller/lifecycle context. Updater UX and install policy are scope creep; broad shutdown attribution is relevant. |
|
||||
| [#8459](https://github.com/stablyai/orca/issues/8459) | Open; 2026-07-13 | Resource Manager labels live daemon sessions orphan from renderer-only evidence and bulk-kills them. | **Proven ownership-safety precedent.** Absence from one renderer is not authority. | Unshipped. Same invariant as #9749, different UI initiator. Do not couple UI resource-manager redesign. |
|
||||
| [#8585](https://github.com/stablyai/orca/issues/8585) | Open; 2026-07-13; SSH relay | Failed relay `--connect` unlinks a socket while the old relay and PTYs remain alive. | **Different namespace/transport ownership leak.** | Unresolved. SSH regression consideration, not a native daemon-generation fix. |
|
||||
| [#7783](https://github.com/stablyai/orca/issues/7783) | Open; 2026-07-08; macOS | Helper survives app quit with roughly 189 descendants. | **Supported historical survival/descendant leak.** | Unresolved adjacent accumulation evidence; no reconnect-kill attribution. |
|
||||
| [#8457](https://github.com/stablyai/orca/issues/8457) | Open; 2026-07-13 | Headless serve and GUI relaunch ownership collide, interrupting or duplicating live agents. | **Supported multi-owner lifecycle conflict.** | Reinforces exactly-one reconciliation authority. Full headless lifecycle redesign is separate. |
|
||||
| [#8362](https://github.com/stablyai/orca/issues/8362) | Open; 2026-07-12; remote relay | PTY master FDs leak across relay children. | **Different mechanism: missing close-on-exec/inherited descriptors.** | Unresolved, but unrelated to named-pipe discovery or destructive requests. |
|
||||
| [#9569](https://github.com/stablyai/orca/issues/9569) / [PR #9587](https://github.com/stablyai/orca/pull/9587) | Issue closed 2026-07-20; PR open | Worktree removal dials a dead legacy v22 socket after v23 upgrade and fails ENOENT. | **Proven stale adapter routing.** PR tolerates dead legacy adapter teardown. | Unshipped. Demonstrates adapter lifecycle staleness; no authority fix. |
|
||||
| [#8689](https://github.com/stablyai/orca/issues/8689) / [PR #8697](https://github.com/stablyai/orca/pull/8697) | Closed/merged 2026-07-14; merge `840d3277d` | Daemon accepts a connection but never answers hello, wedging startup. | **Proven bounded handshake/replacement path.** | Shipped. Harness must bound hello and avoid reconnect storms; not a session-kill mechanism. |
|
||||
| [PR #7538](https://github.com/stablyai/orca/pull/7538) | Merged 2026-07-07 as `03cfc5bd1` | Windows update moved daemon code while live daemon/PTYs should survive. | **Proven historical compatibility intent.** Relocated host preserves same-protocol daemon across update. | Shipped. Later legacy adapters extended preservation across protocol bumps; blanket reaping would regress this contract. |
|
||||
| [PR #2974](https://github.com/stablyai/orca/pull/2974) | Merged 2026-05-28 as `5a852415a` | Resolver refresh risked killing live PTYs. | **Proven preserve-live policy.** Protocol bump/legacy routing is preferred to broad cleanup. | Shipped. Strong evidence against “kill old generation on sight.” |
|
||||
| [PR #7836](https://github.com/stablyai/orca/pull/7836) | Merged 2026-07-18 as `5f6728c1b` | Shutdown/provider selection race could clear a binding while the daemon PTY survived. | **Proven ownership race fix.** Retains provider/shutdown ownership until outcome. | Shipped. Preserve in regression tests; not close-intent adjudication. |
|
||||
| [PR #1343](https://github.com/stablyai/orca/pull/1343) | Merged 2026-05-03 as `df1fefcc2` | Users lacked session visibility; stale PID files risked PID-reuse mistakes. | **Proven management/PID-start-time guard.** | Shipped. Useful diagnostic/incarnation precedent, but observability alone cannot prevent #9749. |
|
||||
| [PR #9516](https://github.com/stablyai/orca/pull/9516) | Merged 2026-07-20 as `2e67af82d`/`de86f482c` | Windows worktree teardown RPCs could hang indefinitely. | **Proven bounded-deadline change.** | Shipped. Bounds cleanup but does not decide whether cleanup is authorized. |
|
||||
| [PR #8768](https://github.com/stablyai/orca/pull/8768) / [PR #8817](https://github.com/stablyai/orca/pull/8817) | Merged 2026-07-14/15 as `02de3c565` and `f7926c11f` | Restored/legacy PTYs could render blank or be unmounted while still live. | **Proven adoption compatibility.** Keep legacy daemon PTYs mounted and defer snapshots correctly. | Shipped. Direct reason that refusing all legacy discovery is unsafe. |
|
||||
| [#9441](https://github.com/stablyai/orca/issues/9441) / [PR #9446](https://github.com/stablyai/orca/pull/9446) | Open; 2026-07-19; macOS 1.4.146 | Large persisted profile drives high CPU/RSS and exits during startup restoration; clean user-data does not reproduce. | **Supported restore-load ordering issue.** PR defers full worktree scan. | Unshipped. Profile-switch/load stress case only; not destructive daemon authority. |
|
||||
|
||||
## Timestamped incident and call-flow reconstruction
|
||||
|
||||
### A. How generations survive
|
||||
|
||||
1. **2026-07-15 13:50 local:** #9749's v21 daemon starts.
|
||||
2. An app quit/update runs `disconnectDaemon`, whose adapter `disconnectOnly`
|
||||
closes client sockets and leaves live PTYs/history reattachable. The daemon is
|
||||
detached/unref'd, so parent death is not daemon death.
|
||||
3. **2026-07-18 14:26 local:** a v22 daemon starts on a different versioned
|
||||
endpoint while v21 retains its PTYs.
|
||||
4. **2026-07-20 14:50 local:** v23 starts while both prior endpoints remain.
|
||||
5. Windows endpoints are generated by
|
||||
`getDaemonSocketPath(runtimeDir, protocolVersion)` as
|
||||
`\\?\pipe\orca-terminal-host-v<protocol>-<sha256(runtimeDir)[0..12]>`.
|
||||
Tokens and PID records are likewise protocol-specific files. No endpoint
|
||||
collision forces an old generation out.
|
||||
6. Before protocol 24, a non-empty daemon had no generation-retirement
|
||||
protocol. Since #9277, an empty v24+ daemon can atomically self-retire, but a
|
||||
live session intentionally blocks it and v23-or-older behavior is unchanged.
|
||||
7. #9749's claim that `shutdown-dispose-failed` necessarily leaves the pipe open
|
||||
is not source-proven for 1.4.146. Commit `a635ff9a7` catches disposal failure
|
||||
and continues ordinary shutdown; #9277 additionally closes admission first.
|
||||
A stuck native handle may keep a process alive, but the endpoint is meant to
|
||||
stop being authoritative.
|
||||
|
||||
### B. Why startup greets every surviving generation
|
||||
|
||||
1. Current startup establishes the v25 (v24 in the reporter build) adapter and
|
||||
its complete control/stream lifecycle lease.
|
||||
2. `createLegacyDaemonAdapters(runtimeDir)` loops every value in
|
||||
`PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, derives that version's pipe/token/PID
|
||||
paths, and probes each endpoint.
|
||||
3. A responsive endpoint receives a `DaemonPtyAdapter` configured with that
|
||||
exact old protocol. It has no respawn callback, because new code must not
|
||||
recreate old environment semantics.
|
||||
4. `DaemonPtyRouter.discoverLegacySessions()` calls `adapter.listProcesses()`
|
||||
for each legacy adapter.
|
||||
5. `listProcesses()` calls `ensureConnected()`. `DaemonClient.doConnect()` opens
|
||||
a control socket, sends hello, then opens a stream socket and sends hello
|
||||
using one client UUID. This is the precise source of each control/stream pair
|
||||
in #9749 at `16:41:32.277Z` through `.286Z`.
|
||||
6. v24+ hellos return PID/start-time/launch-nonce and require both sockets to
|
||||
match. Old protocols return no identity, so successful token+protocol hello
|
||||
authenticates the endpoint but cannot prove a process incarnation.
|
||||
7. `listSessions` results populate `sessionAdapters: Map<sessionId, adapter>`.
|
||||
That map is keyed only by reusable session ID, not daemon/session
|
||||
incarnation; a later generation can overwrite an earlier claim.
|
||||
8. No code in hello acceptance, adapter construction, or discovery sends
|
||||
`kill`, `shutdown`, or `shutdownIfIdle` to a legacy daemon.
|
||||
|
||||
### C. Exact broad shutdown caller
|
||||
|
||||
1. Current-daemon replacement/manual restart/full cleanup calls
|
||||
`cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)` (or the explicitly
|
||||
supplied current handle protocol).
|
||||
2. It probes that version's endpoint and creates a `DaemonClient` for the same
|
||||
protocol.
|
||||
3. It connects with the same control/stream hello sequence.
|
||||
4. It requests `listSessions`; any error is converted to `{ sessions: [] }`.
|
||||
5. It then unconditionally sends `shutdown { killSessions: true }` and treats a
|
||||
reply race with daemon exit as success.
|
||||
6. `daemon-server.ts` logs `shutdown reason:'rpc' killSessions:true`, begins the
|
||||
ordinary shutdown admission fence, awaits `host.dispose`, logs but catches
|
||||
`shutdown-dispose-failed`, writes the reply if possible, disposes resources,
|
||||
destroys clients/transports, unlinks owned identity artifacts, and closes
|
||||
the server.
|
||||
7. Repository search found no other direct production sender of
|
||||
`shutdown { killSessions: true }`. Legacy adapter discovery is not a caller.
|
||||
8. Unsafe residual: list failure cannot be treated as proof of emptiness for
|
||||
any future generation reaper, and broad shutdown still lacks an origin,
|
||||
daemon-incarnation, ownership, and intent audit record.
|
||||
|
||||
### D. Supported reconnect-to-mass-kill path
|
||||
|
||||
1. **Renderer bootstrap T+0:** a paired desktop/runtime renderer hydrates
|
||||
persisted mirrors containing process-lifetime remote terminal handles.
|
||||
2. **T+milliseconds:** before the fresh authoritative session snapshot fully
|
||||
reconciles, remote transport subscribes using a stale handle.
|
||||
3. The host cannot resolve a connected PTY for that handle and returns a gone
|
||||
condition; remote transport synthesizes `pty-exit`.
|
||||
4. `Terminal.tsx` calls `closeTerminalTab(tabId, { reason: 'pty-exit' })`.
|
||||
5. Current main uses that reason for local retirement behavior but
|
||||
`closeWebRuntimeSessionTab` sends only `{ worktree, tabId }`.
|
||||
6. Runtime RPC `session.tabs.close` has no origin/intent in its schema or
|
||||
handler. `OrcaRuntimeService.closeMobileSessionTab` treats it like an
|
||||
explicit user request and invokes either direct `ptyController.kill`, a
|
||||
whole-parent renderer close, or headless teardown.
|
||||
7. For daemon-backed terminals, the PTY controller is the `DaemonPtyRouter`.
|
||||
`adapterFor(sessionId)` uses the discovery map to select a current or legacy
|
||||
generation and its adapter sends `kill { sessionId, immediate }`.
|
||||
8. The owning daemon logs `session-killed` before awaiting `host.kill`. This is
|
||||
why app and daemon PIDs remain alive while real terminal sessions disappear.
|
||||
9. **#8871 observed T+3.348 s** for the first bootstrap-to-kill incident. Three
|
||||
other new renderer processes aligned within about one second with 4-, 7-,
|
||||
and 7-worktree kill bursts.
|
||||
10. Stale host tab surfaces (#9352/#9585), persisted client mirrors, reconnect
|
||||
retries, and republished snapshots can invoke the same close again. If the
|
||||
daemon still retains the session entry or physical exit is unresolved, a
|
||||
reconnect can rediscover and target the same ID again. Because logging
|
||||
precedes the awaited kill, repeated `session-killed` records can also be
|
||||
repeated failed/not-yet-settled attempts; they do not prove a dead process
|
||||
was resurrected and killed twice.
|
||||
|
||||
### E. Why adjacent bugs are different
|
||||
|
||||
- **#8275/#8048/#8140:** PTY teardown double-closes a Windows ConPTY native
|
||||
handle and the shared daemon dies. There is no broad shutdown event. All
|
||||
sessions disappear because their owner process died.
|
||||
- **#9704/#9045/#9752:** a valid or invalid kill has already targeted the PTY
|
||||
root, but Windows descendants remain and retain memory/files. The owner stays
|
||||
alive or the registry stays stale. This is cleanup completeness after a kill,
|
||||
not destructive authority.
|
||||
- **#9749/#8871:** app and daemons remain alive. Explicit RPCs reach live daemon
|
||||
sessions because stale client lifecycle evidence is interpreted as intent.
|
||||
|
||||
## Compatibility behavior that must remain
|
||||
|
||||
- App quit is a disconnect, not terminal shutdown.
|
||||
- Same-protocol daemon survival across packaged Windows updates (#7538) avoids
|
||||
terminating live work.
|
||||
- Old-protocol adapters remain addressable after an upgrade so mounted and
|
||||
sleeping PTYs can reattach (#2974, #8768, #8817).
|
||||
- Legacy daemons cannot be judged empty from one current profile or renderer.
|
||||
Missing ownership data, inactive profiles, sleeping sessions, remote/SSH
|
||||
routes, and legacy claims all mean keep/audit.
|
||||
- Current protocol 24+ can retire only after its own atomic server-side idle
|
||||
predicate proves there is nothing to preserve.
|
||||
- Older clients/servers must not infer support from an ignored additive field.
|
||||
In particular, no caller may send daemon `kill intent:'auto'` until a
|
||||
protocol bump or negotiated capability proves the daemon will enforce it.
|
||||
|
||||
## Direction comparison
|
||||
|
||||
| Direction | Immediate safety | Compatibility and failure mode | Decision |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Skip `client-hello` for every old protocol | Avoids routing kills to legacy sessions, but also makes their live PTYs invisible/unattachable. | Directly violates warm legacy reattachment and converts preservation into inaccessible leaks. | Reject. Hello is not the authority bug. |
|
||||
| Force-exit on `shutdown-dispose-failed` | Bounds a half-shutdown process if endpoint fencing and handle cleanup are correct. | Can silently destroy promised live PTYs; does not stop reconnect close RPCs; overlaps #9752 descendant semantics. Current source already continues server shutdown after disposal error. | Not the immediate fix. Test bounded endpoint loss separately. |
|
||||
| Startup reaping | Can reduce accumulated attack surface. | Unsafe without #9138/#9229 all-profile evidence, exact incarnations, grace observations, barriers, and one authority. | Audit-only follow-up, not #9749 patch. |
|
||||
| Explicit generation handoff/retirement | Correct long-term lifecycle model. | Requires old/new capability negotiation and ownership persistence across profiles, SSH, WSL, and sleeping sessions. | Broader #9138/#9229 work. |
|
||||
| Daemon-side fencing | Prevents a retiring daemon from accepting new work or acting after retirement begins. | v24 #9277 already fences ordinary/idle shutdown admission; older protocols cannot be retrofitted. | Preserve and extend only with negotiated capability. |
|
||||
| Host close-intent adjudication (#8872) | Stops the demonstrated stale `pty-exit` echo at the component that owns the live PTY, while real user close and genuinely dead tab retirement still work. | Old clients remain ambiguous unless paired with a default-deny compatibility policy. | Best narrow foundation. |
|
||||
| Connection/device provenance policy (#8888) | Default-denies ambiguous paired-runtime destruction, adds dedupe/rate bounds and strong attribution. | Older paired clients receive a safe successful no-op; policy is broader than one close reason and must preserve mobile/CLI/local/SSH semantics. | Reuse focused policy/logging pieces; assess full policy after the failing harness. |
|
||||
| Job Objects (#9752) | Reaps descendants after an authorized native-Windows agent PTY kill. | Cannot determine intent; plain terminal and WSL exclusions are semantically important. | Compose separately, never substitute for authority. |
|
||||
|
||||
The smallest robust immediate change is expected to combine typed close intent
|
||||
with host-side liveness adjudication and non-secret requester logging. The
|
||||
three-generation harness must decide whether #8888's default-deny/dedupe layer
|
||||
is also required for old-client and repeated-reconnect safety. Full generation
|
||||
retirement stays in #9138/#9229.
|
||||
|
||||
## Harness gate for the next phase
|
||||
|
||||
The first executable artifact will be an isolated native-Windows harness, not a
|
||||
production edit. It must:
|
||||
|
||||
- allocate a temporary runtime/user-data root and unique pipe namespace;
|
||||
- launch three disposable protocol fixtures (for example v21/v22/v23) with
|
||||
distinct live canary children plus stale/terminating table entries;
|
||||
- use the real `DaemonClient`, legacy adapter discovery, router, and runtime
|
||||
session-close path where feasible instead of calling a proposed helper;
|
||||
- prove hello/discovery alone is non-destructive;
|
||||
- separately trigger the exact broad current-protocol shutdown caller and the
|
||||
reconnect stale-mirror close path;
|
||||
- distinguish daemon/app liveness, PTY-root liveness, and descendant liveness
|
||||
with independent counters and process handles;
|
||||
- repeat reconnect bursts and record duplicate requests for a retained ID;
|
||||
- model a PTY owner whose disposal never proves physical exit and assert a
|
||||
bounded, non-authoritative endpoint state;
|
||||
- cover current→next protocol upgrade, same-version quit/relaunch, profile
|
||||
switch, remote-server paired client, multiple simultaneous clients, mixed
|
||||
client/server versions, and WSL/SSH routing boundaries;
|
||||
- validate PID/start identity, dead parent, access-denied identity probes, no
|
||||
listener/pipe/handle leaks, and no CIM/PowerShell per-session hot path;
|
||||
- guarantee cleanup in `finally` using only fixture-owned exact PIDs, handles,
|
||||
files, and pipe names.
|
||||
|
||||
Externally visible RED invariants are: reconnect kills one or more canary PTYs
|
||||
while the app/fixture processes remain alive; repeated stale input can issue
|
||||
another destructive request for the same retained incarnation; and a refused
|
||||
disposal does not reach a bounded terminal endpoint state. The post-fix GREEN
|
||||
invariants are survival of every unrelated canary, one exact kill for an
|
||||
explicit user target, legacy warm reattachment, bounded retirement fencing, and
|
||||
zero leaked fixture processes/handles/pipes.
|
||||
|
||||
## Implemented immediate safety boundary
|
||||
|
||||
Implementation snapshot: 2026-07-21 15:26 PDT (2026-07-21 22:26 UTC).
|
||||
|
||||
The initial implementation used a 30-second capability cache keyed only by
|
||||
runtime-environment ID. That was rejected before finalization: a positive
|
||||
`status.get` result could outlive a server replacement under the same
|
||||
environment, and the later destructive request had no proof that it reached the
|
||||
generation that supplied the capability.
|
||||
|
||||
The implemented boundary makes compatibility and identity validation atomic
|
||||
with the lifecycle request:
|
||||
|
||||
1. Explicit user closes remain on `session.tabs.close`, preserving the durable
|
||||
host-owned close behavior shipped by #8628 and compatibility with old
|
||||
servers that ignore the additive `reason:'user'` field.
|
||||
2. Renderer-originated `pty-exit` and `cleanup` echoes use the additive
|
||||
`session.tabs.closeLifecycle` method. An old server returns
|
||||
`method_not_found` before entering its legacy destructive close handler. The
|
||||
client clears optimistic close suppression and requests an authoritative
|
||||
snapshot; it never falls back to `session.tabs.close`.
|
||||
3. The lifecycle method requires both the `publicationEpoch` observed in the
|
||||
host snapshot and the exact terminal handle observed for that tab. Missing
|
||||
evidence means keep, refresh, and audit rather than kill.
|
||||
4. The host refreshes its PTY records, rejects a different publication epoch,
|
||||
rejects a terminal handle that no longer belongs to the addressed parent,
|
||||
rejects when provider liveness is unavailable, and rejects while any parent
|
||||
leaf still has a connected PTY. Refusals carry a bounded reason
|
||||
(`stale-publication`, `stale-terminal`, `unknown-liveness`,
|
||||
`live-host-pty`, or `retirement-owner`) and republish only when doing so
|
||||
cannot create the known dead-leaf/live-sibling echo loop.
|
||||
5. A lifecycle close never signals a PTY or relays a renderer close. A dead
|
||||
whole headless parent can be retired from persisted/runtime state with
|
||||
`killPtys:false`; a renderer-owned parent or partial split remains with its
|
||||
authoritative owner. Thus a reusable tab ID or incomplete provider read
|
||||
cannot become destructive authority.
|
||||
6. Reasonless closes from authenticated legacy mobile or runtime clients retain
|
||||
their pre-change explicit-user meaning, so upgrading only the host does not
|
||||
break close. Their old lifecycle/user ambiguity remains until the client
|
||||
upgrades; unattributed in-process reasonless calls are refused and replayed.
|
||||
7. Renderer close intents are scoped by runtime environment and worktree, and
|
||||
terminal-incarnation evidence must match that exact runtime environment.
|
||||
Identical tab/worktree IDs in another profile cannot suppress or authorize
|
||||
this profile's retirement.
|
||||
|
||||
The RPC span records origin/client kind, close reason, connection ID, request
|
||||
ID, publication epoch, and allow/refusal decision. It does not record terminal
|
||||
contents, authentication tokens, or environment secrets. The legacy adapter
|
||||
hello/discovery path, warm PTY adoption, daemon shutdown protocol, and Job
|
||||
Object descendant cleanup remain unchanged.
|
||||
|
||||
This is the smallest immediate #9749 fix. Cross-profile generation inventory,
|
||||
all-profile ownership evidence, sleeping-session policy, and explicit daemon
|
||||
handoff/retirement remain #9138/#9229. Windows descendant reaping after an
|
||||
authorized kill remains #9704/#9752.
|
||||
|
||||
## GREEN evidence
|
||||
|
||||
The post-fix native-Windows run completed at 2026-07-21 15:25 PDT:
|
||||
|
||||
- v21/v22/v23 used distinct versioned named pipes under a disposable runtime
|
||||
root, with six independent PTY-root/descendant canaries;
|
||||
- three reconnect discovery bursts plus simultaneous clients opened the real
|
||||
control/stream hello pairs without destructive side effects;
|
||||
- desktop and two remote/profile connection identities attempted each stale
|
||||
mirror retirement three times, then a full reconnect-client process
|
||||
exit/relaunch repeated the same persisted IDs for six attempts total;
|
||||
- the lifecycle requests traversed the production schema, dispatcher,
|
||||
`OrcaRuntimeService`, and `DaemonPtyRouter` using publication/terminal
|
||||
incarnation claims;
|
||||
- every daemon, PTY root, and descendant remained alive while the app/client
|
||||
process remained alive;
|
||||
- the contained refusal-to-exit PTY produced `shutdown-dispose-failed`, lost
|
||||
named-pipe authority within the bounded deadline, and was cleaned by exact
|
||||
fixture-owned process identity;
|
||||
- the reconnect/relaunch test passed in 42.9 seconds and the bounded-disposal
|
||||
test passed in 15.2 seconds.
|
||||
|
||||
Focused verification at this checkpoint: node and web typechecks passed;
|
||||
changed-file `oxlint` and `git diff --check` passed; 181 focused renderer/RPC
|
||||
tests passed; and 12 host close-adjudication tests passed (784 unrelated tests
|
||||
filtered out). No PowerShell/CIM process-per-session path, polling loop, broad
|
||||
installed-daemon discovery, or Job Object implementation was added.
|
||||
|
||||
Additional validation completed at 2026-07-21 15:48 PDT:
|
||||
|
||||
- the native generation harness passed 25 reconnect bursts in 52.1 seconds;
|
||||
every v21/v22/v23 daemon and all six PTY-root/descendant canaries survived;
|
||||
- the mixed-version daemon-lifecycle E2E kept the non-empty v22 daemon
|
||||
reattachable while the empty current v24 daemon retired through #9277;
|
||||
- a fresh `pnpm build:electron-vite` and a fresh paired-web-client build both
|
||||
completed successfully;
|
||||
- 182 remote-runtime, multi-client, remote-server parity, SSH-provider, WSL
|
||||
host-context, and remote PTY transport tests passed;
|
||||
- the complete runtime service/RPC group passed 884 of 885 tests. The lone
|
||||
failure, `preserves existing badgeColor on runtime createRepo dedupe`, is an
|
||||
existing Windows-only POSIX path expectation (`/tmp/...` versus
|
||||
`\\tmp\\...`) in code untouched by this change; every close, remote, SSH,
|
||||
and WSL case in that run passed;
|
||||
- CLI typecheck, switch-independent changed-file lint, reliability gates,
|
||||
max-lines ratchet, and `git diff --check` passed. The repository-wide
|
||||
switch-exhaustiveness command is blocked by the pre-existing unmatched
|
||||
`undefined | 'current' | 'duplicate'` cases in
|
||||
`skill-freshness-group.tsx`, outside this diff.
|
||||
|
||||
Two practical Electron runs exposed setup/teardown limitations without
|
||||
contradicting the close-safety result:
|
||||
|
||||
- `restart-restore-terminal-input.spec.ts` completed the clean-restart, live
|
||||
daemon, restored-output, keyboard-input, and direct-input assertions, then
|
||||
failed only in `RestartSession.dispose()` with `EPERM` deleting its isolated
|
||||
profile. Restart Manager and Sysinternals Handle found no surviving file
|
||||
lock after teardown; the exact fixture root deleted successfully later
|
||||
without terminating a process. The same failure reproduced with the temp
|
||||
root inside this worktree and with the production diff to
|
||||
`orca-restart.ts` empty, so it is recorded as a fixture cleanup limitation,
|
||||
not a session-liveness failure;
|
||||
- the paired-browser navigation E2E built the web client and launched the
|
||||
isolated desktop, but timed out before pairing because its host fixture
|
||||
displayed `No workspaces found`. The deterministic multi-client runtime
|
||||
integration passed; no terminal-close assertion failed in this E2E.
|
||||
|
||||
All diagnostic downloads and worktree-local E2E temp roots were removed by
|
||||
their exact verified paths. No installed Orca daemon pipe or real user terminal
|
||||
was discovered, greeted, stopped, or mutated during these runs.
|
||||
|
||||
## Internal review-until-clean and final native evidence
|
||||
|
||||
The requested `$internal-review-until-clean` loop ran against merge base
|
||||
`937a2015eaf85144d02848c5b6d4c09ecd423830`. Round 1 found and fixed four
|
||||
in-scope safety defects:
|
||||
|
||||
- lifecycle retirement could still relay a destructive renderer close or kill
|
||||
retained/disconnected headless IDs; lifecycle requests now never signal a
|
||||
process and only state-retire a dead whole headless parent;
|
||||
- an unavailable/access-denied PTY inventory was treated like authoritative
|
||||
absence; it now returns `unknown-liveness` and keeps/audits;
|
||||
- pending close intent was keyed only by worktree and could cross-contaminate
|
||||
two runtime profiles; it is now scoped and cleaned by environment/worktree;
|
||||
- the harness accepted a two-second PID start-time tolerance; capture and
|
||||
revalidation now compare the same CIM `CreationDate` exactly.
|
||||
|
||||
The elegance pass also removed an unnecessary cached capability probe. The
|
||||
additive method dispatch on the exact connection is the atomic compatibility
|
||||
boundary; a cached positive result could outlive server replacement. The
|
||||
performance pass found no production polling, process enumeration, listener,
|
||||
or subprocess addition. The existing bounded controller inventory refresh is
|
||||
unchanged in frequency, snapshot refreshes coalesce per environment/worktree,
|
||||
and all added tracking maps have completion or ownership cleanup. Round 2
|
||||
re-interrogated the full diff and found no remaining proven in-scope issue.
|
||||
After strengthening the native harness to invoke production desktop discovery,
|
||||
round 3 found one Windows-only test defect: three scanner tests modeled a live
|
||||
v9 endpoint through POSIX `existsSync` but let every Windows named-pipe probe
|
||||
connect. Their socket mock now accepts only v9 and errors every other version;
|
||||
all 133 daemon lifecycle tests pass. The subsequent full-diff review is clean.
|
||||
|
||||
The latest native Windows run captured its event reconstruction at
|
||||
2026-07-21 16:58 PDT (2026-07-21 23:58 UTC). It launched v21/v22/v23/v24/v25
|
||||
on isolated versioned named pipes, then called the same
|
||||
`createLegacyDaemonAdapters` scanner as desktop startup: the v25 client found
|
||||
exactly v21-v24. Every generation accepted 16 reconnect control/stream hellos;
|
||||
desktop and two remote-profile paths sent six lifecycle attempts for each
|
||||
persisted stale-mirror ID across process relaunch. All five daemons, all ten
|
||||
PTY roots, and all ten descendants were alive afterward, with zero
|
||||
`session-killed` events. This directly covers the v24→v25 current-to-next
|
||||
upgrade boundary while keeping older generations reattachable.
|
||||
|
||||
The bounded `shutdown-dispose-failed` scenario also passed and now separates
|
||||
authority from liveness explicitly: the late connection failed after endpoint
|
||||
fencing while the refusing daemon, PTY root, and descendant remained alive
|
||||
until exact fixture cleanup. The full Playwright command, including a fresh
|
||||
Electron E2E build, exited successfully in 137.1 seconds, and no
|
||||
`orca-9749-dg-*` directory remained. A final no-rebuild rerun passed both
|
||||
scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed
|
||||
again in 24.0 seconds, and mixed-version retirement/live-session preservation
|
||||
passed 2/2 in 23.1 seconds.
|
||||
|
||||
Latest focused verification includes 15/15 host adjudication tests, 948/949
|
||||
focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/
|
||||
retirement/access-failure tests, all three typechecks, and 215
|
||||
remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote
|
||||
PTY, and shared-control tests. The sole focused failure remains the untouched
|
||||
Windows `/tmp` normalization baseline documented above. The experimental
|
||||
`terminal-session.daemon-generation-reconnect-safety` reliability gate records
|
||||
the invariant, RED/GREEN oracle, performance budget, promotion criteria, and
|
||||
known platform/provider gaps.
|
||||
|
||||
## Fresh PR review after current-main integration
|
||||
|
||||
The explicitly requested post-PR `$internal-review-until-clean` pass completed
|
||||
at 2026-07-21 18:02 PDT after merging `v1.4.150-rc.0` main, including the
|
||||
remote-runtime network-recovery work from #9774. It found and fixed three
|
||||
additional contract gaps:
|
||||
|
||||
- a stale PTY-exit callback could borrow a replacement or sibling handle from
|
||||
tab-wide state; lifecycle evidence now comes only from the exact callback PTY;
|
||||
- the legacy close endpoint accepted lifecycle reasons without incarnation
|
||||
evidence; it now accepts only explicit user intent, with reasonless
|
||||
compatibility retained for authenticated legacy mobile and runtime clients;
|
||||
- keep-on-unknown preserved the PTY but could leave its client mirror hidden;
|
||||
the host now republishes unchanged authority when inventory is unavailable.
|
||||
|
||||
The PR feedback loop also corrected the bulk-close payload assertion and made
|
||||
the fixture protocol list collision-safe. The final native no-build run passed
|
||||
both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds,
|
||||
mixed-version retirement passed, and practical Electron restart/input passed
|
||||
two scenarios with one intentionally skipped wedge scenario. Current-main
|
||||
remote recovery (112 tests), focused close/reconnect suites, all 15 host
|
||||
adjudication cases, all typechecks, reliability gates, max-lines, changed lint,
|
||||
formatting, and diff checks pass. The full lint command remains blocked only by
|
||||
pre-existing current-main switch-exhaustiveness and localization findings; no
|
||||
remaining in-scope review finding is open.
|
||||
|
||||
## Additional clean review after `v1.4.150-rc.0` integration
|
||||
|
||||
This additional requested `$internal-review-until-clean` pass completed at
|
||||
2026-07-21 18:56 PDT (2026-07-22 01:56 UTC) against merge base
|
||||
`4d0e3f51ce0325a8f4670b4074618494984ab63d`. Round 1 found and fixed four
|
||||
in-scope evidence/performance gaps:
|
||||
|
||||
- the native harness omitted production `DaemonPtyRouter.listProcesses`, so
|
||||
its synthetic lifecycle closes could pass through `unknown-liveness`; it now
|
||||
routes the production inventory, uses worktree-prefixed daemon session IDs,
|
||||
and requires every close to return `live-host-pty` with a republished
|
||||
snapshot;
|
||||
- concurrent reconnect closes each started a full cross-generation PTY
|
||||
inventory; the host now shares one in-flight inventory and a deterministic
|
||||
count test proves two concurrent closes call `listProcesses` once;
|
||||
- the renderer introduced a second snapshot-refresh map; it now reuses the
|
||||
existing environment/worktree remote-session deduper used by PTY reconnect;
|
||||
- the reliability gate omitted the parked-tab callback path; its exact exiting
|
||||
PTY incarnation assertion is now part of the gate.
|
||||
|
||||
The review also added a wire-compatibility assertion proving that the additive
|
||||
`{ reason: 'user' }` field is stripped by the previous `ActivateTab` server
|
||||
schema. The repeated elegance and performance interrogation found no remaining
|
||||
avoidable infrastructure, polling, subprocess churn, listener/handle leak, or
|
||||
unbounded reconnect work. Round 2 re-read the full production and fixture diff,
|
||||
constructed stale-publication, stale-handle, concurrent-close, mixed-client,
|
||||
and old-server failure paths, and found no remaining proven in-scope issue.
|
||||
|
||||
Fresh verification includes all three typechecks; reliability-manifest,
|
||||
max-lines, changed-file lint, formatting, and diff checks; 175 remote snapshot
|
||||
and PTY transport tests; and the isolated lifecycle/reconnect adjudication
|
||||
group. The strengthened native five-generation Windows harness passed after a
|
||||
fresh Electron E2E build in 137.2 seconds, required `live-host-pty` for every
|
||||
synthetic close, left every daemon/root/descendant alive, and left no
|
||||
`orca-9749-dg-*` directory. The full focused gate reached 992/993 passing tests;
|
||||
the only failure remains the untouched Windows `/tmp` versus `\\tmp`
|
||||
`createRepo` baseline. The newly merged headless-update group reached 143/144
|
||||
passing tests plus 25 skips; its only failure is an untouched LF-only
|
||||
source-text assertion that does not match CRLF on Windows.
|
||||
|
||||
## Completion evidence matrix
|
||||
|
||||
| Original acceptance requirement | Authoritative evidence | Status |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| Complete recursive issue/PR/commit/tag investigation before production edits | The 41-row investigation matrix above, release chronology, and timestamped call-flow reconstruction cover every required starting item and every directly discovered related item. GitHub reads were completed before the first production edit. | Proven |
|
||||
| Explain survival, generation discovery/hello, broad shutdown caller, repeated reconnect kills, adjacent-bug distinctions, and compatibility | Sections A–E identify `disconnectDaemon`, `createLegacyDaemonAdapters` → `discoverLegacySessions` → control/stream hello, `cleanupDaemonForProtocol`, the stale-handle `pty-exit` path, #8275 versus #9704, and the warm legacy reattach contract. | Proven |
|
||||
| Isolated native-Windows multi-generation reproduction with external liveness oracle | `daemon-generation-reconnect-safety.spec.ts` uses an exact temporary user-data/runtime root, versioned pipe names, disposable ConPTY children, independent daemon/root/descendant PID-start identities, bounded output, and exact cleanup. The historical RED run above recorded real repeated `session-killed` events while daemon/client witnesses stayed alive. | Proven |
|
||||
| Exercise the desktop discovery path and current→next mixed-version upgrade | The latest native run calls production `createLegacyDaemonAdapters`; v25 discovers exactly v21/v22/v23/v24, reattaches every original process incarnation, and leaves all five generations alive. | Proven |
|
||||
| Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) |
|
||||
| `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven |
|
||||
| Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven |
|
||||
| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and authenticated legacy versus unattributed reasonless callers. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven |
|
||||
| Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) |
|
||||
| No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven |
|
||||
| Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable |
|
||||
| Practical Electron restart behavior | A real isolated Electron application created a daemon-backed terminal, wrote and restored output across clean app quit/relaunch, preserved the exact daemon PID, and accepted both keyboard and direct terminal input after reattachment. | Proven on native Windows (`electron-headless`, 24.0 s) |
|
||||
| Internal review-until-clean and final gates | The original three review rounds and both additional PR review loops are clean. The latest loop fixed four oracle/performance/gate gaps, then completed a clean full-diff re-review. Typecheck, lint, format, reliability manifest, max-lines ratchet, Electron build, native harness, and focused suites pass apart from the documented untouched baselines. | Proven |
|
||||
| Public GitHub and real daemon safety | No public GitHub mutation occurred. Fixture guards reject non-temporary roots and known Orca user-data paths; no installed pipe/token/session was discovered or contacted. | Proven |
|
||||
|
||||
Live Linux SSH validation remains an explicit gap: the required throwaway
|
||||
Docker target is unavailable on this runner (`docker` is not installed and no
|
||||
Docker Desktop process or standard executable path exists), and `wsl.exe`
|
||||
reports that WSL is not installed. A real remote or localhost was deliberately
|
||||
not substituted. The Orca worktree comment was updated through the scoped
|
||||
`worktree set --comment` CLI with the investigation, RED reproduction,
|
||||
root-cause, implementation, native-validation, and clean-review milestones.
|
||||
No terminal or daemon command was issued through the CLI, and no public GitHub
|
||||
comment was made.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user