Merge remote-tracking branch 'origin/main' into OrcaWin/node-rt-phase3

# Conflicts:
#	config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1
#	src/main/ipc/parcel-watcher-process-supervisor.ts
This commit is contained in:
m4air
2026-10-02 14:38:16 -07:00
223 changed files with 10931 additions and 1263 deletions
@@ -30,7 +30,7 @@ inputs:
outputs:
verification-cache-hit:
description: Whether pnpm's verification record was restored.
value: ${{ steps.verification-cache-restore.outputs.cache-hit }}
value: ${{ steps.verification-cache.outputs.cache-hit }}
verification-cache-path:
description: The small pnpm-owned verification record, without registry metadata.
value: ${{ steps.verification-cache.outputs.path }}
@@ -110,28 +110,11 @@ runs:
path: ${{ steps.pnpm-store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}
- name: Resolve pnpm verification cache
id: verification-cache
if: >-
inputs.cache-pnpm-verification == 'true' &&
(runner.os == 'Linux' ||
(runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) ||
(runner.os == 'macOS' && runner.arch == 'X64'))
shell: bash
env:
POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }}
run: |
printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT"
printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT"
- name: Restore pnpm verification record
id: verification-cache-restore
if: steps.verification-cache.outputs.key != ''
continue-on-error: true
uses: actions/cache/restore@v5
id: verification-cache
uses: ./.github/actions/restore-pnpm-verification
with:
path: ${{ steps.verification-cache.outputs.path }}
key: ${{ steps.verification-cache.outputs.key }}
enabled: ${{ inputs.cache-pnpm-verification }}
- name: Validate native runtime
shell: bash
@@ -169,7 +152,7 @@ runs:
# pnpm checks the cached record's policy and validity; never bypass verification.
- name: Save pnpm verification record on main
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true'
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
@@ -0,0 +1,58 @@
name: Restore pnpm verification
description: Restore the pnpm-owned lockfile verdict without installing dependencies.
inputs:
enabled:
description: Restore a verification record on supported runners.
default: 'true'
container-toolchain:
description: Use the manifest's pnpm version and default Linux cache path without installing host pnpm.
default: 'false'
outputs:
path:
description: The pnpm-owned verification record.
value: ${{ steps.verification-cache.outputs.path }}
key:
description: Exact OS, architecture, pnpm version and policy key.
value: ${{ steps.verification-cache.outputs.key }}
cache-hit:
description: Whether the exact record was restored.
value: ${{ steps.verification-cache-restore.outputs.cache-hit }}
runs:
using: composite
steps:
- name: Resolve pnpm verification cache
id: verification-cache
if: >-
inputs.enabled == 'true' &&
(runner.os == 'Linux' ||
(runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) ||
(runner.os == 'macOS' && runner.arch == 'X64'))
shell: bash
env:
POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }}
CONTAINER_TOOLCHAIN: ${{ inputs.container-toolchain }}
run: |
if [ "$CONTAINER_TOOLCHAIN" = true ]; then
test "$RUNNER_OS" = Linux
# Cache versions include paths, so match the native Linux producer.
cache_path="$(node -p "require('node:path').join(process.env.XDG_CACHE_HOME || require('node:path').join(require('node:os').homedir(), '.cache'), 'pnpm')")"
pnpm_version="$(node -p "require('./package.json').packageManager.match(/^pnpm@([^+]+)/)[1]")"
mkdir -p "$cache_path"
else
cache_path="$(pnpm cache path)"
pnpm_version="$(pnpm --version)"
fi
printf 'path=%s/lockfile-verified.jsonl\n' "$cache_path" >> "$GITHUB_OUTPUT"
printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH" >> "$GITHUB_OUTPUT"
- name: Restore pnpm verification record
id: verification-cache-restore
if: steps.verification-cache.outputs.key != ''
continue-on-error: true
uses: actions/cache/restore@v5
with:
path: ${{ steps.verification-cache.outputs.path }}
key: ${{ steps.verification-cache.outputs.key }}
@@ -1,5 +1,7 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { pathToFileURL } from 'node:url'
import { isAgentStateRulesTag } from '../../config/scripts/release-tag-patterns.mjs'
const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca'
const token = process.env.GITHUB_TOKEN
@@ -24,6 +26,22 @@ async function fetchJson(url) {
return response.json()
}
// Why rules releases are excluded: every running app fetches them every few hours, so they
// count fetches, not installs.
export function countReleaseDownloads(releases) {
let total = 0
for (const release of releases) {
if (release.draft || isAgentStateRulesTag(release.tag_name ?? '')) {
continue
}
for (const asset of release.assets ?? []) {
total += asset.download_count ?? 0
}
}
return total
}
async function getTotalReleaseDownloads() {
let page = 1
let total = 0
@@ -37,16 +55,7 @@ async function getTotalReleaseDownloads() {
return total
}
for (const release of releases) {
if (release.draft) {
continue
}
for (const asset of release.assets ?? []) {
total += asset.download_count ?? 0
}
}
total += countReleaseDownloads(releases)
page += 1
}
}
@@ -104,9 +113,11 @@ function renderBadge(value) {
`
}
const total = await getTotalReleaseDownloads()
const badge = renderBadge(formatDownloads(total))
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
const total = await getTotalReleaseDownloads()
const badge = renderBadge(formatDownloads(total))
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, badge)
console.log(`Rendered ${outputPath} from ${total} downloads.`)
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, badge)
console.log(`Rendered ${outputPath} from ${total} downloads.`)
}
@@ -0,0 +1,94 @@
name: Publish agent state rules
# The only publisher of agent state rules releases. Merging never publishes: a maintainer runs
# this from main, first to `next` (RC and dev builds), then promotes the identical file to
# `stable` after a soak. Every job runs on the dispatched commit, so the publish job runs exactly
# the script and rules the gate tested.
#
# Why GITHUB_TOKEN: release-policy.yml deletes an agent-state-rules-* release any other author
# publishes. Its releases start no workflow, so release-policy.yml sees them only when someone
# edits one by hand, and then accepts them as bot-authored prereleases.
on:
workflow_dispatch:
inputs:
action:
description: 'publish-next builds from main and publishes to next; promote-stable copies the next file to stable'
required: true
type: choice
options:
- publish-next
- promote-stable
bundled_only:
description: 'publish-next: tell apps to fall back to the rules they shipped with; promote-stable then carries it to stable'
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: agent-state-rules-publish
cancel-in-progress: false
jobs:
gate:
if: github.ref == 'refs/heads/main' && inputs.action == 'publish-next'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Run the rules gate (schema, regex safety, the bundle, every transcript replay)
shell: bash
run: |
set -euo pipefail
mapfile -t gate_files < <(git ls-files \
':(glob)src/main/runtime/agent-state-rules/**/*.test.ts' \
':(glob)src/main/runtime/readiness-census*.test.ts' \
':(glob)src/main/runtime/*transcript.test.ts' \
':(glob)src/main/runtime/*transcripts.test.ts' \
config/scripts/agent-state-rules-bundle.test.mjs)
pnpm exec vitest run --config config/vitest.config.ts "${gate_files[@]}"
publish-next:
needs: gate
runs-on: ubuntu-latest
timeout-minutes: 10
environment: agent-state-rules
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Publish to next
env:
GH_TOKEN: ${{ github.token }}
BUNDLED_ONLY: ${{ inputs.bundled_only }}
shell: bash
run: |
set -euo pipefail
flags=()
[[ "$BUNDLED_ONLY" == "true" ]] && flags+=(--bundled-only)
node config/scripts/agent-state-rules-bundle.mjs publish-next "${flags[@]}"
promote-stable:
if: github.ref == 'refs/heads/main' && inputs.action == 'promote-stable'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: agent-state-rules
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Copy the next file to stable
env:
GH_TOKEN: ${{ github.token }}
run: node config/scripts/agent-state-rules-bundle.mjs promote-stable
+4 -2
View File
@@ -1,14 +1,16 @@
name: Warm shared CI caches
on:
# Cache-input pushes seed immediately; this schedule repairs eviction and image changes.
schedule:
- cron: '41 * * * *'
- cron: '41 */6 * * *'
workflow_dispatch:
push:
branches: [main]
paths:
- '.github/workflows/ci-cache-warmup.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
- '.github/actions/prepare-native-runtime/**'
- '.github/actions/prepare-git-compatibility/**'
- '.github/actions/prepare-linux-package-fixture/**'
@@ -51,7 +53,7 @@ permissions:
concurrency:
group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}
# Hourly retries must let an active warmer finish publishing its caches.
# Scheduled retries must let an active warmer finish publishing its caches.
cancel-in-progress: ${{ github.event_name != 'schedule' }}
jobs:
+1
View File
@@ -26,6 +26,7 @@ on:
# it or to the release workflow it guards must re-run them.
- '.github/workflows/mobile.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
- '.github/workflows/mobile-ios-release.yml'
- 'config/scripts/mobile-release-check-scope*'
- 'config/scripts/mobile-test-change-scope*'
+34 -6
View File
@@ -16,6 +16,7 @@ on:
- '.npmrc'
- '.pnpmfile.cjs'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
- '.github/actions/prepare-native-runtime/**'
- '.github/actions/prepare-orcad-prebuilds/**'
- '.github/workflows/node-server-tests.yml'
@@ -35,6 +36,7 @@ on:
- '.npmrc'
- '.pnpmfile.cjs'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
- '.github/actions/prepare-native-runtime/**'
- '.github/actions/prepare-orcad-prebuilds/**'
- '.github/workflows/node-server-tests.yml'
@@ -74,15 +76,18 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
should_run: ${{ steps.scope.outputs.should_run }}
qualification: ${{ steps.scope.outputs.qualification }}
runners: ${{ steps.scope.outputs.runners }}
should_run: ${{ steps.scope.outputs.should_run || steps.graph.outputs.should_run }}
qualification: ${{ steps.scope.outputs.qualification || steps.graph.outputs.qualification }}
runners: ${{ steps.scope.outputs.runners || steps.graph.outputs.runners }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- uses: actions/setup-node@v6
with:
node-version-file: package.json
package-manager-cache: false
- name: Detect headless-server build and test inputs
id: scope
shell: bash
@@ -94,7 +99,7 @@ jobs:
# Compare the entire push, including multi-commit pushes and removed files.
if git fetch --no-tags --depth=1 origin "$PUSH_BASE" &&
git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification
else
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
@@ -102,11 +107,26 @@ jobs:
fi
# Compare the tested merge with its base, retaining both sides of renames.
if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes"
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph
else
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/install-node-dependencies
if: steps.scope.outputs.graph_required == 'true'
- name: Check the headless import graph
id: graph
if: steps.scope.outputs.graph_required == 'true'
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
run: |
if [ "$EVENT_NAME" = push ]; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification
else
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes"
fi
persistence:
needs: changes
concurrency:
@@ -363,11 +383,19 @@ jobs:
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: ./.github/actions/restore-pnpm-verification
id: pnpm-verification
with:
container-toolchain: 'true'
- name: Verify native Alpine artifact and persistence
env:
VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }}
run: |
touch "$VERIFICATION_CACHE_PATH"
# Multi-arch index digest of the tag; re-resolve it whenever NODE_RUNTIME_PIN moves.
docker run --rm --init -i \
-e ORCA_BACKGROUND_LAUNCH=1 \
-v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \
-v "$GITHUB_WORKSPACE:/work" -w /work \
node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'NODE_SERVER_QUALIFICATION'
set -eu
+54 -7
View File
@@ -79,6 +79,7 @@ jobs:
source_ref: ${{ steps.resolve.outputs.ref }}
source_sha: ${{ steps.resolve.outputs.sha }}
source_short_sha: ${{ steps.resolve.outputs.short_sha }}
ships_orcad_template: ${{ steps.orcad-template-support.outputs.ships }}
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
@@ -783,6 +784,21 @@ jobs:
git push origin "$TAG"
fi
# Why: a patch cut from a base older than #24155 has no orcad template to build or ship.
- name: Detect whether the tag ships the orcad template
id: orcad-template-support
if: steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != ''
env:
TAG: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
run: |
set -euo pipefail
if git cat-file -e "refs/tags/${TAG}^{commit}:config/scripts/packaged-orcad-template.cjs" 2>/dev/null; then
echo "ships=true" >>"$GITHUB_OUTPUT"
else
echo "ships=false" >>"$GITHUB_OUTPUT"
echo "::notice::$TAG predates the orcad template; skipping its build and download."
fi
- name: Release E2E signal summary
if: always()
run: |
@@ -1154,7 +1170,7 @@ jobs:
# signing quota, so it runs beside the release gates instead of behind them.
orcad-template:
needs: cut
if: needs.cut.outputs.should_release == 'true'
if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true'
permissions:
contents: read
uses: ./.github/workflows/node-server-tests.yml
@@ -1203,7 +1219,16 @@ jobs:
- orcad-template
- release-preflight
- relay-windows-process-tree
if: needs.cut.outputs.should_release == 'true'
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
if: >-
!cancelled() &&
needs.cut.result == 'success' &&
needs.cut.outputs.should_release == 'true' &&
needs.create-release.result == 'success' &&
needs.release-preflight.result == 'success' &&
needs.relay-windows-process-tree.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
env:
# beforePack and afterPack fail the package when the template is absent.
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
@@ -1463,6 +1488,7 @@ jobs:
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
if: needs.cut.outputs.ships_orcad_template == 'true'
uses: actions/download-artifact@v8
with:
name: orcad-template
@@ -1806,7 +1832,7 @@ jobs:
# each file against the template manifest, so it must record the signed bytes.
- name: Reseal the orcad template over its signed binaries
id: reseal-orcad-template
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true'
run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt
# The uninstaller must return signed before rebuilding the installer.
@@ -2306,7 +2332,16 @@ jobs:
- release-preflight
# release-mac-build.yml downloads the relay addons from this run.
- relay-windows-process-tree
if: needs.cut.outputs.should_release == 'true'
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
if: >-
!cancelled() &&
needs.cut.result == 'success' &&
needs.cut.outputs.should_release == 'true' &&
needs.create-release.result == 'success' &&
needs.release-preflight.result == 'success' &&
needs.relay-windows-process-tree.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
# Why: SignPath requires every job in this signing workflow to be
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
# Blacksmith stays outside Windows artifact provenance.
@@ -2340,6 +2375,15 @@ jobs:
- skill-sharing-linux-floor-release-gate
- skill-sharing-release-gate
- terminal-rendering-golden
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
if: >-
!cancelled() &&
needs.cut.result == 'success' &&
needs.build.result == 'success' &&
needs.build-mac.result == 'success' &&
needs.skill-sharing-linux-floor-release-gate.result == 'success' &&
needs.skill-sharing-release-gate.result == 'success' &&
needs.terminal-rendering-golden.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -2408,7 +2452,8 @@ jobs:
needs:
- cut
- publish-release
if: ${{ needs.cut.outputs.tag != '' }}
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
@@ -2437,7 +2482,8 @@ jobs:
# A release created with GITHUB_TOKEN does not reliably emit a release
# event to other workflows. Dispatch the trusted default-branch workflow;
# it validates and checks out the released tag before deploying.
if: ${{ needs.cut.outputs.tag != '' }}
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
@@ -2481,7 +2527,8 @@ jobs:
needs:
- cut
- publish-release
if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }}
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }}
uses: ./.github/workflows/homebrew-bump.yml
with:
tag: ${{ needs.cut.outputs.tag }}
+2
View File
@@ -158,6 +158,8 @@ jobs:
# Design D2: the parent release-cut run merged it from every node-server lane at this tag.
- name: Download the orcad deployment template from the release run
# Why: release-cut skips the template for a tag that predates it.
if: hashFiles('config/scripts/packaged-orcad-template.cjs') != ''
uses: actions/download-artifact@v8
with:
name: orcad-template
+3 -1
View File
@@ -22,7 +22,9 @@ jobs:
# Why: release events run this file from the tagged commit, so load the module from the same commit.
- uses: actions/checkout@v6
with:
sparse-checkout: config/scripts/release-policy.mjs
sparse-checkout: |
config/scripts/release-policy.mjs
config/scripts/release-tag-patterns.mjs
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Enforce release policy
+9
View File
@@ -27,6 +27,7 @@ on:
- '!src/**/*.test.ts'
- 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts'
- '.github/workflows/ssh-hostile-hosts.yml'
- '.github/actions/restore-pnpm-verification/**'
workflow_dispatch:
inputs:
cells:
@@ -99,11 +100,19 @@ jobs:
with:
name: hostile-hosts-glibc-slot
path: out/orcad-prebuilds
- uses: ./.github/actions/restore-pnpm-verification
id: pnpm-verification
with:
container-toolchain: 'true'
- name: Build and smoke the linux-x64-musl slot on Alpine
env:
VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }}
run: |
touch "$VERIFICATION_CACHE_PATH"
# Same digest as the headless-server musl lane; re-resolve it whenever NODE_RUNTIME_PIN moves.
docker run --rm --init -i \
-e ORCA_BACKGROUND_LAUNCH=1 \
-v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \
-v "$GITHUB_WORKSPACE:/work" -w /work \
node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'MUSL_SLOT'
set -eu
+21 -5
View File
@@ -38,6 +38,7 @@ on:
- 'config/ci/windows-ssh-provider/**'
- '.github/workflows/ssh-windows-hosts.yml'
- '.github/actions/prepare-orcad-prebuilds/**'
- '.github/actions/restore-pnpm-verification/**'
- 'config/scripts/orcad-windows-prebuild-cache.mjs'
workflow_dispatch:
inputs:
@@ -85,10 +86,6 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
id: dependencies
with:
native-runtime: node
- name: Self-test the provisioning scripts before touching the machine
shell: pwsh
run: |
@@ -98,6 +95,22 @@ jobs:
if($errors.Count){throw "PowerShell parse failed: $($file.FullName)"}
}
& config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1
# ARM inbox servicing can finish while the independent Node artifacts are prepared.
- name: Prepare the Windows inbox SSH capability
id: inbox-capability
background: true
shell: pwsh
run: |
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json')
}
- uses: ./.github/actions/install-node-dependencies
id: dependencies
with:
native-runtime: node
# The deploy materializes rung A from this template; only this runner's slot exists here.
# The process-tree addon carries the launcher that starts the relay outside sshd's job; the
# orcad slot and the relay both stage it, and a standard-user host has no other launch route.
@@ -123,6 +136,7 @@ jobs:
pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}"
node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}"
pnpm run build:relay
- wait: inbox-capability
- name: Run the Windows host cells against a private ${{ matrix.server }} sshd
shell: pwsh
timeout-minutes: 50
@@ -136,6 +150,8 @@ jobs:
$cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_})
if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')}
$archive=''
$preparation=''
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=Join-Path $receipts 'inbox-capability-preparation.json'}
if('${{ matrix.server }}' -eq 'preview'){
$archive=Join-Path $env:RUNNER_TEMP 'preview-${{ matrix.archive }}'
# The provisioning script refuses the archive unless its sha256 and every binary's match the pin.
@@ -145,7 +161,7 @@ jobs:
$callback={param($context)
& (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells
}.GetNewClosure()
& (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log')
& (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log')
- uses: actions/upload-artifact@v7
if: always()
with:
@@ -2,46 +2,34 @@
# -HiddenTools: the private accounts are denied every machine PATH directory holding one of these
# executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain.
# -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes.
param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe)
param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe,[string]$InboxPreparationReceipt)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')
$target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch]
$scopeServer=if($Server -eq 'inbox'){'Windows inbox OpenSSH.Server capability binaries'}else{'Microsoft Win32-OpenSSH 10.0.0.0p2-Preview'}
$report = @{scope="$scopeServer $Arch private loopback authentication and stock cmd.exe dispatch"; server=$Server; status='running'; imageVersion=$env:ImageVersion; cleanup=@('not-confirmed'); globalBootstrapCleanup='Not qualified: service bootstrap may create ProgramData SSH and OpenSSH registry entries; disposable CI VM destruction is the boundary'; observations=@(); stages=@(); diagnosticCaptureFailures=@()}
$script:receiptWritten=$false
function Write-Stage([string]$Stage) {
$timestamp=[DateTime]::UtcNow.ToString('o')
$report.stages += @{stage=$Stage; utc=$timestamp}
try {
$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report | ConvertTo-Json -Depth 6))
$temporary="$Receipt.pending"
$stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read)
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()}
[IO.File]::Move($temporary,$Receipt,$true)
$script:receiptWritten=$true
} catch {Write-Warning 'Progress receipt could not be updated; cleanup must still run'}
Write-Host "Native SSH stage: $Stage ($timestamp)"
if(Write-WindowsSshReceiptStage $report $Receipt $Stage){$script:receiptWritten=$true}
}
Write-Stage 'preflight-start'
if(-not $script:receiptWritten){throw 'Initial progress receipt unavailable; refuse provisioning'}
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $target.os) { throw "Requires isolated native $Arch GitHub runner" }
$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $admin) { throw 'Administrative private service/account setup required' }
Assert-IsolatedWindowsSshCi $Arch
Write-Stage 'existing-server-query-start'
$inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH'
function Assert-GlobalServerDormant {
$global=Get-CimInstance Win32_Service -Filter "Name='sshd'"
if(-not $global){return}
# Inbox mode may register the global service; it must stay stopped and never be started here.
if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'}
}
Assert-GlobalServerDormant
Assert-WindowsSshGlobalServerDormant $Server
Write-Stage 'existing-server-query-complete'
Write-Stage 'default-shell-query-start'
$openSshKey = 'HKLM:\SOFTWARE\OpenSSH'
$registry = Get-ItemProperty -LiteralPath $openSshKey -ErrorAction SilentlyContinue
# Host-cell probes may set DefaultShell per cell; cleanup restores this stock state.
if ($registry.DefaultShell -or $registry.DefaultShellCommandOption) { throw 'Requires stock cmd.exe OpenSSH shell at start' }
Assert-WindowsSshStockShell
Write-Stage 'default-shell-query-complete'
if($InboxPreparationReceipt){
if($Server -ne 'inbox'){throw 'Inbox preparation receipt cannot qualify a preview server'}
$preparation=Get-Content -LiteralPath $InboxPreparationReceipt -Raw | ConvertFrom-Json
if($preparation.status -ne 'passed' -or $preparation.arch -ne $Arch -or $preparation.sourceSha -ne $env:GITHUB_SHA -or $preparation.runId -ne $env:GITHUB_RUN_ID -or $preparation.runAttempt -ne $env:GITHUB_RUN_ATTEMPT -or $preparation.runnerName -ne $env:RUNNER_NAME -or $preparation.imageVersion -ne $env:ImageVersion){throw 'Inbox preparation receipt identity or verdict mismatch'}
$report.inboxCapabilityPreparation=$preparation
}
$id = [Guid]::NewGuid().ToString('N').Substring(0,10)
$name = "orca$id"
$accountNames = @($name) + @(if($Accounts -gt 1){2..$Accounts | ForEach-Object {"$name$_"}})
@@ -180,12 +168,7 @@ try {
$report.nativeInputs=$verified
Write-Stage 'preview-native-input-verification-complete'
} else {
Write-Stage 'inbox-capability-start'
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0'
$report.inboxCapabilityInitialState=[string]$capability.State
if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null}
Assert-GlobalServerDormant
Write-Stage 'inbox-capability-complete'
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage}
$verified=@()
foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){
$path=Join-Path $sshDir $binary
@@ -431,21 +414,38 @@ LogLevel DEBUG1
foreach($directory in $deniedToolDirs){Invoke-Bounded icacls.exe (@($directory.TrimEnd('\'),'/remove:d')+@($ownedAccounts | ForEach-Object {"*$($_.sid)"})) 120 | Out-Null}
Write-Stage 'cleanup-toolchain-acl-complete'
Write-Stage 'cleanup-user-profile-start'
$profileTargets=@(foreach($account in $ownedAccounts){
if($account.sid){@{sid=$account.sid;watch=$null;done=$false;profiles=@();waitMs=0;disposition=$null}}
})
$report.profileCleanup=@()
foreach($account in $ownedAccounts){
if(-not $account.sid){continue}
$profileWait=[Diagnostics.Stopwatch]::StartNew()
do {
$profiles=@(Get-CimInstance Win32_UserProfile | Where-Object SID -eq $account.sid)
if(-not @($profiles | Where-Object Loaded).Count){break}
Start-Sleep -Milliseconds 500
} while($profileWait.Elapsed.TotalSeconds -lt 30)
$report.profileUnloadWaitMs=$profileWait.ElapsedMilliseconds
$report.privateProfile=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}})
Write-Stage 'cleanup-user-profile-observed'
$loadedProfiles=@($profiles | Where-Object Loaded)
$report.profileCleanup+=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'}
$profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance
$report.privateProfiles=@()
do {
foreach($entry in @($profileTargets | Where-Object {-not $_.done})){
if(-not $entry.watch){$entry.watch=[Diagnostics.Stopwatch]::StartNew()}
$profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'")
if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'}
if(@($profiles | Where-Object Loaded).Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue}
# A profile may reload after the polling snapshot.
$profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'")
if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'}
$loadedProfiles=@($profiles | Where-Object Loaded)
if($loadedProfiles.Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue}
$profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance
$entry.profiles=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}})
$entry.waitMs=$entry.watch.ElapsedMilliseconds
$entry.disposition=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'}
$entry.done=$true
$report.profileCleanup=@($profileTargets | Where-Object done | ForEach-Object disposition)
$report.privateProfiles=@($profileTargets | Where-Object done | ForEach-Object {@{sid=$_.sid;waitMs=$_.waitMs;profiles=$_.profiles;disposition=$_.disposition}})
$report.profileUnloadWaitMs=$entry.waitMs
$report.privateProfile=$entry.profiles
Write-Stage 'cleanup-user-profile-observed'
}
if(@($profileTargets | Where-Object {-not $_.done}).Count){Start-Sleep -Milliseconds 500}
} while(@($profileTargets | Where-Object {-not $_.done}).Count)
if($profileTargets.Count){
$report.profileUnloadWaitMs=$profileTargets[-1].waitMs
$report.privateProfile=$profileTargets[-1].profiles
}
Write-Stage 'cleanup-user-profile-complete'
Write-Stage 'cleanup-user-start'
@@ -33,5 +33,166 @@ try {
if(($result.hidden -join '|') -ne "$nodeDir|$gccDir"){throw 'Toolchain PATH entries not hidden'}
if(($result.kept -join '|') -ne "$plainDir|$(Join-Path $pathRoot 'missing')|Q:\no-such-drive"){throw 'Plain PATH entries not kept in order'}
} finally {Remove-Item -LiteralPath $pathRoot -Recurse -Force -ErrorAction SilentlyContinue}
# Mock only the machine boundary; exercise the shared installer without servicing this host.
. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')
function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}}
function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}}
function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}}
function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}}
function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}}
function Reset-CapabilityControl([string]$State){
$script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0
$script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false
$script:capabilityStages=[Collections.Generic.List[string]]::new()
}
foreach($state in @('Installed','NotPresent')){
Reset-CapabilityControl $state
$capabilityReport=@{}
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)}
$expectedAdds=if($state -eq 'Installed'){0}else{1}
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
}
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){
Reset-CapabilityControl 'NotPresent'
switch($fault){
'host' {$script:refuseCapabilityHost=$true}
'global-before' {$script:refuseGlobalCheck=1}
'shell-before' {$script:refuseShellCheck=1}
'global-after' {$script:refuseGlobalCheck=2}
'shell-after' {$script:refuseShellCheck=2}
'install' {$script:failCapabilityInstall=$true}
}
$rejected=$false
try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true}
if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"}
if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'}
}
$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $capabilityRoot | Out-Null
$capabilityReceipt=Join-Path $capabilityRoot 'capability.json'
Reset-CapabilityControl 'Installed'
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt
$completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'}
Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true
$rejected=$false
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
$failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'}
} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue}
& {
param($ProofAst)
$cleanup=@($ProofAst.FindAll({param($node) $node -is [Management.Automation.Language.TryStatementAst] -and $node.Body.Extent.Text.Contains("Write-Stage 'cleanup-start'")},$true))
if($cleanup.Count -ne 1 -or -not $cleanup[0].Extent.Text.Contains('[Diagnostics.Stopwatch]::StartNew()')){throw 'Cleanup clock boundary missing'}
# Run the real cleanup gates with virtual clocks; no Windows machine operation may escape these mocks.
$cleanupBlock=[scriptblock]::Create($cleanup[0].Extent.Text.Replace('[Diagnostics.Stopwatch]::StartNew()','(New-ProfileControlStopwatch)').Replace('[DateTime]::UtcNow','(Get-ProfileControlUtcNow)'))
$ownedSids=@('S-1-5-21-100-200-300-1001','S-1-5-21-100-200-300-1002','S-1-5-21-100-200-300-1003')
$foreignSid='S-1-5-21-100-200-300-9000';$control=@{}
function New-ProfileControlStopwatch {
$watch=[pscustomobject]@{StartedMilliseconds=$control.clockMs;Control=$control}
$watch | Add-Member ScriptProperty ElapsedMilliseconds {$this.Control.clockMs-$this.StartedMilliseconds}
return $watch
}
function Get-ProfileControlUtcNow {[DateTime]::new(2026,10,2,0,0,0,[DateTimeKind]::Utc).AddMilliseconds($control.clockMs)}
function Start-Sleep([int]$Milliseconds,[int]$Seconds){$control.clockMs+=$Milliseconds+1000*$Seconds}
function Write-Stage([string]$Stage){$control.stages.Add($Stage)}
function Record-PrivateServiceDiagnostics([switch]$AfterStop){}
function Test-Path([string]$LiteralPath){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected filesystem query'};return $false}
function Get-ItemProperty([string]$LiteralPath,[object]$ErrorAction){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected registry query'};return $null}
function Remove-ItemProperty {throw 'Unexpected registry mutation'}
function Stop-Service([string]$Name,[switch]$Force,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service stop'};if($control.case.Fault -ne 'server-exit'){$control.serverLive=$false}}
function Invoke-Bounded([string]$Program,[string[]]$Arguments){if($Program -ne 'sc.exe' -or ($Arguments -join '|') -ne 'delete|orca-sshd-control'){throw 'Unexpected native command'};if($control.case.Fault -ne 'service-absence'){$control.servicePresent=$false}}
function Get-Process([int]$Id,[object]$ErrorAction){if($Id -ne 100){throw 'Foreign process query'};if($control.serverLive){@{Id=100}}}
function Get-Service([string]$Name,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service query'};if($control.servicePresent){@{Name=$Name}}}
function Get-ProfileControlLoaded([string]$Sid){
$state=$control.profiles[$Sid]
if($state.Mode -eq 'late'){return $control.clockMs -lt $state.UnloadAtMs}
if($state.Mode -in @('reload','reload-at-deadline')){return $state.CurrentLoaded}
return $state.Mode -eq 'loaded'
}
function Get-CimInstance([Parameter(Position=0)][string]$ClassName,[string]$Filter){
if($ClassName -eq 'Win32_Service'){
if($Filter -ne "Name='orca-sshd-control'"){throw 'Foreign service query'}
if($control.servicePresent){@{PathName=$(if($control.case.Fault -eq 'service-identity'){'C:\foreign\sshd.exe'}else{'C:\fake\ossh-control\sshd.exe'});ProcessId=$(if($control.case.Fault -eq 'service-pid'){200}else{100})}};return
}
if($ClassName -eq 'Win32_Process'){
if($control.case.Fault -eq 'child-exit'){@{ExecutablePath='C:\fake\OpenSSH\session.exe';ProcessId=101;ParentProcessId=100;CreationDate=(Get-ProfileControlUtcNow)}};return
}
if($ClassName -ne 'Win32_UserProfile' -or $Filter -notmatch "^SID='(S-1-5-21-100-200-300-\d+)'$" -or $Matches[1] -notin $ownedSids){throw 'Profile query must target an owned SID'}
$sid=$Matches[1];$control.clockMs+=$control.case.QueryCostMs;$control.profileQueries[$sid]++
if($control.case.Fault -eq 'query' -and $sid -eq $ownedSids[1]){throw 'Injected profile query failure'}
if($control.case.Fault -eq 'foreign-result' -and $sid -eq $ownedSids[1]){[pscustomobject]@{SID=$foreignSid;Loaded=$false;Status=0};return}
$state=$control.profiles[$sid]
if($state.Mode -eq 'absent' -or $state.Deleted){return}
$loaded=Get-ProfileControlLoaded $sid
if($state.Mode -eq 'reload' -and $control.profileQueries[$sid] -eq 1){$loaded=$false;$state.CurrentLoaded=$true}
if($state.Mode -eq 'reload-at-deadline' -and $control.clockMs -ge 30000 -and -not $state.Reinjected){$loaded=$false;$state.CurrentLoaded=$true;$state.Reinjected=$true}
[pscustomobject]@{SID=$sid;Loaded=$loaded;Status=0}
}
function Remove-CimInstance {
param([Parameter(ValueFromPipeline=$true)][object]$InputObject)
process {
if($InputObject.SID -notin $ownedSids -or $InputObject.Loaded -or (Get-ProfileControlLoaded $InputObject.SID)){throw 'Unsafe profile deletion attempted'}
if($control.case.Fault -eq 'delete'){throw 'Injected profile deletion failure'}
$control.profiles[$InputObject.SID].Deleted=$true;$control.removed.Add($InputObject.SID)
}
}
function Get-LocalUser([string]$Name,[object]$ErrorAction){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account query'};if($control.users[$Name]){@{Name=$Name}}}
function Remove-LocalUser([string]$Name){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account deletion'};if($control.case.Fault -ne 'account'){$control.users[$Name]=$false}}
function Remove-Item([string]$LiteralPath,[switch]$Recurse,[switch]$Force){if($LiteralPath -ne 'C:\fake\ossh-control'){throw 'Foreign filesystem deletion'};if($control.case.Fault -eq 'keys'){throw 'Injected private key deletion failure'};$control.keysRemoved=$true}
function Assert-ProfileCleanup([hashtable]$Case){
$control.Clear();$control.case=$Case;$control.clockMs=0;$control.serverLive=$true;$control.servicePresent=$true;$control.keysRemoved=$false
$control.stages=[Collections.Generic.List[string]]::new();$control.removed=[Collections.Generic.List[string]]::new()
$control.profiles=@{};$control.users=@{};$control.profileQueries=@{}
$ownedAccounts=@(for($index=0;$index -lt $ownedSids.Count;$index++){
$sid=$ownedSids[$index];$control.profileQueries[$sid]=0
$control.profiles[$sid]=@{Mode=$Case.Modes[$index];UnloadAtMs=$Case.UnloadAtMs[$index];CurrentLoaded=$true;Deleted=$false}
$name="orca-control-$index";$control.users[$name]=$true;@{name=$name;sid=$(if($Case.MissingSid -and $index -eq 2){$null}else{$sid})}
})
$report=@{status='proof-passed-cleanup-pending'};$openSshKey='HKLM:\SOFTWARE\OpenSSH';$serviceName='orca-sshd-control'
$root='C:\fake\ossh-control';$createdService=$true;$ownedServerPid=100;$sshDir='C:\fake\OpenSSH';$preexisting=@();$deniedToolDirs=@()
& $cleanupBlock
if($Case.Fault){
if($report.status -ne 'failed' -or $report.cleanupError -ne $Case.Error -or $control.keysRemoved -or $control.stages.Contains('cleanup-private-files-complete')){throw "Cleanup fault did not fail at its gate: $($Case.Name)"}
if($Case.Fault -notin @('account','keys') -and @($control.users.Values | Where-Object {-not $_}).Count){throw "Failure bypassed account cleanup gate: $($Case.Name)"}
return
}
if($report.status -ne 'passed' -or @($control.users.Values | Where-Object {$_}).Count -or -not $control.keysRemoved){throw "Cleanup omitted account/key gates: $($Case.Name)"}
if(($control.removed.ToArray() | Sort-Object) -join ',' -ne (($Case.Removed | Sort-Object) -join ',')){throw "Wrong removed SIDs: $($Case.Name)"}
if(@($report.profileCleanup | Where-Object {$_ -eq 'Loaded profile retained for disposable CI VM destruction'}).Count -ne $Case.Retained){throw "Wrong retained disposition: $($Case.Name)"}
foreach($sid in $Case.FullWindow){
$observed=@($report.privateProfiles | Where-Object sid -eq $sid)
if($observed.Count -ne 1 -or $observed[0].waitMs -lt 30000){throw "Short profile observation window: $($Case.Name)"}
}
if($control.clockMs -gt $Case.MaxClockMs){throw "Profile windows were serialized: $($Case.Name)"}
if($Case.MissingSid -and $control.profileQueries[$ownedSids[2]]){throw 'Missing SID gained profile deletion authority'}
if($report.privateProfiles.Count -and ($report.profileUnloadWaitMs -ne $report.privateProfiles[-1].waitMs -or ($report.privateProfile | ConvertTo-Json -Compress) -ne ($report.privateProfiles[-1].profiles | ConvertTo-Json -Compress))){throw 'Legacy scalar observation lost owned-account order'}
}
$cases=@(
@{Name='three loaded full windows';Modes=@('loaded','loaded','loaded');Retained=3;Removed=@();FullWindow=$ownedSids},
@{Name='unload at 29999ms';Modes=@('late','unloaded','absent');UnloadAtMs=@(29999,0,0);Retained=0;Removed=$ownedSids[0..1];FullWindow=@($ownedSids[0])},
@{Name='reload before deletion';Modes=@('reload','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])},
@{Name='independent unloads';Modes=@('late','loaded','late');UnloadAtMs=@(5000,0,20000);Retained=1;Removed=@($ownedSids[0],$ownedSids[2]);FullWindow=@($ownedSids[1])},
@{Name='slow provider queries';Modes=@('loaded','loaded','loaded');QueryCostMs=200;Retained=3;Removed=@();FullWindow=$ownedSids;MaxClockMs=40000},
@{Name='reload at expired window';Modes=@('reload-at-deadline','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])},
@{Name='late unload honestly retained';Modes=@('loaded','loaded','late');UnloadAtMs=@(0,0,45000);Retained=3;Removed=@();FullWindow=$ownedSids},
@{Name='missing SID is skipped';Modes=@('unloaded','absent','unloaded');Retained=0;Removed=@($ownedSids[0]);MissingSid=$true}
)
foreach($case in $cases){
if(-not $case.UnloadAtMs){$case.UnloadAtMs=@(0,0,0)}
if(-not $case.MaxClockMs){$case.MaxClockMs=33000}
Assert-ProfileCleanup $case
}
$failures=@{
query='Injected profile query failure';delete='Injected profile deletion failure';'foreign-result'='Private profile query returned an unrelated SID'
'service-identity'='Private service identity changed; refuse stop';'service-pid'='Private service identity changed; refuse stop'
'server-exit'='Private sshd process still live; no PID-only kill attempted'
'service-absence'='Private service still registered';'child-exit'='Private SSH child processes remain; preserve files and discard ephemeral runner'
account='Private account still exists';keys='Injected private key deletion failure'
}
foreach($failure in $failures.GetEnumerator()){Assert-ProfileCleanup @{Name=$failure.Key;Fault=$failure.Key;Error=$failure.Value;Modes=@('unloaded','unloaded','unloaded');UnloadAtMs=@(0,0,0)}}
} $ast
# Extract functions through the AST: never provision the fixture while testing diagnostics.
'PASS: fixture parse, five numeric-diagnostic cases and the toolchain PATH split'
'PASS: fixture parse, diagnostics, PATH split, capability boundaries, profile windows and cleanup failure gates'
@@ -0,0 +1,61 @@
function Assert-IsolatedWindowsSshCi([ValidateSet('arm64','x64')][string]$Arch) {
$os=@{arm64='Arm64';x64='X64'}[$Arch]
if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $os){throw "Requires isolated native $Arch GitHub runner"}
$admin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if(-not $admin){throw 'Administrative private service/account setup required'}
}
function Assert-WindowsSshGlobalServerDormant([ValidateSet('preview','inbox')][string]$Server) {
$global=Get-CimInstance Win32_Service -Filter "Name='sshd'"
if(-not $global){return}
$inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH'
# Inbox installation may register the global service; it must never be started here.
if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'}
}
function Assert-WindowsSshStockShell {
$registry=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue
if($registry.DefaultShell -or $registry.DefaultShellCommandOption){throw 'Requires stock cmd.exe OpenSSH shell at start'}
}
function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[string]$Stage) {
$timestamp=[DateTime]::UtcNow.ToString('o')
$Report.stages+=@{stage=$Stage;utc=$timestamp}
try {
$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Report | ConvertTo-Json -Depth 6))
$temporary="$Receipt.pending"
$stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read)
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()}
[IO.File]::Move($temporary,$Receipt,$true)
$written=$true
} catch {$written=$false;Write-Warning 'Progress receipt could not be updated; cleanup must still run'}
Write-Host "Native SSH stage: $Stage ($timestamp)"
return $written
}
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) {
Assert-IsolatedWindowsSshCi $Arch
Assert-WindowsSshGlobalServerDormant 'inbox'
Assert-WindowsSshStockShell
& $Stage 'inbox-capability-start'
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0'
$Report.inboxCapabilityInitialState=[string]$capability.State
if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null}
Assert-WindowsSshGlobalServerDormant 'inbox'
Assert-WindowsSshStockShell
& $Stage 'inbox-capability-complete'
}
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) {
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
try {
if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'}
Install-WindowsInboxSshCapability $Arch $report {param($name)
if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'}
}
$report.status='passed'
} catch {$report.status='failed';$report.error=$_.Exception.Message;throw}
finally {
if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-finished')){throw 'Final capability preparation receipt unavailable'}
}
}
+174
View File
@@ -0,0 +1,174 @@
// Builds and publishes the agent state rules bundle (agent-state-rules.json). The app's loader
// validates the same file (src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts), and
// agent-state-rules-bundle.test.mjs proves the build passes it; this script only assembles the
// file and enforces the publishing rules a single file cannot express.
//
// node config/scripts/agent-state-rules-bundle.mjs build <out> [--bundled-only]
// node config/scripts/agent-state-rules-bundle.mjs publish-next [--bundled-only]
// node config/scripts/agent-state-rules-bundle.mjs promote-stable
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { agentStateRulesTag } from './release-tag-patterns.mjs'
const RULES_DIR = join(import.meta.dirname, '..', '..', 'src/main/runtime/agent-state-rules')
export const AGENT_STATE_RULES_ASSET = 'agent-state-rules.json'
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
/** The live-updatable agents' files, in release order, under the release's version. */
export function buildAgentStateRulesBundle({ bundledOnly = false } = {}) {
const release = readJson(join(RULES_DIR, 'agent-state-rules-release.json'))
const files = release.liveUpdatable.map((id) => readJson(join(RULES_DIR, `${id}.json`)))
const bundle = {
version: release.version,
engineVersion: files[0].engineVersion,
...(bundledOnly ? { bundledOnly: true } : {}),
files
}
return `${JSON.stringify(bundle, null, 2)}\n`
}
function runGh(args) {
const result = spawnSync('gh', args, { encoding: 'utf8' })
return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
}
function ghOrThrow(gh, args) {
const result = gh(args)
if (result.status !== 0) {
throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`)
}
return result.stdout
}
/** The file on `tag`'s release, or null when there is no release yet. */
function downloadPublished(gh, repo, tag) {
const args = [
'release',
'download',
tag,
'--repo',
repo,
'-p',
AGENT_STATE_RULES_ASSET,
'-O',
'-'
]
const result = gh(args)
if (result.status === 0) {
return result.stdout
}
// Why throw on anything else, a missing asset included: that means an earlier upload broke.
if (/release not found/i.test(result.stderr)) {
return null
}
throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`)
}
/**
* Puts `text` on the channel's release for its engine, as a prerelease that never becomes Latest
* (the app updater follows Latest). `gh` is injectable so the sequence is testable.
*/
export function publishAgentStateRules({ repo, channel, text, target, gh = runGh }) {
const candidate = JSON.parse(text)
const tag = agentStateRulesTag(candidate.engineVersion, channel)
const publishedText = downloadPublished(gh, repo, tag)
// Why strictly higher: apps refuse a version they already have, so republishing one would reach
// nobody, and a lower one would reach only apps that never took the higher.
const published = publishedText === null ? null : JSON.parse(publishedText).version
if (published !== null && candidate.version <= published) {
throw new Error(
`version ${candidate.version} is not higher than the published ${published}; bump agent-state-rules-release.json`
)
}
const file = join(mkdtempSync(join(tmpdir(), 'agent-state-rules-')), AGENT_STATE_RULES_ASSET)
writeFileSync(file, text)
if (published === null) {
ghOrThrow(gh, [
'release',
'create',
tag,
file,
'--repo',
repo,
'--target',
target,
'--prerelease',
'--latest=false',
'--title',
`Agent state rules (${channel})`,
'--notes',
'Agent state rules for Orca. Running apps download this file; it is not an app release.'
])
} else {
// Why --clobber on the same tag: the app's fixed URL stays valid, and a fetch that lands
// mid-upload gets a 404 and keeps its last good copy.
ghOrThrow(gh, ['release', 'upload', tag, file, '--repo', repo, '--clobber'])
}
return { tag, version: candidate.version }
}
/**
* Why no rebuild and no re-gate: stable gets exactly the bytes RC and dev builds soaked on next,
* which only the gated publish-next job writes. A bundledOnly next is promoted too: that is how
* stable rolls back to the rules it shipped.
*/
export function promoteAgentStateRules({ repo, engineVersion, target, gh = runGh }) {
const nextTag = agentStateRulesTag(engineVersion, 'next')
const text = downloadPublished(gh, repo, nextTag)
if (text === null) {
throw new Error(`${nextTag} has not been published`)
}
return publishAgentStateRules({ repo, channel: 'stable', text, target, gh })
}
function requireEnv(name) {
const value = process.env[name]
if (!value) {
throw new Error(`${name} is not set`)
}
return value
}
function main([command, ...args]) {
const bundledOnly = args.includes('--bundled-only')
switch (command) {
case 'build': {
const out = args.find((arg) => !arg.startsWith('--'))
if (!out) {
throw new Error('usage: build <out> [--bundled-only]')
}
writeFileSync(out, buildAgentStateRulesBundle({ bundledOnly }))
return
}
case 'publish-next':
case 'promote-stable': {
const repo = requireEnv('GITHUB_REPOSITORY')
const target = requireEnv('GITHUB_SHA')
const text = buildAgentStateRulesBundle({ bundledOnly })
const { tag, version } =
command === 'publish-next'
? publishAgentStateRules({ repo, channel: 'next', text, target })
: promoteAgentStateRules({ repo, engineVersion: JSON.parse(text).engineVersion, target })
console.log(`Published version ${version} to ${tag}.`)
return
}
default:
throw new Error(`unknown command ${command ?? '(none)'}`)
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
try {
main(process.argv.slice(2))
} catch (error) {
console.error(`::error::${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
}
@@ -0,0 +1,179 @@
// The rules-release gate: the bundle a release would publish validates in the app's own loader,
// carries only agents whose transcripts the census replays, and only the protected workflow can
// publish it.
import { readdirSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import {
BUNDLED_AGENT_STATE_RULES_VERSION,
LIVE_UPDATABLE_AGENT_STATE_RULE_IDS,
parseAgentStateRulesBundle
} from '../../src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts'
import { BUNDLED_AGENT_STATE_RULE_FILES } from '../../src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts'
import { agentStateRulesDownloadUrl } from '../../src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts'
import {
AGENT_STATE_RULES_ENGINE_VERSION,
UNKNOWN_PANE_RULES_ID
} from '../../src/main/runtime/agent-state-rules/agent-state-rules-schema.ts'
import { CENSUS_TRANSCRIPTS } from '../../src/main/runtime/readiness-census-transcript-catalog.ts'
import {
AGENT_STATE_RULES_ASSET,
buildAgentStateRulesBundle,
promoteAgentStateRules,
publishAgentStateRules
} from './agent-state-rules-bundle.mjs'
import { agentStateRulesTag } from './release-tag-patterns.mjs'
const REPO = 'stablyai/orca'
const NEXT = agentStateRulesTag(1, 'next')
const STABLE = agentStateRulesTag(1, 'stable')
describe('agent state rules bundle build', () => {
it('builds a bundle the app accepts, under the bundled version and engine', () => {
const text = buildAgentStateRulesBundle()
const parsed = parseAgentStateRulesBundle(text, 'live-updatable')
expect(parsed.ok).toBe(true)
const bundle = JSON.parse(text)
expect(bundle.engineVersion).toBe(AGENT_STATE_RULES_ENGINE_VERSION)
expect(bundle.version).toBe(BUNDLED_AGENT_STATE_RULES_VERSION)
expect(bundle.files).toEqual(
BUNDLED_AGENT_STATE_RULE_FILES.filter((file) =>
LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id)
)
)
expect(bundle.bundledOnly).toBeUndefined()
expect(JSON.parse(buildAgentStateRulesBundle({ bundledOnly: true })).bundledOnly).toBe(true)
})
it('lets a rules release change only agents the readiness census replays', () => {
const replayed = new Set(CENSUS_TRANSCRIPTS.flatMap((transcript) => transcript.agent ?? []))
// Why unknown-pane: the census replays every recording on an agent-unknown pane too.
replayed.add(UNKNOWN_PANE_RULES_ID)
expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !replayed.has(id))).toEqual([])
})
it('publishes to the exact URL the app fetches', () => {
for (const channel of ['next', 'stable']) {
expect(agentStateRulesDownloadUrl(channel)).toBe(
`https://github.com/${REPO}/releases/download/${agentStateRulesTag(AGENT_STATE_RULES_ENGINE_VERSION, channel)}/${AGENT_STATE_RULES_ASSET}`
)
}
})
})
/** A `gh` stand-in over an in-memory set of releases, recording each call. */
function fakeGh(releases = {}) {
const calls = []
const gh = (args) => {
calls.push(args)
const [, verb, tag] = args
if (verb === 'download') {
return tag in releases
? { status: 0, stdout: releases[tag], stderr: '' }
: { status: 1, stdout: '', stderr: 'release not found' }
}
if (verb === 'upload' || verb === 'create') {
expect(args[3].endsWith(`/${AGENT_STATE_RULES_ASSET}`)).toBe(true)
releases[tag] = readFileSync(args[3], 'utf8')
}
return { status: 0, stdout: '', stderr: '' }
}
return { gh, calls, releases }
}
const at = (version, engineVersion = 1) =>
`${JSON.stringify({ version, engineVersion, files: [] })}\n`
describe('publishAgentStateRules', () => {
it("creates the engine's channel release as a prerelease that can never be Latest", () => {
const fake = fakeGh()
expect(
publishAgentStateRules({
repo: REPO,
channel: 'next',
text: at(2),
target: 'abc',
gh: fake.gh
})
).toEqual({ tag: NEXT, version: 2 })
expect(fake.calls.find((args) => args[1] === 'create')).toEqual(
expect.arrayContaining([NEXT, '--prerelease', '--latest=false', '--target', 'abc'])
)
expect(fake.releases[NEXT]).toBe(at(2))
})
it('replaces the asset in place on an existing release, keeping the tag', () => {
const fake = fakeGh({ [NEXT]: at(1) })
publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh: fake.gh })
expect(fake.calls.map((args) => args[1])).toEqual(['download', 'upload'])
expect(fake.calls[1]).toContain('--clobber')
expect(fake.releases[NEXT]).toBe(at(2))
})
it.each([1, 2])('refuses version %s over a published 2, uploading nothing', (version) => {
const fake = fakeGh({ [NEXT]: at(2) })
expect(() =>
publishAgentStateRules({
repo: REPO,
channel: 'next',
text: at(version),
target: 'abc',
gh: fake.gh
})
).toThrow('bump agent-state-rules-release.json')
expect(fake.calls.map((args) => args[1])).toEqual(['download'])
})
it('fails when the release exists but its download fails', () => {
const gh = () => ({ status: 1, stdout: '', stderr: 'no assets match the file pattern' })
expect(() =>
publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh })
).toThrow('no assets match')
})
it('promotes the identical next bytes to stable', () => {
const nextText = JSON.stringify({ version: 3, engineVersion: 1, files: [] }, null, 2)
const fake = fakeGh({ [NEXT]: nextText, [STABLE]: at(2) })
promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh })
expect(fake.releases[STABLE]).toBe(nextText)
})
it('refuses to promote before next exists', () => {
const fake = fakeGh()
expect(() =>
promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh })
).toThrow('has not been published')
})
})
describe('agent state rules workflows', () => {
const read = (name) => parse(readFileSync(`.github/workflows/${name}`, 'utf8'))
const publish = read('agent-state-rules-publish.yml')
it('publishes only on manual dispatch from main, in the protected environment', () => {
expect(Object.keys(publish.on)).toEqual(['workflow_dispatch'])
for (const name of ['publish-next', 'promote-stable']) {
const job = publish.jobs[name]
expect(job.environment).toBe('agent-state-rules')
expect(job.permissions).toEqual({ contents: 'write' })
}
expect(publish.permissions).toEqual({ contents: 'read' })
expect(publish.jobs.gate.if).toContain("github.ref == 'refs/heads/main'")
expect(publish.jobs['promote-stable'].if).toContain("github.ref == 'refs/heads/main'")
expect(publish.jobs['publish-next'].needs).toBe('gate')
// Why: every job must check out the dispatched commit, so publish runs what the gate tested.
const checkouts = Object.values(publish.jobs).flatMap((job) =>
job.steps.filter((step) => step.uses?.startsWith('actions/checkout'))
)
expect(checkouts.map((step) => step.with?.ref)).toEqual([undefined, undefined, undefined])
})
it('is the only workflow that publishes rules releases', () => {
const publishers = readdirSync('.github/workflows').filter((name) =>
/agent-state-rules-bundle\.mjs (?:publish|promote)|release create agent-state-rules/.test(
readFileSync(`.github/workflows/${name}`, 'utf8')
)
)
expect(publishers).toEqual(['agent-state-rules-publish.yml'])
})
})
+13 -1
View File
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs'
export function windowsCliLauncherFingerprint(inputPaths, version) {
const hash = createHash('sha256').update(version)
@@ -66,6 +67,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
join(crateRoot, 'build.rs'),
manifestPath,
join(crateRoot, 'app.manifest'),
join(crateRoot, '.cargo', 'config.toml'),
iconPath,
join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs')
],
@@ -117,6 +119,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(result.status ?? 1)
}
copyFileSync(join(targetDirectory, 'release', 'orca.exe'), outputPath)
const builtPath = join(targetDirectory, 'release', 'orca.exe')
const vcRuntimeImports = findDynamicVcRuntimeImports(
readPeImportedDllNames(readFileSync(builtPath))
)
if (vcRuntimeImports.length > 0) {
// Why fatal: those DLLs ship with the Visual C++ Redistributable, so the CLI would fail to start on a clean Windows install.
throw new Error(
`orca.exe imports ${vcRuntimeImports.join(', ')}; the C runtime must be linked statically (native/windows-cli-launcher/.cargo/config.toml).`
)
}
copyFileSync(builtPath, outputPath)
writeFileSync(`${outputPath}.sha256`, fingerprint)
}
@@ -17,6 +17,7 @@ import {
windowsCliLauncherFileVersion,
windowsCliLauncherFingerprint
} from './build-windows-cli-launcher.mjs'
import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -131,6 +132,9 @@ describe('Windows CLI launcher', () => {
expect(info.ProductVersion).toBe(version)
const binary = readFileSync(launcherPath)
expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true)
const imports = readPeImportedDllNames(binary)
expect(imports.map((name) => name.toLowerCase())).toContain('kernel32.dll')
expect(findDynamicVcRuntimeImports(imports)).toEqual([])
const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico'))
const imageSize = icon.readUInt32LE(14)
const imageOffset = icon.readUInt32LE(18)
@@ -72,12 +72,12 @@ it('bounds warming to the required platforms and validates changes without grant
expect(workflow.permissions).toEqual({ contents: 'read' })
expect(workflow.on.push.branches).toEqual(['main'])
expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**')
expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }])
expect(workflow.on.schedule).toEqual([{ cron: '41 */6 * * *' }])
expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml')
expect(steps[0].with['persist-credentials']).toBe(false)
})
it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => {
it('lets scheduled warmers wait while pushes, PR updates, and manual runs can replace active work', () => {
expect(workflow.concurrency).toEqual({
group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}',
'cancel-in-progress': "${{ github.event_name != 'schedule' }}"
+2 -2
View File
@@ -123,8 +123,8 @@ describe('CI native cache ownership', () => {
installerPath,
Buffer.from(
readFileSync(installerPath, 'utf8').replace(
"inputs.cache-pnpm-verification == 'true'",
"inputs.cache-pnpm-verification == 'false'"
'enabled: ${{ inputs.cache-pnpm-verification }}',
"enabled: 'false'"
)
)
)
@@ -1,21 +1,116 @@
import { readFileSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { classifyPrJobs, PR_CHECK_JOBS } from './pr-code-change-scope.mjs'
import { runProcessSync } from './script-child-process.mjs'
const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
const steps = action.runs.steps
const resolve = steps.find((step) => step.id === 'verification-cache')
const restore = steps.find((step) => step.id === 'verification-cache-restore')
const cache = steps.find((step) => step.id === 'verification-cache')
const cacheAction = parse(
readFileSync('.github/actions/restore-pnpm-verification/action.yml', 'utf8')
)
const resolve = cacheAction.runs.steps.find((step) => step.id === 'verification-cache')
const restore = cacheAction.runs.steps.find((step) => step.id === 'verification-cache-restore')
const install = steps.find((step) => step.name === 'Install dependencies')
const save = steps.find((step) => step.name === 'Save pnpm verification record on main')
const evaluate = (expression, context) =>
runInNewContext(
expression.replaceAll('inputs.cache-pnpm-verification', 'inputs["cache-pnpm-verification"]'),
context
)
const evaluate = (expression, context) => runInNewContext(expression, context)
function resolveRecord({
container = false,
os = 'Linux',
packageManager = 'pnpm@12.8.1+sha512.test'
} = {}) {
const root = mkdtempSync(join(tmpdir(), 'orca-verification-record-'))
try {
const output = join(root, 'outputs')
const calls = join(root, 'pnpm-calls')
const cacheRoot = join(root, 'cache with spaces')
const cachePath = join(cacheRoot, 'pnpm')
writeFileSync(join(root, 'package.json'), JSON.stringify({ packageManager }))
const execution = runProcessSync({
program: 'bash',
args: [
'-e',
'-o',
'pipefail',
'-c',
`
pnpm() {
printf '%s\\n' "$*" >> "$TEST_CALLS"
case "$*" in
'cache path') printf '%s\\n' "$TEST_CACHE_PATH" ;;
'--version') printf '%s\\n' '12.8.1' ;;
*) return 2 ;;
esac
}
${resolve.run}
`
],
cwd: root,
env: {
...process.env,
RUNNER_OS: os,
RUNNER_ARCH: 'ARM64',
POLICY_HASH: 'policy-digest',
GITHUB_OUTPUT: output,
TEST_CALLS: calls,
TEST_CACHE_PATH: cachePath,
CONTAINER_TOOLCHAIN: String(container),
XDG_CACHE_HOME: cacheRoot
}
})
return {
code: execution.code,
output: existsSync(output) ? readFileSync(output, 'utf8').replaceAll(root, '<root>') : '',
calls: existsSync(calls) ? readFileSync(calls, 'utf8') : ''
}
} finally {
rmSync(root, { recursive: true, force: true })
}
}
describe('pnpm-owned verification record', () => {
it('qualifies every shared-installer consumer when verification restoration changes', () => {
const result = classifyPrJobs(['.github/actions/restore-pnpm-verification/action.yml'])
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
})
it.skipIf(process.platform === 'win32')(
'shares the exact key and archive path with containers without invoking host pnpm',
() => {
const host = resolveRecord()
const container = resolveRecord({ container: true })
expect(host.code).toBe(0)
expect(container.code).toBe(0)
expect(container.output).toBe(host.output)
expect(container.output).toContain(
'path=<root>/cache with spaces/pnpm/lockfile-verified.jsonl'
)
expect(container.output).toContain(
'key=pnpm-verification-v1-Linux-ARM64-12.8.1-policy-digest'
)
expect(host.calls).toBe('cache path\n--version\n')
expect(container.calls).toBe('')
}
)
it.skipIf(process.platform === 'win32').each([
{ container: true, os: 'Windows' },
{ container: true, os: 'macOS' },
{ container: true, packageManager: 'npm@12.8.1' }
])('rejects an unsupported container identity: %j', (options) => {
const result = resolveRecord(options)
expect(result.code).not.toBe(0)
expect(result.output).toBe('')
expect(result.calls).toBe('')
})
it.each([
['Linux', 'X64', true],
['Linux', 'ARM64', true],
@@ -31,7 +126,7 @@ describe('pnpm-owned verification record', () => {
expect(
evaluate(resolve.if, {
runner: { os, arch },
inputs: { 'cache-pnpm-verification': enabled }
inputs: { enabled }
})
).toBe(expected && enabled === 'true')
}
@@ -44,12 +139,15 @@ describe('pnpm-owned verification record', () => {
expect(resolve.run).toContain('"$(pnpm cache path)"')
expect(resolve.run).toContain('lockfile-verified.jsonl')
expect(resolve.run).toContain('pnpm-verification-v1-%s-%s-%s-%s')
expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH"')
expect(resolve.run).toContain('pnpm_version="$(pnpm --version)"')
expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH"')
expect(cache.uses).toBe('./.github/actions/restore-pnpm-verification')
expect(cache.with.enabled).toBe('${{ inputs.cache-pnpm-verification }}')
expect(restore.uses).toBe('actions/cache/restore@v5')
expect(restore.with.path).toBe('${{ steps.verification-cache.outputs.path }}')
expect(restore.with['restore-keys']).toBeUndefined()
expect(restore['continue-on-error']).toBe(true)
expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(install))
expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(install))
expect(install.if).toBeUndefined()
expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts')
})
@@ -64,21 +162,20 @@ describe('pnpm-owned verification record', () => {
const context = {
github: { event_name: event, ref },
steps: {
'verification-cache': { outputs: { key: 'a-key' } },
'verification-cache-restore': { outputs: { 'cache-hit': 'false' } }
'verification-cache': { outputs: { key: 'a-key', 'cache-hit': 'false' } }
}
}
const expression = save.if
.replaceAll(
'steps.verification-cache-restore.outputs.cache-hit',
'steps["verification-cache-restore"].outputs["cache-hit"]'
'steps.verification-cache.outputs.cache-hit',
'steps["verification-cache"].outputs["cache-hit"]'
)
.replaceAll('steps.verification-cache.outputs.key', 'steps["verification-cache"].outputs.key')
expect(evaluate(expression, context)).toBe(expected)
context.steps['verification-cache'].outputs.key = ''
expect(evaluate(expression, context)).toBe(false)
context.steps['verification-cache'].outputs.key = 'a-key'
context.steps['verification-cache-restore'].outputs['cache-hit'] = 'true'
context.steps['verification-cache'].outputs['cache-hit'] = 'true'
expect(evaluate(expression, context)).toBe(false)
expect(save.uses).toBe('actions/cache/save@v5')
expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(install))
+17 -9
View File
@@ -1,12 +1,6 @@
import { build } from 'esbuild'
import { appendFileSync, globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
externalNativeAddons,
ORCAD_CHILD_ENTRY_POINTS,
ORCAD_ENTRY_POINT
} from './orcad-entry-build.mjs'
import { nodeServerTestPaths } from './node-server-test-paths.mjs'
import { nodeServerQualification } from './node-server-qualification.mjs'
@@ -42,6 +36,7 @@ const ALWAYS_FILES = new Set([
])
const ALWAYS_PREFIXES = [
'.github/actions/install-node-dependencies/',
'.github/actions/restore-pnpm-verification/',
'.github/actions/prepare-native-runtime/',
'.github/actions/prepare-orcad-prebuilds/',
// These areas also contain worker paths and fixtures opened without an import.
@@ -68,6 +63,8 @@ export function discoverNodeServerTests(root = ROOT) {
}
export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) {
const [{ build }, { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT }] =
await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')])
const entries = entryPoints ?? [
ORCAD_ENTRY_POINT,
...Object.values(ORCAD_CHILD_ENTRY_POINTS),
@@ -99,7 +96,11 @@ export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {})
)
}
export async function classifyNodeServerChanges(changedFiles, collect = collectNodeServerInputs) {
export async function classifyNodeServerChanges(
changedFiles,
collect = collectNodeServerInputs,
{ deferGraph = false } = {}
) {
if (changedFiles.length === 0) {
return { shouldRun: true, reason: 'No complete changed-file evidence' }
}
@@ -113,6 +114,9 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN
if (forced) {
return { shouldRun: true, reason: `Build or CI input changed: ${forced}` }
}
if (deferGraph) {
return { graphRequired: true, reason: 'Installed dependencies are needed to check imports' }
}
try {
const inputs = await collect()
const matched = changedFiles.find((file) => inputs.has(file))
@@ -133,12 +137,16 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean)
const result = await classifyNodeServerChanges(changedFiles)
const result = await classifyNodeServerChanges(changedFiles, collectNodeServerInputs, {
deferGraph: process.argv.includes('--defer-graph')
})
console.log(result.reason)
const policy = nodeServerQualification(changedFiles, result, {
fullQualification: process.argv.includes('--full-qualification')
})
const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
const output = result.graphRequired
? 'graph_required=true\n'
: `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, output)
} else {
@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
@@ -11,6 +11,8 @@ import {
import { nodeServerTestPaths } from './node-server-test-paths.mjs'
import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs'
import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import { runProcessSync } from './script-child-process.mjs'
import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs'
const temporaryDirs = []
afterEach(() => {
@@ -83,6 +85,7 @@ it.each([
'config/patches/node-pty@1.1.0.patch',
'native/windows-registry/src/addon.cc',
'.github/actions/install-node-dependencies/action.yml',
'.github/actions/restore-pnpm-verification/action.yml',
'.github/actions/prepare-native-runtime/action.yml',
'.github/actions/prepare-orcad-prebuilds/action.yml',
'.github/workflows/node-server-tests.yml',
@@ -91,6 +94,80 @@ it.each([
expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true)
})
it('defers uncertain paths without issuing a qualification verdict', async () => {
const result = await classifyNodeServerChanges(
['src/renderer/src/example.ts'],
async () => {
throw new Error('graph must not run before installation')
},
{ deferGraph: true }
)
expect(result.graphRequired).toBe(true)
expect(result.shouldRun).toBeUndefined()
})
it.each([
{ files: [], deferred: true, expected: 'should_run=true' },
{ files: ['package.json'], deferred: true, expected: 'should_run=true' },
{
files: ['src/main/persistence/profile-state/profile-state-windows.ts'],
deferred: true,
fullQualification: false,
qualification: false,
runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'],
expected: 'should_run=true'
},
{
files: ['src/main/persistence/profile-state/profile-state-windows.ts'],
deferred: true,
expected: 'should_run=true'
},
{
files: ['src/main/providers/provider-windows.ts'],
deferred: true,
fullQualification: false,
expected: 'should_run=true'
},
{ files: ['src/renderer/src/example.ts'], deferred: true, expected: 'graph_required=true' },
{ files: ['src/renderer/src/example.ts'], deferred: false, expected: 'should_run=true' }
])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => {
const root = moduleTree(
Object.fromEntries(
['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map(
(name) => [
`config/scripts/${name}.mjs`,
readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8')
]
)
)
)
const changes = join(root, 'changes')
const stepOutput = join(root, 'step-output')
writeFileSync(changes, scenario.files.map((file) => `${file}\0`).join(''))
const result = runProcessSync({
program: process.execPath,
args: [
realpathSync(join(root, 'config/scripts/node-server-change-scope.mjs')),
changes,
...(scenario.fullQualification === false ? [] : ['--full-qualification']),
...(scenario.deferred ? ['--defer-graph'] : [])
],
cwd: root,
env: { ...process.env, GITHUB_OUTPUT: stepOutput },
timeoutMs: 5_000
})
expect(result.code).toBe(0)
const output = readFileSync(stepOutput, 'utf8')
expect(output).toContain(scenario.expected)
if (scenario.expected === 'graph_required=true') {
expect(output).not.toContain('should_run=')
expect(output).not.toContain('runners=')
} else {
expect(output).toContain(`qualification=${scenario.qualification !== false}`)
expect(output).toContain(`runners=${JSON.stringify(scenario.runners ?? NODE_SERVER_RUNNERS)}`)
}
})
describe('the actual Bun build and profile-test dependency graph', () => {
let inputs
beforeAll(async () => {
@@ -149,7 +226,7 @@ it('keeps every platform job and runs them when detection is skipped or fails',
expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}')
expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"')
expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD')
expect(detect.run).toContain('node-server-changes" --full-qualification')
expect(detect.run).toContain('node-server-changes" --defer-graph --full-qualification')
expect(workflow.on.pull_request.types).toContain('ready_for_review')
expect(workflow.on.schedule).toHaveLength(1)
// A pull request may qualify one platform, so the merged commit must re-qualify all six.
@@ -12,6 +12,7 @@ const BUILD_PREFIXES = [
'native/',
'config/patches/',
'.github/actions/install-node-dependencies/',
'.github/actions/restore-pnpm-verification/',
'.github/actions/prepare-native-runtime/',
'.github/actions/prepare-orcad-prebuilds/'
]
@@ -27,6 +27,7 @@ it.each([
'native/windows-registry/src/addon.cc',
'config/patches/node-pty.patch',
'.github/actions/install-node-dependencies/action.yml',
'.github/actions/restore-pnpm-verification/action.yml',
'.github/actions/prepare-native-runtime/action.yml',
'.github/actions/prepare-orcad-prebuilds/action.yml',
'src/main/ssh/ssh-provider.ts',
@@ -123,6 +123,65 @@ describe('orcad template release wiring (design D2)', () => {
expect(releaseMac.permissions.actions).toBe('read')
})
it('skips the template only for a tag that predates it', () => {
const cutSteps = releaseCut.jobs.cut.steps
const push = stepIndex(cutSteps, (step) => step.name === 'Push tag')
const detect = stepIndex(cutSteps, (step) => step.id === 'orcad-template-support')
expect(detect).toBeGreaterThan(push)
expect(cutSteps[detect].run).toContain(':config/scripts/packaged-orcad-template.cjs"')
expect(releaseCut.jobs.cut.outputs.ships_orcad_template).toBe(
'${{ steps.orcad-template-support.outputs.ships }}'
)
expect(releaseCut.jobs['orcad-template'].if).toContain(
"needs.cut.outputs.ships_orcad_template == 'true'"
)
for (const name of ['build', 'build-mac']) {
const condition = releaseCut.jobs[name].if
// Every other dependency still has to succeed, as under the implicit success().
for (const need of releaseCut.jobs[name].needs.filter((need) => need !== 'orcad-template')) {
expect(condition).toContain(`needs.${need}.result == 'success'`)
}
expect(condition).toContain("needs.orcad-template.result == 'success'")
expect(condition).toContain(
"(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')"
)
}
const buildSteps = releaseCut.jobs.build.steps
for (const step of [
buildSteps.find((step) => step.name === 'Download the orcad deployment template'),
buildSteps.find((step) => step.id === 'reseal-orcad-template')
]) {
expect(step.if).toContain("needs.cut.outputs.ships_orcad_template == 'true'")
}
const macDownload = releaseMac.jobs['build-mac'].steps.find(
(step) => step.with?.name === 'orcad-template'
)
expect(macDownload.if).toBe("hashFiles('config/scripts/packaged-orcad-template.cjs') != ''")
})
it('keeps every job downstream of the template from inheriting its skip', () => {
const needsOf = (name) => [releaseCut.jobs[name].needs ?? []].flat()
const dependsOnTemplate = (name) =>
needsOf(name).some((need) => need === 'orcad-template' || dependsOnTemplate(need))
const downstream = Object.keys(releaseCut.jobs).filter(dependsOnTemplate)
expect(downstream).toEqual(
expect.arrayContaining(['build', 'build-mac', 'publish-release', 'homebrew-bump'])
)
for (const name of downstream) {
// A skipped ancestor skips the job under the implicit success() that any `if` without a
// status function gets, so each one must override it and check its own needs instead.
const condition = releaseCut.jobs[name].if
expect(condition, name).toContain('!cancelled()')
for (const need of needsOf(name).filter(
(need) => need !== 'orcad-template' && need !== 'cut'
)) {
expect(condition, `${name} -> ${need}`).toContain(`needs.${need}.result == 'success'`)
}
}
})
it('signs only Windows template binaries and reseals the manifest before the installer rebuild', () => {
const steps = releaseCut.jobs.build.steps
const stage = steps.find((step) => step.id === 'stage-inner')
+1
View File
@@ -38,6 +38,7 @@ const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
const GLOBAL_FORCE_PREFIXES = [
'.github/workflows/pr.yml',
'.github/actions/install-node-dependencies/',
'.github/actions/restore-pnpm-verification/',
'.github/actions/prepare-native-runtime/',
'config/scripts/pr-code-change-scope'
]
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { countReleaseDownloads } from '../../.github/scripts/render-readme-downloads-badge.mjs'
const asset = (downloads) => ({ download_count: downloads })
describe('README downloads badge', () => {
it('counts published app releases and skips drafts and agent state rules releases', () => {
expect(
countReleaseDownloads([
{ tag_name: 'v1.4.1', draft: false, assets: [asset(10), asset(5)] },
{ tag_name: 'v1.4.2-rc.0', draft: false, assets: [asset(3)] },
{ tag_name: 'mobile-v0.0.1', draft: false, assets: [asset(2)] },
{ tag_name: 'v1.4.3', draft: true, assets: [asset(100)] },
{ tag_name: 'agent-state-rules-engine-1-next', draft: false, assets: [asset(9000)] },
{ tag_name: 'agent-state-rules-engine-1-stable', draft: false, assets: [asset(9000)] }
])
).toBe(20)
})
})
@@ -28,7 +28,9 @@ describe('release E2E dispatch contract', () => {
expect(releaseWorkflow.jobs.e2e).toBeUndefined()
expect(dispatchJob.needs).toEqual(['cut', 'publish-release'])
expect(dispatchJob.if).toBe("${{ needs.cut.outputs.tag != '' }}")
expect(dispatchJob.if).toBe(
"${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}"
)
expect(dispatchJob.permissions.actions).toBe('write')
expect(dispatchStep.env.TAG).toBe('${{ needs.cut.outputs.tag }}')
expect(dispatchStep.run).toContain('gh workflow run e2e.yml')
+10 -6
View File
@@ -1,15 +1,19 @@
// Enforced by .github/workflows/release-policy.yml on release events.
import {
DESKTOP_RC_TAG as RC_TAG,
DESKTOP_STABLE_TAG as STABLE_TAG,
MOBILE_TAG,
isAgentStateRulesTag
} from './release-tag-patterns.mjs'
const BOT_LOGIN = 'github-actions[bot]'
const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'
const NUMBER = '(?:0|[1-9][0-9]*)'
const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}`
const STABLE_TAG = new RegExp(`^v${VERSION}$`)
const RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`)
const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`)
// Why agent state rules here: their publish workflow is a bot author, and as a prerelease the
// release can never become Latest, which the app updater follows.
export function isPrereleaseTag(tag) {
return RC_TAG.test(tag) || MOBILE_TAG.test(tag)
return RC_TAG.test(tag) || MOBILE_TAG.test(tag) || isAgentStateRulesTag(tag)
}
export function compareStableTags(left, right) {
+37
View File
@@ -147,4 +147,41 @@ describe('release policy', () => {
await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.100')
})
it('keeps a bot-published agent state rules release as a prerelease, off Latest', async () => {
const rules = release('agent-state-rules-engine-1-next', 'github-actions[bot]')
const stable = release('v1.4.214', 'github-actions[bot]')
const github = createGithub({
releases: [rules, stable],
tags: { 'v1.4.214': {} }
})
await run(github, rules, 'published')
expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled()
expect(github.rest.git.deleteRef).not.toHaveBeenCalled()
expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({
...repoRef,
release_id: rules.id,
prerelease: true,
make_latest: 'false'
})
expect(github.rest.repos.updateRelease).toHaveBeenLastCalledWith({
...repoRef,
release_id: stable.id,
make_latest: 'true'
})
})
it('deletes an agent state rules release a person published', async () => {
const rules = release('agent-state-rules-engine-1-stable', 'someone', { prerelease: true })
const github = createGithub({ releases: [rules] })
await run(github, rules, 'published')
expect(github.rest.repos.deleteRelease).toHaveBeenCalledWith({
...repoRef,
release_id: rules.id
})
})
})
@@ -0,0 +1,184 @@
// Census: every release-triggered workflow and every script that lists this repo's releases
// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state
// rules cannot reach a desktop-only path through one that forgot it.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parse } from 'yaml'
import {
DESKTOP_RC_TAG,
DESKTOP_STABLE_TAG,
DESKTOP_STABLE_TAG_SHELL_PATTERN,
agentStateRulesTag,
isAgentStateRulesTag
} from './release-tag-patterns.mjs'
const WORKFLOWS_DIR = '.github/workflows'
const SCRIPT_DIRS = ['config/scripts', '.github/scripts']
const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/
const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/
const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')]
const rulesRelease = (tag, extra = {}) => ({
tag_name: tag,
name: tag,
draft: false,
prerelease: true,
author: { login: 'github-actions[bot]' },
assets: [{ name: 'agent-state-rules.json', download_count: 5 }],
...extra
})
function read(path) {
return readFileSync(path, 'utf8')
}
function importsSharedPatterns(path) {
return SHARED_IMPORT.test(read(path))
}
function isReleaseTriggered(workflow) {
const on = workflow.on
if (typeof on === 'string') {
return on === 'release'
}
if (Array.isArray(on)) {
return on.includes('release')
}
return typeof on === 'object' && on !== null && 'release' in on
}
function releaseTriggeredWorkflows() {
return readdirSync(WORKFLOWS_DIR)
.filter((name) => /\.ya?ml$/.test(name))
.map((name) => join(WORKFLOWS_DIR, name))
.filter((path) => isReleaseTriggered(parse(read(path))))
}
function scriptsNamedIn(text) {
return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map(
(match) => match[1]
)
}
function releaseListingScripts() {
return SCRIPT_DIRS.flatMap((dir) =>
readdirSync(dir)
.filter((name) => name.endsWith('.mjs') && !name.includes('.test.'))
.map((name) => join(dir, name))
).filter((path) => LISTS_RELEASES.test(read(path)))
}
/**
* Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves,
* by calling the script, that a rules release never passes; a new listing script must import the
* shared patterns or add a proof here.
*/
const DESKTOP_ONLY_PROOFS = {
'config/scripts/create-draft-release.mjs': async () => {
const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs')
const releases = [
...RULES_TAGS.map((tag) => rulesRelease(tag)),
{ tag_name: 'v1.4.1', draft: false }
]
expect(latestPreviousPublishedDesktopReleaseTag(releases, 'v1.4.2')).toBe('v1.4.1')
},
'config/scripts/publish-complete-draft-releases.mjs': async () => {
const { isReleaseCutDraft } = await import('./publish-complete-draft-releases.mjs')
for (const tag of RULES_TAGS) {
expect(isReleaseCutDraft(rulesRelease(tag, { draft: true }))).toBe(false)
}
},
'config/scripts/latest-stable-release.mjs': async () => {
const { latestStableDesktopReleaseTag } = await import('./latest-stable-release.mjs')
const releases = [
...RULES_TAGS.map((tag) => rulesRelease(tag, { prerelease: false })),
{ tag_name: 'v1.4.1' }
]
expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.1')
},
'config/scripts/assert-github-release-is-draft.mjs': async () => {
const { matchingDesktopReleases } = await import('./assert-github-release-is-draft.mjs')
expect(
matchingDesktopReleases(
RULES_TAGS.map((tag) => rulesRelease(tag)),
'v1.4.1'
)
).toEqual([])
},
'config/scripts/verify-release-required-assets.mjs': async () => {
const { verifyRequiredReleaseAssets } = await import('./verify-release-required-assets.mjs')
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(RULES_TAGS.map((tag) => rulesRelease(tag)))))
)
await expect(
verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' })
).rejects.toThrow('was not found')
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('release tag pattern census', () => {
it('finds the release-triggered workflows and release-listing scripts it guards', () => {
expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0)
expect(releaseListingScripts().length).toBeGreaterThan(0)
})
it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => {
const text = read(path)
const viaScript = scriptsNamedIn(text).some(importsSharedPatterns)
const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN)
expect(
viaScript || viaShellPattern,
`${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern`
).toBe(true)
})
it.each(releaseTriggeredWorkflows())(
'%s checks out the shared patterns beside its script',
(path) => {
const workflow = parse(read(path))
const sparse = Object.values(workflow.jobs)
.flatMap((job) => job.steps ?? [])
.map((step) => step.with?.['sparse-checkout'])
.filter((value) => typeof value === 'string')
for (const paths of sparse) {
if (scriptsNamedIn(paths).some(importsSharedPatterns)) {
expect(paths).toContain('config/scripts/release-tag-patterns.mjs')
}
}
}
)
it.each(releaseListingScripts())(
'%s imports the shared patterns or proves it admits only desktop tags',
async (path) => {
if (importsSharedPatterns(path)) {
return
}
const proof = DESKTOP_ONLY_PROOFS[path]
expect(
proof,
`${path} lists releases: import release-tag-patterns.mjs or add a proof`
).toBeDefined()
await proof()
}
)
it('keeps no proof for a script that no longer lists releases', () => {
const listing = new Set(releaseListingScripts())
expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([])
})
it('never classifies an agent state rules tag as a desktop release', () => {
for (const tag of RULES_TAGS) {
expect(isAgentStateRulesTag(tag)).toBe(true)
expect(DESKTOP_STABLE_TAG.test(tag) || DESKTOP_RC_TAG.test(tag)).toBe(false)
}
expect(() => agentStateRulesTag(1, 'beta')).toThrow()
})
})
+30
View File
@@ -0,0 +1,30 @@
// The tag families published as GitHub releases of this repo. Every release-triggered workflow and
// every script that lists releases classifies tags through this file, so a new family (like the
// agent state rules) is excluded or admitted in one place; release-tag-pattern-census.test.mjs
// enforces that.
const NUMBER = '(?:0|[1-9][0-9]*)'
const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}`
/** The stable desktop tag as a bash `[[ =~ ]]` pattern, for workflows that gate inline. */
export const DESKTOP_STABLE_TAG_SHELL_PATTERN =
'^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$'
export const DESKTOP_STABLE_TAG = new RegExp(DESKTOP_STABLE_TAG_SHELL_PATTERN)
export const DESKTOP_RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`)
export const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`)
/** The agent state rules bundle, one release per rules engine and channel, updated in place. */
const AGENT_STATE_RULES_TAG = /^agent-state-rules-engine-[1-9][0-9]*-(?:next|stable)$/
export function isAgentStateRulesTag(tag) {
return AGENT_STATE_RULES_TAG.test(tag)
}
export function agentStateRulesTag(engineVersion, channel) {
const tag = `agent-state-rules-engine-${engineVersion}-${channel}`
if (!isAgentStateRulesTag(tag)) {
throw new Error(`not an agent state rules tag: ${tag}`)
}
return tag
}
@@ -14,6 +14,14 @@ const workflow = parse(
)
const job = workflow.jobs.hosts
const runStep = job.steps.find((step) => step.name?.startsWith('Run the Windows host cells'))
const provisioning = readFileSync(
join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'),
'utf8'
)
const capability = readFileSync(
join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'),
'utf8'
)
const manifest = (arch) =>
JSON.parse(
readFileSync(
@@ -48,6 +56,99 @@ describe('SSH Windows-host workflow', () => {
'x64/windows-2022/preview'
])
expect(job.env).toMatchObject({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_ISOLATED_SSH_CI: '1' })
expect(job.strategy['fail-fast']).toBe(false)
expect(job['timeout-minutes']).toBe(75)
expect(runStep['timeout-minutes']).toBe(50)
})
it('overlaps only guarded ARM inbox capability preparation with the existing builds', () => {
const selfTestIndex = job.steps.findIndex((step) => step.name?.startsWith('Self-test'))
const prepareIndex = job.steps.findIndex((step) => step.id === 'inbox-capability')
const installIndex = job.steps.findIndex(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const buildIndex = job.steps.findIndex((step) => step.name?.startsWith('Build this runner'))
const prebuildIndex = job.steps.findIndex(
(step) => step.uses === './.github/actions/prepare-orcad-prebuilds'
)
const templateIndex = job.steps.findIndex((step) => step.name?.startsWith('Build the win32'))
const waitIndex = job.steps.findIndex((step) => step.wait === 'inbox-capability')
const runIndex = job.steps.indexOf(runStep)
expect([
selfTestIndex,
prepareIndex,
installIndex,
buildIndex,
prebuildIndex,
templateIndex,
waitIndex,
runIndex
]).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
expect(job.steps[prepareIndex]).toMatchObject({
background: true,
shell: 'pwsh'
})
expect(job.steps[prepareIndex].if).toBeUndefined()
expect(job.steps[waitIndex].if).toBeUndefined()
expect(job.steps[prepareIndex].run.trim()).toMatch(
/^if\('\$\{\{ matrix\.server }}' -eq 'inbox' -and '\$\{\{ matrix\.arch }}' -eq 'arm64'\)\{[\s\S]+\}$/
)
expect(job.steps[prepareIndex].run).toContain('Initialize-WindowsInboxSshCapability')
expect(job.steps[prepareIndex].run).toContain('inbox-capability-preparation.json')
expect(runStep.run).toContain('-InboxPreparationReceipt $preparation')
expect(runStep.run).toContain(
"$preparation=Join-Path $receipts 'inbox-capability-preparation.json'"
)
expect(runStep.run).toContain(
"if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation="
)
expect(runStep.background).toBeUndefined()
expect(job.steps.at(-1)).toMatchObject({ if: 'always()', uses: 'actions/upload-artifact@v7' })
})
it('shares one capability installer without bypassing native verification or private cleanup', () => {
expect(capability.match(/Add-WindowsCapability -Online/g)).toHaveLength(1)
expect(provisioning).not.toContain('Add-WindowsCapability')
expect(provisioning).toContain(". (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')")
expect(provisioning).toContain('Install-WindowsInboxSshCapability $Arch $report')
const install = capability.slice(
capability.indexOf('function Install-WindowsInboxSshCapability'),
capability.indexOf('function Initialize-WindowsInboxSshCapability')
)
const mutation = install.indexOf('Add-WindowsCapability')
expect(install.indexOf('Assert-IsolatedWindowsSshCi')).toBeLessThan(mutation)
expect(install.indexOf('Assert-WindowsSshGlobalServerDormant')).toBeLessThan(mutation)
expect(install.lastIndexOf('Assert-WindowsSshGlobalServerDormant')).toBeGreaterThan(mutation)
expect(install.indexOf('Assert-WindowsSshStockShell')).toBeLessThan(mutation)
expect(install.lastIndexOf('Assert-WindowsSshStockShell')).toBeGreaterThan(mutation)
for (const check of [
'(Machine $path) -ne $target.machine',
'Get-AuthenticodeSignature -LiteralPath $path',
'Inbox native input Microsoft signature invalid',
"Write-Stage 'host-cell-probe-start'",
"Write-Stage 'cleanup-default-shell-start'",
"Write-Stage 'cleanup-service-stop-delete-start'"
]) {
expect(provisioning).toContain(check)
}
})
it('keeps preparation provenance separate from the oracle current capability state', () => {
for (const [field, environment] of [
['sourceSha', 'GITHUB_SHA'],
['runId', 'GITHUB_RUN_ID'],
['runAttempt', 'GITHUB_RUN_ATTEMPT'],
['runnerName', 'RUNNER_NAME'],
['imageVersion', 'ImageVersion']
]) {
expect(capability).toContain(`${field}=$env:${environment}`)
expect(provisioning).toContain(`$preparation.${field} -ne $env:${environment}`)
}
expect(provisioning).toContain("$preparation.status -ne 'passed'")
expect(provisioning).toContain('$preparation.arch -ne $Arch')
expect(provisioning).toContain('$report.inboxCapabilityPreparation=$preparation')
expect(capability).toContain('$Report.inboxCapabilityInitialState=[string]$capability.State')
expect(capability).toContain("$report.status='failed';$report.error=$_.Exception.Message;throw")
})
it('fetches the preview release its hash manifests pin', () => {
@@ -2,5 +2,6 @@
// them, but the injected identity and key remain adjacent in the declaration.
export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*["`](rc|stable)["`]/
export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*["`](phc_[A-Za-z0-9_-]+)["`]/
// Why the `,` alternative: the identity can be a later declarator in a shared `var` (`var a=!0,b=\`stable\``).
export const MINIFIED_TELEMETRY_RE =
/\b(?:const|let|var)\s+[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/
/(?:\b(?:const|let|var)\s+|,\s*)[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/
@@ -22,4 +22,16 @@ describe('telemetry bundle constant patterns', () => {
const bundle = 'var dde=`stable`,fde=`phc_example-key_123`,pde=(dde===`stable`)'
expect(bundle).toMatch(MINIFIED_TELEMETRY_RE)
})
it('accepts the identity as a later declarator in a shared declaration', () => {
const bundle = 'var wpe=!0,Tpe=`stable`,Epe=`phc_example-key_123`,Dpe=(Tpe===`stable`)'
expect(MINIFIED_TELEMETRY_RE.exec(bundle)?.slice(1, 3)).toEqual([
'stable',
'phc_example-key_123'
])
})
it('rejects a minified identity with no adjacent write key', () => {
expect('var a=!0,b=`stable`,c=null').not.toMatch(MINIFIED_TELEMETRY_RE)
})
})
+54
View File
@@ -0,0 +1,54 @@
// Why a hand-rolled reader: the release guard only needs the import table's DLL
// names, and a parser dependency would be a new supply-chain input for one check.
const IMPORT_DIRECTORY_INDEX = 1
export function readPeImportedDllNames(buffer) {
const peOffset = buffer.readUInt32LE(0x3c)
if (buffer.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') {
throw new Error('Not a PE image')
}
const sectionCount = buffer.readUInt16LE(peOffset + 6)
const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20)
const optionalHeader = peOffset + 24
const isPe32Plus = buffer.readUInt16LE(optionalHeader) === 0x20b
const dataDirectories = optionalHeader + (isPe32Plus ? 112 : 96)
const importRva = buffer.readUInt32LE(dataDirectories + IMPORT_DIRECTORY_INDEX * 8)
if (importRva === 0) {
return []
}
const sections = []
for (let index = 0; index < sectionCount; index += 1) {
const header = optionalHeader + optionalHeaderSize + index * 40
sections.push({
virtualAddress: buffer.readUInt32LE(header + 12),
size: Math.max(buffer.readUInt32LE(header + 8), buffer.readUInt32LE(header + 16)),
rawOffset: buffer.readUInt32LE(header + 20)
})
}
const fileOffset = (rva) => {
const section = sections.find(
(candidate) =>
rva >= candidate.virtualAddress && rva < candidate.virtualAddress + candidate.size
)
if (!section) {
throw new Error(`RVA 0x${rva.toString(16)} is outside every section`)
}
return rva - section.virtualAddress + section.rawOffset
}
const names = []
for (let descriptor = fileOffset(importRva); ; descriptor += 20) {
const nameRva = buffer.readUInt32LE(descriptor + 12)
if (nameRva === 0) {
return names
}
const start = fileOffset(nameRva)
names.push(buffer.toString('latin1', start, buffer.indexOf(0, start)))
}
}
// The Visual C++ runtime DLLs ship with the Redistributable, not with Windows.
const DYNAMIC_VC_RUNTIME_RE = /^(?:vcruntime|msvcp|msvcr)\d+(?:_\d+)?\.dll$/i
export function findDynamicVcRuntimeImports(dllNames) {
return dllNames.filter((name) => DYNAMIC_VC_RUNTIME_RE.test(name))
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs'
// Smallest PE32+ image with one section holding an import directory for `dllNames`.
function peWithImports(dllNames) {
const peOffset = 0x40
const optionalHeaderSize = 240
const sectionHeader = peOffset + 24 + optionalHeaderSize
const rawOffset = 0x200
const virtualAddress = 0x1000
const descriptorsSize = (dllNames.length + 1) * 20
const strings = dllNames.map((name) => Buffer.from(`${name}\0`, 'latin1'))
const sectionSize = descriptorsSize + strings.reduce((sum, item) => sum + item.length, 0)
const buffer = Buffer.alloc(rawOffset + sectionSize)
buffer.write('MZ', 0, 'latin1')
buffer.writeUInt32LE(peOffset, 0x3c)
buffer.write('PE\0\0', peOffset, 'latin1')
buffer.writeUInt16LE(0x8664, peOffset + 4)
buffer.writeUInt16LE(1, peOffset + 6)
buffer.writeUInt16LE(optionalHeaderSize, peOffset + 20)
buffer.writeUInt16LE(0x20b, peOffset + 24)
buffer.writeUInt32LE(virtualAddress, peOffset + 24 + 112 + 8)
buffer.writeUInt32LE(sectionSize, sectionHeader + 8)
buffer.writeUInt32LE(virtualAddress, sectionHeader + 12)
buffer.writeUInt32LE(sectionSize, sectionHeader + 16)
buffer.writeUInt32LE(rawOffset, sectionHeader + 20)
let stringRva = virtualAddress + descriptorsSize
strings.forEach((item, index) => {
buffer.writeUInt32LE(stringRva, rawOffset + index * 20 + 12)
item.copy(buffer, rawOffset + (stringRva - virtualAddress))
stringRva += item.length
})
return buffer
}
describe('windows PE imports', () => {
it('reads every imported DLL name in order', () => {
const names = ['KERNEL32.dll', 'VCRUNTIME140.dll', 'api-ms-win-crt-runtime-l1-1-0.dll']
expect(readPeImportedDllNames(peWithImports(names))).toEqual(names)
})
it('reads an image with no imports', () => {
expect(readPeImportedDllNames(peWithImports([]))).toEqual([])
})
it('rejects a file that is not a PE image', () => {
const buffer = Buffer.alloc(0x80)
buffer.writeUInt32LE(0x40, 0x3c)
expect(() => readPeImportedDllNames(buffer)).toThrow('Not a PE image')
})
it('flags only the Visual C++ Redistributable DLLs', () => {
expect(
findDynamicVcRuntimeImports([
'KERNEL32.dll',
'ntdll.dll',
'VCRUNTIME140.dll',
'vcruntime140_1.dll',
'MSVCP140.dll',
'msvcr120.dll',
'api-ms-win-crt-heap-l1-1-0.dll',
'ucrtbase.dll'
])
).toEqual(['VCRUNTIME140.dll', 'vcruntime140_1.dll', 'MSVCP140.dll', 'msvcr120.dll'])
})
})
+10 -7
View File
@@ -446,8 +446,8 @@ call it.
`terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state
reached it only through the `<Agent> ready` titles the window writes, so a
headless `orca serve` never saw it (#16095). Now an agent whose rule file says
`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) has its
fresh row read straight from the store, through the same
`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) or
`"turn-end"` (Codex) has its fresh row read straight from the store, through the same
`selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses
(`src/main/runtime/tui-idle-hook-lane.ts`):
@@ -469,11 +469,14 @@ fresh row read straight from the store, through the same
OSC 133 zones itself;
- every other agent stays `identity-only`: Claude sends no event when an
approval is denied or Esc stops a tool, so its row can sit at `waiting` or
`working` forever, and the rules keep deciding. Codex is identity-only too:
before its `Interrupt` hook an Esc mid-turn leaves the row `working`, and an
older TUI can hand its hooks to a newer shared app server, so no version
check tells which Codex posts it. Current Codex settles fast anyway, since
`Interrupt` drives its `Codex ready` title.
`working` forever, and the rules keep deciding;
- Codex is `turn-end`: only a `done` decides (it settles the wait), and a
`working` or permission row leaves the decision to the rules. Before its
`Interrupt` hook an Esc mid-turn can leave the row `working`, and an older
TUI can hand its hooks to a newer shared app server, so no version check
tells which Codex posts it. A `done` is a real turn end on every version, so
trusting only that one keeps the headless gain (no quiet window after the
turn) without letting a missing cancel hang the wait.
The titles stay for display; remote clients read them.
+27 -17
View File
@@ -10,24 +10,34 @@ or a guaranteed forecast of savings.
## What runs now
| Work | Ordinary draft update | Ready PR / final checks | Main reference |
| -------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- |
| Static analysis and types | Immediately | Immediately | Existing workflows |
| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite |
| Packages | After successful static analysis and types | Same | Existing release workflows |
| Headless Node persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when server inputs changed | Full nightly qualification at 11:30 UTC |
| Headless Node glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures |
| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC |
| Work | Ordinary draft update | Ready PR / final checks | Main reference |
| -------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Static analysis and types | Immediately | Immediately | Existing workflows |
| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite |
| Packages | After successful static analysis and types | Same | Existing release workflows |
| Headless Node persistence | Deferred until ready | Linux x64 for ordinary runtime changes; explicit platform families or all six for sensitive inputs | All six platforms for relevant main pushes; full nightly qualification at 11:30 UTC |
| Headless Node glibc/musl qualification | Deferred until ready | Linux-specific or full qualification, after persistence succeeds | Both architectures for relevant main pushes and nightly qualification |
| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC |
Headless-runtime-sensitive inputs include root/toolchain files, configuration, native code,
resources, platform-specific paths, persistence, SQLite, orcad, providers,
daemon, SSH, relay, and child-process code. Dependency discovery failure, a
missing event or an incomplete diff retains full qualification. An unrelated
change still skips these lanes through the existing dependency classifier. No native
artifact is shared across platforms or ABIs. The routine draft reduction is an
explicit coverage-placement change; it does not assert identical per-update
coverage. Every non-draft synchronize event and the ready-for-review event
restores full qualification.
Headless draft updates carry no verdict; readiness starts the checks. Relevant
ready PRs retain Linux x64 smoke coverage. Explicit Windows or macOS paths add both
architectures in that family, while Linux paths add Linux ARM and the glibc/musl
lanes. Root/toolchain inputs, native build inputs, shared execution/storage paths,
SSH, providers and relay changes retain every platform. Missing or incomplete
change evidence and failed import analysis also retain full qualification.
Unrelated changes skip through the dependency classifier. Relevant main pushes
qualify all six platforms and both Linux compatibility architectures; detection
uses the whole push before qualification can supersede an older relevant run.
The detector checks known build inputs with the pinned Node toolchain first.
Other paths install dependencies for import analysis; an uncertain result never
skips qualification. This changes setup cost, not the qualification policy.
Windows server prebuilds are cached separately by architecture and pinned build
inputs, including the runner image. Only successful qualification on main publishes
them. Consumers validate the payload and still run the pinned-Node load/spawn smoke;
a cache miss or invalid payload builds fresh. Nightly, manual and release builds
remain fresh. No native artifact is shared across platforms or ABIs.
Expensive PR jobs wait for static/type success. This reduces fan-out for failed
or rapidly superseded commits without sleeping on a runner. Successful isolated
+116 -4
View File
@@ -679,8 +679,8 @@ coverage, and release behavior:
Shard 4 spent 535 worker-seconds importing and 357 executing tests; a uniform
per-file import estimate misses that cost. See [timing refresh](../../config/scripts/ci-shard-timings.md).
- Seed Node 24 native modules, the pinned Git compatibility binary, and TypeScript
state on the default branch, hourly
and when dependency/toolchain inputs change. One ten-minute-bounded hosted job
state on the default branch when dependency/toolchain inputs change, with
scheduled recovery (originally hourly; now every six hours). One ten-minute-bounded hosted job
reuses existing cache keys and skips typechecking an already-cached commit.
New PRs can restore default-branch caches, while caches saved by another PR
are inaccessible. The audit found 80 entries totaling 10.67 GiB, including
@@ -1216,7 +1216,7 @@ These are single cold/warm observations, not paired medians or a measured
whole-workflow saving. They demonstrate usable exact-key reuse after publication;
future savings depend on cache availability and unchanged native inputs. The
trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed
after merging this new namespace; the existing main push and hourly warming
after merging this new namespace; the existing main-push and scheduled warming
jobs provide that seed.
## Separate mobile install verification: retain the current policy
@@ -1269,7 +1269,7 @@ This measures the three-file oracle cohort. Whole-shard timings include other
test bodies, imports and transforms, so a whole-suite saving needs separate
measurement.
## Cache warming: let hourly ticks wait for active work
## Cache warming: let scheduled ticks wait for active work
The hourly warmer previously cancelled an active warmer, even when both used
the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780)
@@ -1289,6 +1289,27 @@ scheduled run or its repeated successful lanes, and pending replacement still
applies regardless of the cancellation expression. The bounded 20-run sample
contains this collision; it does not establish a recurring or whole-CI saving.
## Cache warming: six-hour recovery interval
Scheduled warming now runs at 00:41, 06:41, 12:41 and 18:41 UTC instead of hourly.
Main pushes that change cache inputs still seed immediately, and manual dispatch
remains available. All five jobs, probes, keys and publication rules remain.
This removes 20 scheduled workflows and 100 scheduled job starts per day (83%).
Four consecutive October 2 scheduled runs used the same source. The
[18:50 UTC run](https://github.com/stablyai/orca/actions/runs/37050194510) used 474
aggregate runner-seconds across five jobs, including 242 seconds on Windows ARM.
That job restored exact package, verification and native caches; package-store
restore alone took about 70 seconds. Repeating that observed duration twenty
fewer times would avoid about 158 runner-minutes daily, but this one-run estimate
is not a billing forecast or measured post-rollout saving.
The longer interval can delay background repair after eviction or runner-image
changes. Existing consumers retain cold-cache installation/build fallback, and
normal cache reads update last access. Storage was near the repository limit
when audited, so retention and unchanged hit rates are not guaranteed. Observe
misses before reducing the recovery frequency further.
## Daemon shutdown fixture: remove build tools after compilation
The fixture now removes compiler and Python build dependencies, plus npm and
@@ -1392,3 +1413,94 @@ one CI failure does not establish a failure-rate reduction.
The bounded 50-head main sample ending at 8ff6296 contained no root package
metadata changes. Removing app-version metadata from the Windows server cache
key would not improve reuse in that sample, so the key remains unchanged.
## Windows ARM SSH: prepare the inbox capability during independent builds
The ARM inbox lane starts guarded Windows capability preparation after the pure
provisioning self-test and waits for it before any private SSH server or host cell
runs. Dependency installation and the unchanged native artifacts can run during
that preparation. Preview and x64 lanes keep their existing serial provisioning;
the registered background step completes without mutation in those lanes.
The preparation and the foreground provider use the same installer and isolation
guards. The receipt must match the source, run, attempt, runner, image and native
architecture. The foreground provider still reads the installed capability and
verifies every native binary and Microsoft signature. Account ownership, ACLs,
DefaultShell, private service identity, host cells and cleanup remain independent
checks. A background failure propagates through the unconditional native wait.
Two full four-lane pairs used frozen source refs and the same dependency and
native-install policy. The [first baseline](https://github.com/stablyai/orca/actions/runs/36986929163)
ran before the [first candidate](https://github.com/stablyai/orca/actions/runs/36986970976);
the [second candidate](https://github.com/stablyai/orca/actions/runs/36991232037)
was dispatched before the [second baseline](https://github.com/stablyai/orca/actions/runs/36991234729).
Runner image versions matched within each platform in both pairs.
| Active job, seconds | First baseline | First candidate | Second baseline | Second candidate |
| ------------------- | -------------: | --------------: | --------------: | ---------------: |
| ARM inbox | 2,403 | 1,644 | 2,353 | 1,667 |
| ARM preview | 1,002 | 935 | 886 | 872 |
| x64 inbox | 636 | 732 | 616 | 620 |
| x64 preview | 562 | 561 | 623 | 566 |
The ARM inbox observations improved by 759 and 686 seconds. Baseline dependency
installation and artifact builds consumed 501 and 498 seconds before capability
installation could start. Candidate capability installation ran during that
work, but also took about 261 and 232 seconds less than the baseline. Candidate
dependency installation was slower, particularly in the second pair. These
observations support overlap on ARM; they do not establish a guaranteed 11–13
minute saving, a reduction in queue time, or the cause of installer variability.
The x64 lane showed no repeatable gain, so it keeps serial preparation.
All 16 actual Windows providers and 48 host-cell verdicts passed across the two
pairs. Receipts verify native machine identity, private service absence, owned
process exit, account removal and key removal. Loaded profile disposition remains
separate from those required cleanup checks. Hosted execution also verified the
native background/wait syntax; older actionlint versions do not recognize it.
### Overlap the private profile observation budgets
After service deletion and owned process exit, profile cleanup polls each owned
SID with its own full 30-second monotonic budget. Independent budgets now run
together. Every deletion follows a fresh targeted read; loaded profiles remain
for disposable VM destruction. Service identity, PID ownership, process exit,
account removal and key removal still fail the complete provider on error.
The maintained diagnostics self-test executes the actual cleanup try/catch with
scoped Windows API and clock controls. Eight positive cases cover full windows,
late unload, reload, query overhead, mixed states and missing SIDs; ten specific
failure cases cover foreign profiles and the required cleanup gates. Disposable
shortened-deadline and stale-snapshot mutations fail those controls. A separate
mocked real-clock observation took 30.179 seconds for three loaded profiles,
compared with about 90 seconds for serial full budgets. This measures polling,
not an actual Windows provider or the entire job.
The third profile no longer gains incidental extra time while earlier profiles
consume their budgets. A profile unloading at 45 seconds may therefore remain
where serial cleanup removed it. This uses the existing disposable-VM fallback;
it does not remove a loaded profile or relax mandatory account/key cleanup.
Hosted qualification of the combined workflow remains pending.
## Coordinator mail tests: advance observation windows without removing them
Six cases advance their original six 1,500 ms and ten 100 ms observation windows
with a scoped clock. Real filesystem, SQLite, journal, RPC and runtime work still
finishes asynchronously. The original journal-read gate and all counter and
operation assertions remain. Cancellation during delayed startup and the
Date-only age case retain real timers. Teardown stops the host and closes the
database before advancing the known 2,000 ms orphan repair, then asserts no fake
timers remain and restores the clock in `finally`.
Two opposite-order local pairs passed the same 23 cases and unchanged source
hashes. Selected-case totals fell from 13.674 to 3.318 seconds and from 13.276 to
6.323 seconds. Whole-file test totals fell from 24.845 to 10.829 seconds and from
21.500 to 19.410 seconds. Process wall times were 41.488/37.810 seconds and
42.140/78.450 seconds; the reverse candidate spent 56.31 seconds importing under
unrelated local load. Overall wall-time savings remain inconclusive.
Injected extra deliveries at 1,499 ms and 99 ms still fail the original assertions
in both clock modes. The latter candidate fails the unchanged journal-read gate
with the same extra provider start. A separate control confirms the orphan repair
actually executes against the closed database and leaves no fake timers. The
change retains all 121 original expectation sites and adds one teardown check;
it does not shorten the runtime's observation interval or claim a whole-PR gain.
+1 -1
View File
@@ -20,7 +20,7 @@ Alongside each event we include basic build and platform information: the Orca v
The categories of behavior we observe:
- **Lifecycle** — when the app opens. Used to estimate daily, weekly, and monthly active users.
- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text.
- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. For a workspace create we also record how long it took and how that time split across its steps, whether a checkout Orca had prepared in advance was reused (and if so, what reset it needed, what prepared it, how long it took to prepare and how long it waited unused; if not, a fixed reason code), for a failed create which step it stopped in, plus coarse context: whether it came from the Orca window or from the CLI, phone or agent interface, whether Git ran locally, in WSL or over SSH, a bucketed worktree count, a bucketed count of the files the repo tracks, how many other creates and prepared-checkout jobs were running, and whether the repo has a post-checkout hook (never its contents).
- **Agents** — which agents you use and their token counts. Never prompts or agent output.
- **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle.
- **Settings** — when you toggle one of a small whitelisted set of feature-flag or UX preferences. We record which preference changed and whether it's a boolean or an enum, never the raw value of any free-form setting.
@@ -0,0 +1,4 @@
# Why: link the C runtime statically so orca.exe runs on Windows hosts without the
# Visual C++ Redistributable (VCRUNTIME140.dll is not part of Windows).
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
+38 -17
View File
@@ -29,9 +29,9 @@ vi.mock('../pty/shell-startup-env', () => ({
}))
import {
findPaneCodexCommandLine,
findPaneCodex,
findPaneCodexOnSharedServer,
isCodexPaneOnOrcaMirrorHome,
isPaneCodexOnSharedServer,
resolveCodexPaneHome
} from './codex-shared-server-pane'
@@ -46,16 +46,17 @@ beforeEach(() => {
mocks.isShellStartupEnvProbeSupported.mockReturnValue(true)
})
describe('findPaneCodexCommandLine', () => {
describe('findPaneCodex', () => {
it('takes the launcher line, which carries the argv, over its native child', () => {
const rows = [
row(SHELL, 1, '-bash'),
row(101, SHELL, 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon'),
row(102, 101, '/usr/lib/node_modules/@openai/codex/vendor/codex --no-daemon')
]
expect(findPaneCodexCommandLine(rows, SHELL)).toBe(
'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon'
)
expect(findPaneCodex(rows, SHELL)).toEqual({
command: 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon',
shell: 'bash'
})
})
it('ignores the shared server a Windows Codex spawns as its own child', () => {
@@ -65,12 +66,29 @@ describe('findPaneCodexCommandLine', () => {
row(102, 101, '"C:\\h\\codex.exe" app-server --listen unix:// --managed-daemon'),
row(103, 101, '"C:\\h\\codex.exe" app-server daemon pid-update-loop')
]
expect(findPaneCodexCommandLine(rows, SHELL)).toBe('C:\\npm\\codex.exe')
expect(findPaneCodex(rows, SHELL)).toEqual({ command: 'C:\\npm\\codex.exe', shell: 'cmd' })
})
it.each([
['a login shell', row(SHELL, 1, '-zsh'), 'zsh'],
['a shell path', row(SHELL, 1, '/opt/homebrew/bin/fish -l'), 'fish'],
[
'a Windows image name over its command line',
{ ...row(SHELL, 1, ''), name: 'pwsh.exe' },
'pwsh'
]
])('names the shell Codex was typed into from %s', (_label, shell, name) => {
expect(findPaneCodex([shell, row(101, SHELL, 'codex')], SHELL)?.shell).toBe(name)
})
it('leaves fish without config unnamed, since it never loads the codex function', () => {
const fish = row(SHELL, 1, '/opt/homebrew/bin/fish -l -N')
expect(findPaneCodex([fish, row(101, SHELL, 'codex')], SHELL)?.shell).toBeNull()
})
it('ignores Codex outside this pane and non-Codex children', () => {
const rows = [row(SHELL, 1, '-zsh'), row(101, SHELL, 'vim'), row(201, 1, 'codex')]
expect(findPaneCodexCommandLine(rows, SHELL)).toBeNull()
expect(findPaneCodex(rows, SHELL)).toBeNull()
})
})
@@ -148,7 +166,7 @@ describe('isCodexPaneOnOrcaMirrorHome', () => {
})
})
describe('isPaneCodexOnSharedServer', () => {
describe('findPaneCodexOnSharedServer', () => {
beforeEach(() => {
mocks.getCodexPaneAccount.mockReturnValue({
selectionKey: 'host',
@@ -161,20 +179,23 @@ describe('isPaneCodexOnSharedServer', () => {
mocks.readWindowsProcessTable.mockResolvedValue(rows)
})
it('is true for a typed codex while its home has a live server', async () => {
await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(true)
it('finds a typed codex while its home has a live server', async () => {
await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toEqual({
command: 'codex',
shell: 'bash'
})
expect(mocks.probeCodexSharedServer).toHaveBeenCalledWith('/home/me/.codex')
})
it.each(['absent', 'unknown'] as const)(
'is false when the pane home server is %s',
'finds none when the pane home server is %s',
async (state) => {
mocks.probeCodexSharedServer.mockResolvedValue(state)
await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false)
await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull()
}
)
it('is false when Codex runs with --no-daemon, without probing', async () => {
it('finds none when Codex runs with --no-daemon, without probing', async () => {
mocks.getProcessTableSnapshot.mockResolvedValue([
row(SHELL, 1, '-bash'),
row(101, SHELL, 'codex --no-daemon')
@@ -183,13 +204,13 @@ describe('isPaneCodexOnSharedServer', () => {
row(SHELL, 1, 'cmd.exe'),
row(101, SHELL, 'codex --no-daemon')
])
await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false)
await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull()
expect(mocks.probeCodexSharedServer).not.toHaveBeenCalled()
})
it('is false when the pane home cannot be named, without reading processes', async () => {
it('finds none when the pane home cannot be named, without reading processes', async () => {
mocks.getCodexPaneAccount.mockReturnValue(null)
await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false)
await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull()
expect(mocks.getProcessTableSnapshot).not.toHaveBeenCalled()
expect(mocks.readWindowsProcessTable).not.toHaveBeenCalled()
})
+46 -16
View File
@@ -1,3 +1,4 @@
import { win32 as pathWin32 } from 'node:path'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { codexCommandLineJoinsSharedServer } from '../../shared/codex-shared-server-command'
import {
@@ -8,23 +9,41 @@ import {
import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader'
import { readWindowsProcessTable } from '../windows/windows-process-table'
import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env'
import { fishArgsSkipConfig } from '../fish-xdg-data-dirs-handoff'
import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from './codex-home-paths'
import { getCodexPaneAccount } from './codex-pane-account-registry'
import { probeCodexSharedServer } from './codex-shared-server-probe'
type CommandRow = ProcessIdentityRow & { command: string }
type CommandRow = ProcessIdentityRow & { command: string; name?: string }
export type PaneCodexProcess = {
command: string
/** Lowercase name of the process that launched Codex, e.g. `zsh` or `pwsh`; null when absent. */
shell: string | null
}
// Why name first: Windows rows carry the image name, and their command line may be empty.
function executableName(row: CommandRow): string {
const executable = row.name || row.command.trim().split(/\s+/)[0] || ''
return pathWin32
.basename(executable)
.replace(/^-/, '')
.replace(/\.exe$/i, '')
.toLowerCase()
}
/**
* The outermost Codex under the pane's shell. Outermost because a launcher
* (`node …/codex.js`) carries the argv, and on Windows the shared server it
* starts is its own child.
* The outermost Codex under the pane's shell, and the shell it was typed into.
* Outermost because a launcher (`node …/codex.js`) carries the argv, and on
* Windows the shared server it starts is its own child.
*/
export function findPaneCodexCommandLine(
export function findPaneCodex(
rows: readonly CommandRow[],
rootPid: number
): string | null {
): PaneCodexProcess | null {
const index = getProcessTableIndex(rows)
let outermost: (CommandRow & { depth: number }) | null = null
for (const row of collectDescendantsFromIndex(getProcessTableIndex(rows), rootPid)) {
for (const row of collectDescendantsFromIndex(index, rootPid)) {
if (
(!outermost || row.depth < outermost.depth) &&
recognizeAgentProcessFromCommandLine(row.command, { includeHeadlessOneShot: true })?.agent ===
@@ -33,7 +52,15 @@ export function findPaneCodexCommandLine(
outermost = row
}
}
return outermost?.command ?? null
if (!outermost) {
return null
}
const parent = index.byPid.get(outermost.ppid)
const shell = parent ? executableName(parent) : null
// Why: fish without config never loads Orca's codex function, so it counts as unwrapped.
const unwrappedFish =
shell === 'fish' && fishArgsSkipConfig(parent?.command.trim().split(/\s+/).slice(1) ?? [])
return { command: outermost.command, shell: unwrappedFish ? null : shell }
}
/**
@@ -70,18 +97,21 @@ export function isCodexPaneOnOrcaMirrorHome(ptyId: string): boolean {
)
}
/** Whether the Codex running in this local pane is a client of Codex's shared server. */
export async function isPaneCodexOnSharedServer(ptyId: string, rootPid: number): Promise<boolean> {
/** This local pane's Codex when it is a client of Codex's shared server; otherwise null. */
export async function findPaneCodexOnSharedServer(
ptyId: string,
rootPid: number
): Promise<PaneCodexProcess | null> {
const codexHome = resolveCodexPaneHome(ptyId)
if (!codexHome) {
return false
return null
}
const rows: readonly CommandRow[] =
process.platform === 'win32' ? await readWindowsProcessTable() : await getProcessTableSnapshot()
const commandLine = findPaneCodexCommandLine(rows, rootPid)
return (
commandLine !== null &&
codexCommandLineJoinsSharedServer(commandLine) &&
const codex = findPaneCodex(rows, rootPid)
return codex &&
codexCommandLineJoinsSharedServer(codex.command) &&
(await probeCodexSharedServer(codexHome)) === 'live'
)
? codex
: null
}
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable.
export const PROTOCOL_VERSION = 39
export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39
// Why: older daemons reject `setColorQueryReplyColors` as an unknown request type.
export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38
export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { getLegacyDaemonAdapters } from './daemon-provider-routing'
import { LocalPtyProvider } from '../providers/local-pty-provider'
describe('getLegacyDaemonAdapters', () => {
it('lists none for the in-process fallback provider', () => {
expect(getLegacyDaemonAdapters(new LocalPtyProvider())).toEqual([])
})
})
+2 -1
View File
@@ -1,6 +1,7 @@
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import type { IPtyProvider } from '../providers/types'
export type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
@@ -11,7 +12,7 @@ export function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdap
return provider
}
export function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] {
export function getLegacyDaemonAdapters(provider: IPtyProvider): DaemonPtyAdapter[] {
if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) {
return [...provider.getLegacyAdapters()]
}
@@ -99,7 +99,12 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
'prime-agent-0-9-8-slash-menu': 34,
'prime-agent-0-9-8-tool-turn': 40,
'prime-agent-0-9-8-trace-question': 4,
'prime-agent-0-9-8-turn-ended': 24
'prime-agent-0-9-8-turn-ended': 24,
// OMP 18.4.5 captures, serializer untouched: after a shrink the restored cursor sits one column
// short, as for Qoder. The unscrubbed captures diverge identically, so the scrub is not the cause.
'omp-18-composer': 2,
'omp-18-composer-narrow': 7,
'omp-18-setup': 6
}
// Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch.
+1 -1
View File
@@ -22,7 +22,7 @@ export function getFishVendorConfSnippetPath(wrapperRoot: string): string {
// Why: -N/--no-config (also abbreviated or in a flag cluster) skips vendor_conf.d,
// so the snippet could never undo the env. Over-matching only costs the codex hook.
function fishArgsSkipConfig(fishArgs: readonly string[]): boolean {
export function fishArgsSkipConfig(fishArgs: readonly string[]): boolean {
return fishArgs.some(
(arg) => /^-[^-]*N/.test(arg) || (arg.length > 5 && '--no-config'.startsWith(arg))
)
@@ -14,7 +14,8 @@ import { usesHostGitForWslLinkedWorktree } from '../wsl-linked-worktree-git-rout
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import type { GitExecOptions } from './git-exec-options'
function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
/** The WSL distro a Git command with this cwd runs in, or null when it runs on the host. */
export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
if (process.platform !== 'win32') {
return null
}
@@ -0,0 +1,180 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { probeCreateEventRepoFacts } from './create-event-repo-probe'
import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
async function hookPresence(repoPath: string, platform: NodeJS.Platform): Promise<string> {
return (await probeCreateEventRepoFacts(repoPath, platform)).postCheckoutHook
}
describe('probeCreateEventRepoFacts', () => {
let repo: string
beforeEach(async () => {
repo = await mkdtemp(path.join(tmpdir(), 'orca-hook-probe-'))
await mkdir(path.join(repo, '.git', 'hooks'), { recursive: true })
await writeFile(path.join(repo, '.git', 'config'), '[core]\n\tbare = false\n')
})
afterEach(async () => {
await rm(repo, { recursive: true, force: true })
})
async function writeHook(mode: number): Promise<void> {
const hook = path.join(repo, '.git', 'hooks', 'post-checkout')
await writeFile(hook, '#!/bin/sh\nexit 0\n')
await chmod(hook, mode)
}
it('reports absent when only sample hooks exist', async () => {
await writeFile(path.join(repo, '.git', 'hooks', 'post-checkout.sample'), '#!/bin/sh\n')
expect(await hookPresence(repo, 'darwin')).toBe('absent')
})
it('reports present for an executable hook', async () => {
await writeHook(0o755)
expect(await hookPresence(repo, 'linux')).toBe('present')
})
it('reports absent for a hook Git would skip as non-executable', async () => {
await writeHook(0o644)
expect(await hookPresence(repo, 'linux')).toBe('absent')
})
it('reports present for any hook file on Windows', async () => {
await writeHook(0o644)
expect(await hookPresence(repo, 'win32')).toBe('present')
})
it('reports a configured hooks path without following it', async () => {
await writeFile(path.join(repo, '.git', 'config'), '[core]\n\thooksPath = .husky/_\n')
await writeHook(0o755)
expect(await hookPresence(repo, 'linux')).toBe('custom_hooks_path')
})
it('reports neither fact when .git is a file it would need Git to resolve', async () => {
await rm(path.join(repo, '.git'), { recursive: true })
await writeFile(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n')
expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ postCheckoutHook: 'unknown' })
})
async function registerWorktree(name: string, lockReason?: string): Promise<void> {
const adminDir = path.join(repo, '.git', 'worktrees', name)
await mkdir(adminDir, { recursive: true })
if (lockReason !== undefined) {
await writeFile(path.join(adminDir, 'locked'), `${lockReason}\n`)
}
}
it('counts the main checkout plus the linked worktrees, leaving out Orca prepared checkouts', async () => {
await registerWorktree('feature-a')
await registerWorktree('feature-b', 'on a USB drive')
// This process's spare, another process's spare, and a crash leftover all carry the marker.
await registerWorktree('spare-here', createWorktreePreparationLockReason('here'))
await registerWorktree('spare-other', 'orca-create-preparation:v1:424242:other')
await registerWorktree('spare-crashed', 'orca-create-preparation:v1:7:gone')
await writeFile(path.join(repo, '.git', 'worktrees', 'stray-file'), '')
expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 3 })
})
it('does not count a spare whose admin directory Git has not written yet', async () => {
await registerWorktree('feature-a')
// A spare that has only just started has no entry under .git/worktrees, so nothing to drop.
expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 2 })
})
it('reports neither fact for a missing repo', async () => {
expect(await probeCreateEventRepoFacts(path.join(repo, 'missing'), 'linux')).toEqual({
postCheckoutHook: 'unknown'
})
})
async function writeIndex(signature: string, version: number, entries: number): Promise<void> {
const header = Buffer.alloc(12)
header.write(signature, 0, 'latin1')
header.writeUInt32BE(version, 4)
header.writeUInt32BE(entries, 8)
await writeFile(path.join(repo, '.git', 'index'), Buffer.concat([header, Buffer.alloc(64)]))
}
it.each([2, 4])(
'reads the tracked-file count from a version %i index header',
async (version) => {
await writeIndex('DIRC', version, 123_456)
expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({
postCheckoutHook: 'absent',
indexEntryCount: 123_456,
worktreeCount: 1
})
}
)
it('omits the count when the repo has no index yet', async () => {
expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({
postCheckoutHook: 'absent',
worktreeCount: 1
})
})
it('omits the count for a file that is not an index', async () => {
await writeIndex('NOPE', 2, 10)
expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount')
})
it('omits the count for a truncated index', async () => {
await writeFile(path.join(repo, '.git', 'index'), Buffer.from('DIRC\0\0'))
expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount')
})
it('omits the count for a split index, whose entries live in a shared index', async () => {
await writeIndex('DIRC', 2, 3)
await writeFile(path.join(repo, '.git', 'sharedindex.0123abcd'), '')
expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount')
})
it('omits the count for a sparse index, which collapses directories', async () => {
await writeIndex('DIRC', 4, 3)
await writeFile(path.join(repo, '.git', 'config'), '[index]\n\tsparse = true\n')
expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount')
})
it.each([
'[index]\n\tsparse = true\n',
'[index]\n\tsparse = YES\n',
'[index]\n\tsparse\n',
'[core]\n\tsparseCheckout = 1\n'
])(
'omits the count when config.worktree turns sparse checkout on (%j)',
async (worktreeConfig) => {
await writeIndex('DIRC', 4, 7)
await writeFile(path.join(repo, '.git', 'config.worktree'), worktreeConfig)
expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount')
}
)
it('keeps the count when sparse checkout is explicitly off', async () => {
await writeIndex('DIRC', 2, 11)
await writeFile(
path.join(repo, '.git', 'config'),
'[core]\n\tsparseCheckout = false\n\tsparseCheckoutCone = true\n[index]\n\tsparse = off\n'
)
expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ indexEntryCount: 11 })
})
it('reports a hooks path set in config.worktree', async () => {
await writeFile(path.join(repo, '.git', 'config.worktree'), '[core]\n\thooksPath = .githooks\n')
await writeHook(0o755)
expect(await hookPresence(repo, 'linux')).toBe('custom_hooks_path')
})
it('reports neither config-based fact when the config cannot be read', async () => {
await rm(path.join(repo, '.git', 'config'))
await writeIndex('DIRC', 2, 10)
expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({
postCheckoutHook: 'unknown',
worktreeCount: 1
})
})
})
+170
View File
@@ -0,0 +1,170 @@
import { open, readFile, readdir, stat } from 'node:fs/promises'
import path from 'node:path'
import { getErrorCode } from './worktree-operation-options'
import { parseWorktreePreparationOwnerPid } from '../../shared/worktree/create-preparation'
/** Existence only: the hook's content is never read. */
export type PostCheckoutHookPresence = 'present' | 'absent' | 'custom_hooks_path' | 'unknown'
/** What the create event says about the repo, read from its `.git` directory. */
export type CreateEventRepoFacts = {
postCheckoutHook: PostCheckoutHookPresence
/** Entries in the index header, i.e. tracked files; absent when the count is missing or unreliable. */
indexEntryCount?: number
/** The main checkout plus registered linked worktrees, Orca's prepared checkouts excluded. */
worktreeCount?: number
}
const PROBE_TIMEOUT_MS = 2_000
// Shorter than the whole probe, so a slow per-worktree read cannot cost the other facts.
const WORKTREE_COUNT_TIMEOUT_MS = 1_500
/** A hung network or WSL mount must not hold the caller; every answer here is best-effort. */
async function withTimeout<T>(operation: Promise<T>, ms: number, fallback: T): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<T>((resolve) => {
timer = setTimeout(() => resolve(fallback), ms)
timer.unref?.()
})
try {
return await Promise.race([operation, timeout])
} finally {
clearTimeout(timer)
}
}
/**
* Reads, from files only and never by spawning Git, whether `git worktree add` in this repo will
* run a repo-local post-checkout hook, how many files the repo tracks, and how many worktrees it has.
*
* A `core.hooksPath` in the repo's config is reported as such rather than followed, and global or
* included config is not read, so a hooks path set there reads as `absent`. A `.git` file (a repo
* registered from a linked worktree or submodule) needs Git to resolve, so it yields no facts.
*/
export function probeCreateEventRepoFacts(
repoPath: string,
platform: NodeJS.Platform = process.platform
): Promise<CreateEventRepoFacts> {
return withTimeout(readFacts(repoPath, platform), PROBE_TIMEOUT_MS, {
postCheckoutHook: 'unknown'
})
}
async function readFacts(
repoPath: string,
platform: NodeJS.Platform
): Promise<CreateEventRepoFacts> {
const gitDir = path.join(repoPath, '.git')
const isGitDirectory = await stat(gitDir).then(
(entry) => entry.isDirectory(),
() => false
)
if (!isGitDirectory) {
return { postCheckoutHook: 'unknown' }
}
// `git sparse-checkout --sparse-index` and per-worktree settings write to config.worktree.
const [mainConfig, worktreeConfig] = await Promise.all([
readFile(path.join(gitDir, 'config'), 'utf8').catch(() => null),
readFile(path.join(gitDir, 'config.worktree'), 'utf8').catch(() => '')
])
const config = mainConfig === null ? null : `${mainConfig}\n${worktreeConfig}`
const [postCheckoutHook, indexEntryCount, worktreeCount] = await Promise.all([
readPostCheckoutHook(gitDir, config, platform),
readIndexEntryCount(gitDir, config),
withTimeout(readWorktreeCount(gitDir), WORKTREE_COUNT_TIMEOUT_MS, undefined)
])
return {
postCheckoutHook,
...(indexEntryCount !== undefined ? { indexEntryCount } : {}),
...(worktreeCount !== undefined ? { worktreeCount } : {})
}
}
/**
* The worktree count from Git's registry of linked worktrees, since the create itself no longer
* lists them. Like the listing, an entry whose lock reason names an Orca preparation is a prepared
* checkout, not the user's worktree, whichever process or crash left it.
*/
async function readWorktreeCount(gitDir: string): Promise<number | undefined> {
const registry = path.join(gitDir, 'worktrees')
try {
const entries = await readdir(registry, { withFileTypes: true })
const preparations = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map((entry) =>
readFile(path.join(registry, entry.name, 'locked'), 'utf8').then(
(reason) => parseWorktreePreparationOwnerPid(reason.trim()) !== null,
() => false
)
)
)
return preparations.filter((isPreparation) => !isPreparation).length + 1
} catch (error) {
return getErrorCode(error) === 'ENOENT' ? 1 : undefined
}
}
/** `core.sparseCheckout` or `index.sparse` set to anything Git reads as true, bare key included. */
const SPARSE_CHECKOUT_ON = /^\s*sparse(checkout)?\s*(=(?!\s*(false|no|off|0)\s*$).*)?$/im
/**
* The entry count from the index header (`DIRC`, version, big-endian count), which is the same in
* every index version. A split index keeps entries elsewhere and a sparse index collapses them into
* directories, so with either possible (any sparse checkout) the count is left out.
*/
async function readIndexEntryCount(
gitDir: string,
config: string | null
): Promise<number | undefined> {
if (config === null || SPARSE_CHECKOUT_ON.test(config)) {
return undefined
}
try {
const entries = await readdir(gitDir)
if (entries.some((name) => name.startsWith('sharedindex.'))) {
return undefined
}
const index = await open(path.join(gitDir, 'index'), 'r')
try {
const header = Buffer.alloc(12)
const { bytesRead } = await index.read(header, 0, 12, 0)
if (bytesRead < 12 || header.toString('latin1', 0, 4) !== 'DIRC') {
return undefined
}
return header.readUInt32BE(8)
} finally {
await index.close()
}
} catch {
return undefined
}
}
async function readPostCheckoutHook(
gitDir: string,
config: string | null,
platform: NodeJS.Platform
): Promise<PostCheckoutHookPresence> {
if (config === null) {
return 'unknown'
}
try {
if (/^\s*hookspath\s*=/im.test(config)) {
return 'custom_hooks_path'
}
const hook = await stat(path.join(gitDir, 'hooks', 'post-checkout')).catch((error) => {
if (getErrorCode(error) === 'ENOENT') {
return null
}
throw error
})
if (!hook || !hook.isFile()) {
return 'absent'
}
// Git skips a hook without an execute bit, except on Windows where it has none to check.
return platform === 'win32' || (hook.mode & 0o111) !== 0 ? 'present' : 'absent'
} catch {
return 'unknown'
}
}
+9 -3
View File
@@ -125,6 +125,11 @@ export async function unlockPreparedWorktree(
}
}
export type FinalizedPreparedWorktree = AddWorktreeResult & {
/** The prepared checkout was not already at the requested commit, so it was reset onto it. */
preparedHeadReset: boolean
}
export async function finalizePreparedWorktree(
repoPath: string,
preparedPath: string,
@@ -134,7 +139,7 @@ export async function finalizePreparedWorktree(
refreshLocalBaseRef = false,
options: AddWorktreeOptions = {},
expectedLockReason: string
): Promise<AddWorktreeResult> {
): Promise<FinalizedPreparedWorktree> {
const finalizeGitOptions: AddWorktreeOptions = {
...options,
timeout: options.timeout ?? resolveWorktreeAddTimeoutMs()
@@ -181,8 +186,8 @@ export async function finalizePreparedWorktree(
await baseContext.pendingLocalBaseRefRefresh
throw preparedResult.reason
}
const preparedHeadOutput = preparedResult.value.stdout
if (preparedHeadOutput.trim() !== targetHead) {
const preparedHeadReset = preparedResult.value.stdout.trim() !== targetHead
if (preparedHeadReset) {
await verifyOwnership()
await gitExecFileAsync(
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
@@ -255,6 +260,7 @@ export async function finalizePreparedWorktree(
// Why: the refresh overlapped the finalize above; it has no bearing on the checkout's content.
const localBaseRefRefresh = await baseContext.pendingLocalBaseRefRefresh
return {
preparedHeadReset,
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(baseContext.localBaseRefUpdateSuggestion
? { localBaseRefUpdateSuggestion: baseContext.localBaseRefUpdateSuggestion }
@@ -0,0 +1,57 @@
import { resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
const mocks = vi.hoisted(() => ({ graph: vi.fn(), gitOptions: vi.fn() }))
vi.mock('../../repo-worktrees', () => ({ listRepoWorktreeGraph: mocks.graph }))
vi.mock('../../project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: mocks.gitOptions
}))
vi.mock('../registered-worktree-roots-cache', () => ({
resolveRegisteredWorktreePath: async (path: string) => path
}))
vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() }))
vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() }))
import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets'
const repo: Repo = {
id: 'wsl-drive-repo',
path: resolve('/wsl-drive/repo'),
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
const worktree = resolve('/wsl-drive/workspaces/feature-extra')
function fixture(): Store {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership check reads only these store methods.
return { getRepo: () => repo, getAllWorktreeMeta: () => ({}) } as unknown as Store
}
beforeEach(() => {
vi.resetAllMocks()
mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' })
// Only WSL Git names the checkout the way the renderer does; host Git reads its `/mnt/c` metadata.
mocks.graph.mockImplementation(async (_repo: Repo, options?: { wslDistro?: string }) => [
{ path: options?.wslDistro ? worktree : resolve('/mnt/c/wsl-drive/workspaces/feature-extra') }
])
})
describe('source control AI repo ownership for a detected worktree', () => {
it('lists through the project runtime, so a WSL-runtime drive worktree keeps its repo', async () => {
await expect(
getRepoForSourceControlAi(fixture(), { repoId: repo.id, worktreePath: worktree })
).resolves.toBe(repo)
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: 'Ubuntu-24.04' })
})
it('does not claim a worktree the repo listing does not name', async () => {
await expect(
getRepoForSourceControlAi(fixture(), {
repoId: repo.id,
worktreePath: resolve('/wsl-drive/elsewhere')
})
).resolves.toBeNull()
})
})
@@ -1,6 +1,9 @@
import type { Repo } from '../../../shared/repo-types'
import type { Store } from '../../persistence'
import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
import {
getLocalProjectWorktreeGitOptions,
type LocalProjectWorktreeGitOptions
} from '../../project-runtime-git-options'
import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment'
import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation'
import { resolve } from 'node:path'
@@ -77,7 +80,11 @@ async function localRepoOwnsWorktree(
return true
}
try {
const worktrees = await listRepoWorktreeGraph(repo)
// Why the project runtime: WSL Git names a drive worktree differently than host Git does.
const worktrees = await listRepoWorktreeGraph(
repo,
getLocalProjectWorktreeGitOptions(store, repo)
)
return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path)))
} catch {
return false
@@ -5,7 +5,10 @@ const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() }))
vi.mock('node:fs/promises', () => ({ stat: statMock }))
import { prepareWatcherProcessEvents } from './parcel-watcher-event-delivery'
import {
createWatcherProcessEventDeliveryQueue,
prepareWatcherProcessEvents
} from './parcel-watcher-event-delivery'
describe('prepareWatcherProcessEvents', () => {
beforeEach(() => {
@@ -42,3 +45,63 @@ describe('prepareWatcherProcessEvents', () => {
expect(maxActiveStats).toBe(8)
})
})
describe('watcher event queue flush', () => {
it('waits for active delivery and the bounded pending overflow', async () => {
const firstDelivery = Promise.withResolvers<void>()
const deliver = vi
.fn()
.mockImplementationOnce(() => firstDelivery.promise)
.mockResolvedValue(undefined)
const queue = createWatcherProcessEventDeliveryQueue({ maxEventsPerBatch: 1 }, deliver, vi.fn())
queue.enqueue([{ type: 'update', path: '/repo/first' }])
await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1))
queue.enqueue([
{ type: 'delete', path: '/repo/child' },
{ type: 'delete', path: '/repo' }
])
let flushed = false
const pending = queue.flush().then(() => {
flushed = true
})
await Promise.resolve()
expect(flushed).toBe(false)
firstDelivery.resolve()
await pending
expect(deliver.mock.calls.map(([events]) => events)).toEqual([
[{ type: 'update', path: '/repo/first' }],
null
])
await expect(queue.flush()).resolves.toBeUndefined()
queue.close()
})
it('settles all flush callers when closed while delivery is blocked', async () => {
const blocked = Promise.withResolvers<void>()
const queue = createWatcherProcessEventDeliveryQueue({}, () => blocked.promise, vi.fn())
queue.enqueue([{ type: 'update', path: '/repo/file' }])
const first = queue.flush()
const second = queue.flush()
expect(second).toBe(first)
queue.close()
await expect(first).resolves.toBeUndefined()
await expect(queue.flush()).resolves.toBeUndefined()
blocked.resolve()
})
it('settles flush after reporting a delivery error', async () => {
const onError = vi.fn()
const failure = new Error('delivery failed')
const queue = createWatcherProcessEventDeliveryQueue(
{},
async () => {
throw failure
},
onError
)
queue.enqueue([{ type: 'update', path: '/repo/file' }])
await queue.flush()
expect(onError).toHaveBeenCalledWith(failure)
queue.close()
})
})
@@ -13,6 +13,7 @@ const directoryStatSlots = new PrioritySemaphore(DIRECTORY_STAT_CONCURRENCY)
export type WatcherProcessEventDeliveryQueue = {
enqueue(events: readonly ParcelWatcherEvent[]): void
flush(): Promise<void>
close(): void
}
@@ -75,6 +76,12 @@ export function createWatcherProcessEventDeliveryQueue(
let draining = false
let pendingOverflow = false
let pendingEvents: ParcelWatcherEvent[] = []
let flushCompletion: ReturnType<typeof Promise.withResolvers<void>> | undefined
const finishFlush = (): void => {
flushCompletion?.resolve()
flushCompletion = undefined
}
const drain = async (): Promise<void> => {
if (!active || draining) {
@@ -104,6 +111,8 @@ export function createWatcherProcessEventDeliveryQueue(
draining = false
if (active && (pendingOverflow || pendingEvents.length > 0)) {
void drain()
} else {
finishFlush()
}
}
}
@@ -123,11 +132,19 @@ export function createWatcherProcessEventDeliveryQueue(
}
void drain()
},
flush(): Promise<void> {
if (!active || !draining) {
return Promise.resolve()
}
flushCompletion ??= Promise.withResolvers<void>()
return flushCompletion.promise
},
close(): void {
active = false
controller.abort()
pendingEvents = []
pendingOverflow = false
finishFlush()
}
}
}
@@ -16,6 +16,11 @@ export type PendingWatcherUnsubscribe = (error?: Error) => void
export const WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS = RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS
const terminalTeardownFailures = new WeakMap<WatcherProcessSubscriptionRecord, Error>()
const pendingRecordUnsubscribes = new WeakMap<WatcherProcessSubscriptionRecord, Promise<void>>()
const hostSubscriptions = new WeakMap<
WatcherProcessSubscriptionRecord,
WatcherProcessSubscription
>()
export function handleWatcherHostMessage(
message: Exclude<
@@ -138,49 +143,85 @@ export function createHostWatcherSubscription({
terminateUnavailableChild,
sendToChild
}: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription {
return {
const existing = hostSubscriptions.get(record)
if (existing) {
return existing
}
const unsubscribe = (): Promise<void> => {
const terminalFailure = terminalTeardownFailures.get(record)
if (terminalFailure) {
return Promise.reject(terminalFailure)
}
const termination = getTerminationPromise()
if (!records.delete(record.id)) {
return termination ?? Promise.resolve()
}
resetPendingSubscribeAttempt(record)
if (termination) {
return termination
}
if (records.size === 0) {
return killWatcherChildIfIdle()
}
const child = getChild()
if (!child?.connected) {
return terminateUnavailableChild(child)
}
return new Promise((resolve, reject) => {
const onTimeout = (): void => {
if (pendingUnsubscribes.get(record.id) !== settle) {
return
}
// Why: canary setup itself can fail, so native teardown needs an
// independent host deadline that exits the child and releases handles.
void terminateUnavailableChild(child).catch(() => undefined)
}
const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS)
timer.unref?.()
const settle: PendingWatcherUnsubscribe = (error) => {
clearTimeout(timer)
if (error) {
reject(error)
} else {
resolve()
}
}
pendingUnsubscribes.set(record.id, settle)
sendToChild(child, { op: 'unsubscribe', id: record.id })
})
}
const subscription: WatcherProcessSubscription = {
unsubscribe: (): Promise<void> => {
const terminalFailure = terminalTeardownFailures.get(record)
if (terminalFailure) {
return Promise.reject(terminalFailure)
const pending = pendingRecordUnsubscribes.get(record)
if (pending) {
return pending
}
const termination = getTerminationPromise()
if (!records.delete(record.id)) {
return termination ?? Promise.resolve()
}
resetPendingSubscribeAttempt(record)
if (termination) {
return termination
}
if (records.size === 0) {
return killWatcherChildIfIdle()
}
const child = getChild()
if (!child?.connected) {
return terminateUnavailableChild(child)
}
return new Promise((resolve, reject) => {
const onTimeout = (): void => {
if (pendingUnsubscribes.get(record.id) !== settle) {
return
}
// Why: canary setup itself can fail, so native teardown needs an
// independent host deadline that exits the child and releases handles.
void terminateUnavailableChild(child).catch(() => undefined)
const closing = unsubscribe()
pendingRecordUnsubscribes.set(record, closing)
const clearPending = (): void => {
if (pendingRecordUnsubscribes.get(record) === closing) {
pendingRecordUnsubscribes.delete(record)
}
const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS)
timer.unref?.()
const settle: PendingWatcherUnsubscribe = (error) => {
clearTimeout(timer)
if (error) {
reject(error)
} else {
resolve()
}
}
pendingUnsubscribes.set(record.id, settle)
sendToChild(child, { op: 'unsubscribe', id: record.id })
})
}
void closing.then(clearPending, clearPending)
return closing
}
}
hostSubscriptions.set(record, subscription)
return subscription
}
export async function retireHostWatcherSubscription(
record: WatcherProcessSubscriptionRecord,
error: Error,
shouldReport: () => boolean
): Promise<void> {
try {
await hostSubscriptions.get(record)?.unsubscribe()
} catch (teardownError) {
error = teardownError instanceof Error ? teardownError : new Error(String(teardownError))
}
if (shouldReport()) {
reportWatcherTerminalError(record, error)
}
}
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
acknowledgeWatcherSubscribe,
currentWatcherChild,
FakeWatcherChild,
trackPromiseSettlement
} from './parcel-watcher-process-test-child'
const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() }))
vi.mock('node:child_process', () => ({ fork: forkMock }))
vi.mock('node:fs', () => ({
existsSync: () => true,
mkdtempSync: () => '/tmp/orca-watcher-retirement-test',
rmSync: vi.fn()
}))
import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor'
import { WATCHER_PROCESS_EXIT_DEADLINE_MS } from './parcel-watcher-child-termination'
describe('native watcher error retirement', () => {
let supervisor: WatcherProcessSupervisor
beforeEach(() => {
forkMock.mockReset().mockImplementation(() => new FakeWatcherChild())
supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false })
})
afterEach(() => supervisor.dispose())
it('closes only the failed handle before recovery and joins concurrent unsubscribe', async () => {
const callback = vi.fn()
const onTerminalError = vi.fn()
const pending = supervisor.subscribe('/repo', callback, {}, { onTerminalError })
const child = currentWatcherChild(forkMock)
const id = acknowledgeWatcherSubscribe(child)
const subscription = await pending
const keepAlive = supervisor.subscribe('/other', vi.fn(), {})
const otherId = acknowledgeWatcherSubscribe(child)
await keepAlive
child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' })
expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id })
expect(onTerminalError).not.toHaveBeenCalled()
expect(callback).not.toHaveBeenCalled()
const closing = subscription.unsubscribe()
const closed = trackPromiseSettlement(closing)
child.emit('message', { op: 'watch-error', id, message: 'duplicate error' })
child.emit('message', { op: 'events', id, events: [{ type: 'update', path: '/repo/stale' }] })
await Promise.resolve()
expect(closed()).toBe(false)
expect(child.sent.filter((message) => message.op === 'unsubscribe')).toHaveLength(1)
child.emit('message', { op: 'unsubscribed', id })
await closing
await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledTimes(1))
expect(onTerminalError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'native watcher stopped' })
)
expect(callback).not.toHaveBeenCalled()
expect(child.kill).not.toHaveBeenCalled()
child.connected = false
child.emit('exit', 1, null)
const replacement = currentWatcherChild(forkMock)
expect(replacement.sent).toEqual([
expect.objectContaining({ op: 'subscribe', id: otherId, dir: '/other' })
])
})
it('waits for physical exit before reporting the last failed subscription', async () => {
const callback = vi.fn()
const pending = supervisor.subscribe('/repo', callback, {})
const child = currentWatcherChild(forkMock)
const id = acknowledgeWatcherSubscribe(child)
const subscription = await pending
child.emit('message', { op: 'watch-error', id, message: 'root removed' })
expect(child.kill).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalled()
const closing = subscription.unsubscribe()
const closed = trackPromiseSettlement(closing)
await Promise.resolve()
expect(closed()).toBe(false)
child.emit('exit', 0, null)
await closing
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1))
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'root removed' }), [])
expect(forkMock).toHaveBeenCalledTimes(1)
})
it('suppresses delayed recovery after supervisor disposal', async () => {
const onTerminalError = vi.fn()
const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError })
const child = currentWatcherChild(forkMock)
const id = acknowledgeWatcherSubscribe(child)
await pending
const keepAlive = supervisor.subscribe('/other', vi.fn(), {})
acknowledgeWatcherSubscribe(child)
await keepAlive
child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' })
supervisor.dispose()
child.emit('message', { op: 'unsubscribed', id })
await Promise.resolve()
await Promise.resolve()
expect(onTerminalError).not.toHaveBeenCalled()
})
it('fences replacement behind physical exit when termination misses its deadline', async () => {
vi.useFakeTimers()
try {
const onTerminalError = vi.fn()
const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError })
const child = currentWatcherChild(forkMock)
const id = acknowledgeWatcherSubscribe(child)
const subscription = await pending
child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' })
await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS)
expect(onTerminalError).toHaveBeenCalledTimes(1)
const error = onTerminalError.mock.calls[0]?.[0]
expect(error).toMatchObject({ scope: 'supervisor', physicalExit: expect.any(Promise) })
let exited = false
void error.physicalExit.then(() => {
exited = true
})
await expect(subscription.unsubscribe()).rejects.toBe(error)
expect(exited).toBe(false)
child.emit('exit', 0, null)
await error.physicalExit
expect(exited).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('reports the removed owner when shared-child teardown misses its exit deadline', async () => {
vi.useFakeTimers()
try {
const onTerminalError = vi.fn()
const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError })
const child = currentWatcherChild(forkMock)
const id = acknowledgeWatcherSubscribe(child)
const subscription = await pending
const otherTerminalError = vi.fn()
const other = supervisor.subscribe(
'/other',
vi.fn(),
{},
{ onTerminalError: otherTerminalError }
)
acknowledgeWatcherSubscribe(child)
await other
child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' })
child.emit('message', { op: 'unsubscribe-failed', id, message: 'native teardown failed' })
await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS)
expect(onTerminalError).toHaveBeenCalledTimes(1)
expect(otherTerminalError).toHaveBeenCalledTimes(1)
const error = onTerminalError.mock.calls[0]?.[0]
expect(error).toMatchObject({ scope: 'supervisor', physicalExit: expect.any(Promise) })
await expect(subscription.unsubscribe()).rejects.toBe(error)
child.emit('exit', 0, null)
await error.physicalExit
} finally {
vi.useRealTimers()
}
})
})
@@ -516,6 +516,92 @@ describe('parcel watcher process canary', () => {
)
})
it.each(['/repo', '/repo/Café'])(
'invalidates only the deleted root %s after delivering its batch',
async (dir) => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe: vi.fn() }
})
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 1, dir, opts: {} })
await vi.advanceTimersByTimeAsync(0)
sendMock.mockClear()
callback?.(null, [{ type: 'delete', path: `${dir}/nested` }])
callback?.(null, [{ type: 'delete', path: `${dir}-other` }])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock.mock.calls.some(([message]) => message.op === 'watch-error')).toBe(false)
sendMock.mockClear()
callback?.(null, [{ type: 'delete', path: dir }])
callback?.(null, [{ type: 'update', path: `${dir}/late.txt` }])
callback?.(null, [{ type: 'delete', path: dir }])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock.mock.calls.map(([message]) => message)).toEqual([
{ op: 'events', id: 1, events: [{ type: 'delete', path: dir }] },
{ op: 'watch-error', id: 1, message: expect.stringContaining('root') }
])
}
)
it('invalidates an overflowing root-delete batch after IPC backpressure clears', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe: vi.fn() }
})
let releaseEvent: (() => void) | undefined
const sendMock = vi.fn((message, onSent) => {
if (message.op === 'events') {
releaseEvent = onSent
return false
}
return true
})
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', {
op: 'subscribe',
id: 1,
dir: '/repo',
opts: {},
delivery: { maxEventsPerBatch: 1 }
})
await vi.advanceTimersByTimeAsync(0)
sendMock.mockClear()
callback?.(null, [{ type: 'update', path: '/repo/first.txt' }])
await vi.advanceTimersByTimeAsync(0)
callback?.(null, [
{ type: 'delete', path: '/repo/child.txt' },
{ type: 'delete', path: '/repo' }
])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock.mock.calls.map(([message]) => message.op)).toEqual(['events'])
releaseEvent?.()
await vi.advanceTimersByTimeAsync(0)
expect(sendMock.mock.calls.map(([message]) => message.op)).toEqual([
'events',
'overflow',
'watch-error'
])
})
it('bounds pending event batches while child IPC reports backpressure', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
@@ -180,6 +180,7 @@ function main(): void {
opts: WatcherProcessSubscribeOptions,
delivery: WatcherProcessDeliveryOptions | undefined
): Promise<WatcherSubscription | null> => {
let rootDeleted = false
const eventDelivery = createWatcherProcessEventDeliveryQueue(
delivery,
async (events) => {
@@ -216,11 +217,23 @@ function main(): void {
return await watcher.subscribe(
dir,
(err, events) => {
if (rootDeleted) {
return
}
if (err) {
send({ op: 'watch-error', id, message: errorMessage(err) })
return
}
rootDeleted = events.some((event) => event.type === 'delete' && event.path === dir)
eventDelivery.enqueue(events)
if (rootDeleted) {
// Native backends stop deleted roots without reporting an error.
void eventDelivery
.flush()
.then(() =>
send({ op: 'watch-error', id, message: 'File watcher root was deleted' })
)
}
},
opts as ParcelWatcher.Options
)
@@ -44,7 +44,7 @@ export class WatcherProcessSupervisor {
private child: ChildProcess | null = null
private nextSubscriptionId = 1
private readonly crashFuse = new WatcherProcessCrashFuse()
private shutdownRequested = false
private shutdown = { requested: false, disposalRevision: 0 }
private canaryDir: string | null = null
private terminatingChild: ChildProcess | null = null
private readonly terminationQueue = new termination.WatcherTerminationQueue()
@@ -72,7 +72,7 @@ export class WatcherProcessSupervisor {
callback,
opts,
hooks,
shutdownRequested: this.shutdownRequested,
shutdownRequested: this.shutdown.requested,
entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(),
useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true,
allocateId: () => this.nextSubscriptionId++,
@@ -93,7 +93,8 @@ export class WatcherProcessSupervisor {
}
dispose(): void {
this.shutdownRequested = true
this.shutdown.requested = true
this.shutdown.disposalRevision++
this.capacityWait.dispose()
const proc = this.child
this.child = null
@@ -109,7 +110,7 @@ export class WatcherProcessSupervisor {
resetForTest(): void {
this.dispose()
this.ownedChildren = new WatcherOwnedChildren()
this.shutdownRequested = false
this.shutdown.requested = false
this.terminatingChild = null
this.terminationQueue.resetForTest()
this.crashFuse.reset()
@@ -121,7 +122,7 @@ export class WatcherProcessSupervisor {
private ensureWatcherProcess(
entryPath = this.options.entryPath ?? getWatcherProcessEntryPath()
): ChildProcess | null {
if (this.shutdownRequested || this.terminatingChild) {
if (this.shutdown.requested || this.terminatingChild) {
return null
}
if (this.child?.connected) {
@@ -154,6 +155,7 @@ export class WatcherProcessSupervisor {
private handleChildMessage(message: WatcherToHostMessage): void {
const child = this.child
const disposalRevision = this.shutdown.disposalRevision
handleWatcherSupervisorMessage(message, {
records: this.records,
pendingUnsubscribes: this.pendingUnsubscribes,
@@ -174,6 +176,7 @@ export class WatcherProcessSupervisor {
this.terminateUnavailableChild(child)
}
},
shouldReportTerminalError: () => this.shutdown.disposalRevision === disposalRevision,
killWatcherChildIfIdle: () =>
termination.ignoreWatcherTermination(this.killWatcherChildIfIdle())
})
@@ -197,7 +200,7 @@ export class WatcherProcessSupervisor {
recoverWatcherRecordsAfterChildGone(
this.records,
this.crashFuse,
this.shutdownRequested,
this.shutdown.requested,
() => this.ensureWatcherProcess(),
sendWatcherSubscribe,
() => {
@@ -230,9 +233,9 @@ export class WatcherProcessSupervisor {
(exited) => {
this.terminatingChild = null
if (!exited) {
this.shutdownRequested = true
this.shutdown.requested = true
}
return !this.shutdownRequested
return !this.shutdown.requested
},
() => this.ensureWatcherProcess(),
sendWatcherSubscribe,
@@ -297,9 +300,9 @@ export class WatcherProcessSupervisor {
(exited) => {
this.terminatingChild = null
if (!exited) {
this.shutdownRequested = true
this.shutdown.requested = true
}
return !this.shutdownRequested
return !this.shutdown.requested
},
() => this.ensureWatcherProcess(),
sendWatcherSubscribe,
+6 -2
View File
@@ -90,7 +90,7 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
it('forwards watcher errors to the callback', async () => {
it('retires the failed native subscription before reporting its error', async () => {
const callback = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {})
const child = currentChild()
@@ -98,6 +98,10 @@ describe('subscribeViaWatcherProcess', () => {
await promise
child.emit('message', { op: 'watch-error', id, message: 'boom' })
expect(callback).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledTimes(1)
child.emit('exit', 0, null)
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1))
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), [])
})
@@ -126,7 +130,7 @@ describe('subscribeViaWatcherProcess', () => {
expect(forkMock).toHaveBeenCalledTimes(1)
})
it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => {
it('reports a resubscribe failure through the terminal hook', async () => {
const callback = vi.fn()
const onTerminalError = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onTerminalError })
@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { watch } from 'node:fs'
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -103,6 +104,29 @@ describe('shallow watcher subscription', () => {
}
})
it.skipIf(process.platform === 'win32')(
'preserves a literal backslash in an included filename',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-'))
const events: string[] = []
const subscription = startShallowWatcher(
root,
['plugin\\name'],
(nextEvents) => events.push(...nextEvents.map((event) => event.path)),
(error) => {
throw error
}
)
try {
emit(root, 'plugin\\name')
expect(events).toEqual([join(root, 'plugin\\name')])
} finally {
await subscription.unsubscribe()
await rm(root, { recursive: true, force: true })
}
}
)
it('rebinds a nested directory that is replaced, which leaves fs.watch deaf', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-'))
try {
@@ -152,4 +176,79 @@ describe('shallow watcher subscription', () => {
await rm(root, { recursive: true, force: true })
}
})
it('retries a failed replacement binding and resyncs after recovery', async () => {
vi.useFakeTimers()
const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-'))
const logs = join(root, 'logs')
let subscription: ReturnType<typeof startShallowWatcher> | undefined
try {
await mkdir(logs)
const events: string[] = []
subscription = startShallowWatcher(
root,
['HEAD', 'logs/HEAD'],
(nextEvents) => events.push(...nextEvents.map((event) => event.path)),
(error) => {
throw error
}
)
const initialAttempts = vi.mocked(watch).mock.calls.length
await rename(logs, join(root, 'old-logs'))
await mkdir(logs)
vi.mocked(watch).mockImplementationOnce(() => {
throw new Error('ENOSPC: watch limit reached')
})
await vi.advanceTimersByTimeAsync(30_000)
await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(initialAttempts + 1))
await vi.advanceTimersByTimeAsync(30_000)
await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(initialAttempts + 2))
expect(events).toContain(join(logs, 'HEAD'))
events.length = 0
emit(logs, 'HEAD')
expect(events).toEqual([join(logs, 'HEAD')])
} finally {
await subscription?.unsubscribe()
vi.useRealTimers()
await rm(root, { recursive: true, force: true })
}
})
it('resyncs included files when an initially missing directory becomes watchable', async () => {
vi.useFakeTimers()
const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-'))
let subscription: ReturnType<typeof startShallowWatcher> | undefined
try {
const defaultWatch = vi.mocked(watch).getMockImplementation()
if (!defaultWatch) {
throw new Error('Missing watcher test implementation')
}
vi.mocked(watch)
.mockImplementationOnce(defaultWatch)
.mockImplementationOnce(() => {
throw new Error('ENOENT: logs directory does not exist')
})
const events: string[] = []
subscription = startShallowWatcher(
root,
['HEAD', 'logs/HEAD'],
(nextEvents) => events.push(...nextEvents.map((event) => event.path)),
(error) => {
throw error
}
)
const logs = join(root, 'logs')
await mkdir(logs)
await vi.advanceTimersByTimeAsync(30_000)
await vi.waitFor(() => expect(watcherState.has(logs)).toBe(true))
expect(events).toContain(join(logs, 'HEAD'))
} finally {
await subscription?.unsubscribe()
vi.useRealTimers()
await rm(root, { recursive: true, force: true })
}
})
})
@@ -33,7 +33,9 @@ export function startShallowWatcher(
): ShallowWatcherSubscription {
const pathsByDirectory = new Map<string, Set<string>>()
for (const relativePath of relativePaths) {
const parts = relativePath.split(/[\\/]+/).filter(Boolean)
const parts = relativePath
.split(process.platform === 'win32' ? /[\\/]+/ : /\/+/)
.filter(Boolean)
const fileName = parts.pop()
if (!fileName) {
continue
@@ -66,11 +68,11 @@ export function startShallowWatcher(
)
}
const watchDirectory = (parent: string, fileNames: Set<string>, rebind = false): void => {
const watchDirectory = (parent: string, fileNames: Set<string>, rebind = false): boolean => {
const existing = watchers.get(parent)
if (existing) {
if (!rebind) {
return
return true
}
watchers.delete(parent)
boundIdentities.delete(parent)
@@ -102,11 +104,13 @@ export function startShallowWatcher(
})
watcher.on('error', reportError)
watchers.set(parent, watcher)
return true
} catch (error) {
// Nested metadata directories may not exist until Git creates them.
if (parent === '') {
reportError(error)
}
return false
}
}
@@ -139,11 +143,11 @@ export function startShallowWatcher(
}
// Either the directory appeared after we started, or it was replaced while
// watched. Both leave the old binding deaf, so rebind and resync.
watchDirectory(parent, fileNames, true)
boundIdentities.set(parent, identity)
if (bound !== undefined) {
emitUpdates(parent, fileNames)
if (!watchDirectory(parent, fileNames, true)) {
return
}
boundIdentities.set(parent, identity)
emitUpdates(parent, fileNames)
}
const rebindTimer = setInterval(() => {
@@ -3,7 +3,8 @@ import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-t
import {
handleWatcherHostMessage,
type PendingWatcherUnsubscribe,
reportWatcherTerminalError
reportWatcherTerminalError,
retireHostWatcherSubscription
} from './parcel-watcher-host-subscriptions'
import {
startInterruptedSubscribeTimeout,
@@ -28,6 +29,7 @@ type WatcherSupervisorMessageContext = {
) => void
restartAfterCancelledSubscribe: (child: ChildProcess | null) => void
terminateUnavailableChild: (child: ChildProcess | null) => void
shouldReportTerminalError: () => boolean
killWatcherChildIfIdle: () => void
}
@@ -61,13 +63,13 @@ export function handleWatcherSupervisorMessage(
}
}
if (message.op === 'watch-error') {
handleWatcherHostMessage(
message,
context.records,
context.pendingUnsubscribes,
reportWatcherTerminalError,
context.killWatcherChildIfIdle
)
if (record) {
void retireHostWatcherSubscription(
record,
new Error(message.message),
context.shouldReportTerminalError
)
}
return
}
if (message.op === 'cancel-requires-restart') {
@@ -1,18 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type Handler = (event: unknown, args: { id: string }) => Promise<boolean>
type Handler = (event: unknown, args: { id: string }) => Promise<unknown>
type Session = { id: string; rootProcessId?: number; wslDistro?: string }
const mocks = vi.hoisted(() => ({
handlers: new Map<string, Handler>(),
sessions: new Array<Session>(),
hasProvider: vi.fn<(id: string) => boolean>(),
isPaneCodexOnSharedServer: vi.fn<(id: string, rootPid: number) => Promise<boolean>>(),
findPaneCodexOnSharedServer:
vi.fn<
(id: string, rootPid: number) => Promise<{ command: string; shell: string | null } | null>
>(),
resolveCodexPaneHome: vi.fn<(id: string) => string | null>(),
isOnOrcaMirror: vi.fn<(id: string) => boolean>(),
disable: vi.fn<(home: string) => Promise<boolean>>(),
disableOnMirror: vi.fn<(home: string) => Promise<boolean>>(),
stop: vi.fn<(home: string) => Promise<boolean>>()
stop: vi.fn<(home: string) => Promise<boolean>>(),
legacyAdapters: new Array<{ protocolVersion: number; hasPty: (id: string) => boolean }>()
}))
vi.mock('../../pty-host-bindings', () => ({
getPtyIpc: () => ({
@@ -21,7 +25,7 @@ vi.mock('../../pty-host-bindings', () => ({
}))
vi.mock('../../../codex/codex-shared-server-pane', () => ({
isCodexPaneOnOrcaMirrorHome: mocks.isOnOrcaMirror,
isPaneCodexOnSharedServer: mocks.isPaneCodexOnSharedServer,
findPaneCodexOnSharedServer: mocks.findPaneCodexOnSharedServer,
resolveCodexPaneHome: mocks.resolveCodexPaneHome
}))
vi.mock('../../../codex/codex-shared-server-fix', () => ({
@@ -29,6 +33,9 @@ vi.mock('../../../codex/codex-shared-server-fix', () => ({
disableCodexSharedServerAutoStartOnOrcaMirror: mocks.disableOnMirror,
stopCodexSharedServer: mocks.stop
}))
vi.mock('../../../daemon/daemon-provider-routing', () => ({
getLegacyDaemonAdapters: () => mocks.legacyAdapters
}))
vi.mock('../provider/registry', () => ({
hasPtyProviderForInspection: mocks.hasProvider,
getProviderForPty: () => ({ listProcesses: () => Promise.resolve(mocks.sessions) })
@@ -38,13 +45,14 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id'
import { ptyOwnership } from '../provider/ownership-state'
import { installPtyCodexSharedServerIpcHandler } from './codex-shared-server'
// Each channel with its answer for a local pane and its answer when it refuses one.
const CHANNELS = [
'pty:isCodexOnSharedServer',
'pty:disableCodexSharedServerAutoStart',
'pty:stopCodexSharedServer'
['pty:isCodexOnSharedServer', { joined: true, openedBeforeWrapper: false }, { joined: false }],
['pty:disableCodexSharedServerAutoStart', true, false],
['pty:stopCodexSharedServer', true, false]
] as const
function invoke(channel: string, id: string): Promise<boolean> {
function invoke(channel: string, id: string): Promise<unknown> {
const handler = mocks.handlers.get(channel)
if (!handler) {
throw new Error(`missing ${channel}`)
@@ -58,18 +66,50 @@ beforeEach(() => {
ptyOwnership.clear()
mocks.sessions = [{ id: 'local-1', rootProcessId: 100 }]
mocks.hasProvider.mockReturnValue(true)
mocks.isPaneCodexOnSharedServer.mockResolvedValue(true)
mocks.findPaneCodexOnSharedServer.mockResolvedValue({ command: 'codex', shell: 'zsh' })
mocks.resolveCodexPaneHome.mockReturnValue('/home/me/.codex')
mocks.isOnOrcaMirror.mockReturnValue(false)
mocks.disable.mockResolvedValue(true)
mocks.disableOnMirror.mockResolvedValue(true)
mocks.stop.mockResolvedValue(true)
mocks.legacyAdapters = []
installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise: () => undefined })
})
describe('Codex shared-server IPC', () => {
it.each(CHANNELS)('%s answers for a local pane', async (channel) => {
expect(await invoke(channel, 'local-1')).toBe(true)
it.each(CHANNELS)('%s answers for a local pane', async (channel, answer) => {
expect(await invoke(channel, 'local-1')).toEqual(answer)
})
const ownedBy = (protocolVersion: number, ptyId = 'local-1') => ({
protocolVersion,
hasPty: (id: string) => id === ptyId
})
it.each([
['zsh on a v36 daemon', 'zsh', [ownedBy(36)], true],
['zsh on a v37 daemon', 'zsh', [ownedBy(37)], false],
['fish on a v37 daemon', 'fish', [ownedBy(37)], true],
['fish on a v38 daemon', 'fish', [ownedBy(38)], true],
['fish on a v39 daemon', 'fish', [ownedBy(39)], false],
['cmd.exe, which never gets the codex function', 'cmd', [ownedBy(36)], false],
['an unknown shell', null, [ownedBy(36)], false],
['zsh where the older daemon does not hold this pane', 'zsh', [ownedBy(36, 'other')], false]
])('tells whether a new terminal would fix %s', async (_label, shell, adapters, old) => {
mocks.findPaneCodexOnSharedServer.mockResolvedValue({ command: 'codex', shell })
mocks.legacyAdapters = adapters
expect(await invoke('pty:isCodexOnSharedServer', 'local-1')).toEqual({
joined: true,
openedBeforeWrapper: old
})
})
it('reads no daemon owner for a pane that has not joined', async () => {
const hasPty = vi.fn(() => true)
mocks.legacyAdapters = [{ protocolVersion: 36, hasPty }]
mocks.findPaneCodexOnSharedServer.mockResolvedValue(null)
expect(await invoke('pty:isCodexOnSharedServer', 'local-1')).toEqual({ joined: false })
expect(hasPty).not.toHaveBeenCalled()
})
it('runs the fix against the pane home', async () => {
@@ -104,17 +144,18 @@ describe('Codex shared-server IPC', () => {
['a pane no provider holds', 'local-1', () => mocks.hasProvider.mockReturnValue(false)]
]
it.each(CHANNELS.flatMap((channel) => refusals.map((refusal) => [channel, ...refusal] as const)))(
'%s refuses %s',
async (channel, _label, id, arrange) => {
arrange()
expect(await invoke(channel, id)).toBe(false)
expect(mocks.isPaneCodexOnSharedServer).not.toHaveBeenCalled()
expect(mocks.disable).not.toHaveBeenCalled()
expect(mocks.disableOnMirror).not.toHaveBeenCalled()
expect(mocks.stop).not.toHaveBeenCalled()
}
)
it.each(
CHANNELS.flatMap(([channel, , refused]) =>
refusals.map((refusal) => [channel, ...refusal, refused] as const)
)
)('%s refuses %s', async (channel, _label, id, arrange, refused) => {
arrange()
expect(await invoke(channel, id)).toEqual(refused)
expect(mocks.findPaneCodexOnSharedServer).not.toHaveBeenCalled()
expect(mocks.disable).not.toHaveBeenCalled()
expect(mocks.disableOnMirror).not.toHaveBeenCalled()
expect(mocks.stop).not.toHaveBeenCalled()
})
it.each(CHANNELS.slice(1))('%s runs nothing when the pane has no Codex home', async (channel) => {
mocks.resolveCodexPaneHome.mockReturnValue(null)
+44 -9
View File
@@ -1,8 +1,8 @@
import { getPtyIpc } from '../../pty-host-bindings'
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
import {
findPaneCodexOnSharedServer,
isCodexPaneOnOrcaMirrorHome,
isPaneCodexOnSharedServer,
resolveCodexPaneHome
} from '../../../codex/codex-shared-server-pane'
import {
@@ -10,9 +10,25 @@ import {
disableCodexSharedServerAutoStartOnOrcaMirror,
stopCodexSharedServer
} from '../../../codex/codex-shared-server-fix'
import { getLegacyDaemonAdapters } from '../../../daemon/daemon-provider-routing'
import {
CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION,
CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION
} from '../../../daemon/daemon-protocol-version'
import type { CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command'
import { ptyOwnership } from '../provider/ownership-state'
import { getProviderForPty, hasPtyProviderForInspection } from '../provider/registry'
// Why per shell: a new terminal fixes Codex only where this build's daemon gives that shell Orca's
// codex function; cmd.exe and unrecognized shells never get one.
const CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL: ReadonlyMap<string, number> = new Map([
['zsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION],
['bash', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION],
['powershell', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION],
['pwsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION],
['fish', CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION]
])
type Deps = { getLocalPtyProviderStartupPromise: () => Promise<void> | undefined }
/** The pane's root pid when it is a local, non-WSL pane; otherwise null. */
@@ -37,21 +53,40 @@ async function findLocalPaneRootPid(deps: Deps, id: unknown): Promise<number | n
return session?.rootProcessId !== undefined && !session.wslDistro ? session.rootProcessId : null
}
function handleLocalPane(
function handleLocalPane<T>(
deps: Deps,
channel: string,
run: (id: string, rootPid: number) => Promise<boolean>
run: (id: string, rootPid: number) => Promise<T>,
refused: T
): void {
getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise<boolean> => {
getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise<T> => {
try {
const rootPid = await findLocalPaneRootPid(deps, args?.id)
return rootPid === null ? false : await run(args.id, rootPid)
return rootPid === null ? refused : await run(args.id, rootPid)
} catch {
return false
return refused
}
})
}
async function readPaneSharedServerStatus(
id: string,
rootPid: number
): Promise<CodexSharedServerStatus> {
const codex = await findPaneCodexOnSharedServer(id, rootPid)
if (!codex) {
return { joined: false }
}
// Why: the pane's own daemon predates its shell's codex function, which a new terminal has.
const shellFunctionProtocol = CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL.get(codex.shell ?? '')
const openedBeforeWrapper =
shellFunctionProtocol !== undefined &&
getLegacyDaemonAdapters(getProviderForPty(id)).some(
(adapter) => adapter.hasPty(id) && adapter.protocolVersion < shellFunctionProtocol
)
return { joined: true, openedBeforeWrapper }
}
/** Runs a fix command against the pane's own CODEX_HOME, never a guessed one. */
function runForPaneHome(fix: (codexHome: string) => Promise<boolean>) {
return async (id: string): Promise<boolean> => {
@@ -71,7 +106,7 @@ function disableForPane(id: string): Promise<boolean> {
// Why its own read: only a pane already showing Codex asks, so no cadence poll pays for argv.
export function installPtyCodexSharedServerIpcHandler(deps: Deps): void {
handleLocalPane(deps, 'pty:isCodexOnSharedServer', isPaneCodexOnSharedServer)
handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane)
handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer))
handleLocalPane(deps, 'pty:isCodexOnSharedServer', readPaneSharedServerStatus, { joined: false })
handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane, false)
handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer), false)
}
@@ -3,6 +3,29 @@ import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import { hasRemoteFilesystemOwner } from './remote-filesystem-owner'
export type RegisteredOwner = {
repoId: string
listed: Set<string> | null
/** The WSL distro whose Git produced `listed`; undefined is host Git. */
listedWslDistro: string | undefined
recovered: Set<string>
aliases: Set<string>
revision: number
dirty: boolean
}
export function createRegisteredOwner(repoId: string, revision: number): RegisteredOwner {
return {
repoId,
listed: null,
listedWslDistro: undefined,
recovered: new Set(),
aliases: new Set(),
revision,
dirty: true
}
}
export function getWorktreeRootOwnerKey(repo: Repo): string {
return JSON.stringify([repo.id, resolve(repo.path), getRepoExecutionHostId(repo)])
}
@@ -3,16 +3,27 @@ import { resolve } from 'node:path'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import type { Repo } from '../../shared/repo-types'
import { getErrorCode } from '../git/worktree-operation-options'
import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution'
import type { Store } from '../persistence'
import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options'
import { listRepoWorktreeGraph } from '../repo-worktrees'
const CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS = 1_000
const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
type ListedRoots = { roots: Set<string>; listingFailed: boolean }
/** `wslDistro` names the Git that listed the roots; undefined is host Git. */
type ListedRoots = { roots: Set<string>; listingFailed: boolean; wslDistro: string | undefined }
/**
* Why the project runtime's distro: the catalog and removal list through it, and WSL Git records a
* `C:\` worktree as `/mnt/c/...`, which host Git reading that metadata names as another path. A
* runtime awaiting repair has no distro, so it keeps the host Git this listing always used.
*/
export async function listWorktreeRootsWithConcurrency(
store: Store,
repos: readonly Repo[]
): Promise<ListedRoots[]> {
const runtimes = resolveLocalProjectRuntimesForRepos(store, repos)
const results: ListedRoots[] = []
let nextIndex = 0
await Promise.all(
@@ -22,10 +33,14 @@ export async function listWorktreeRootsWithConcurrency(
while (nextIndex < repos.length) {
const index = nextIndex++
const repo = repos[index]
const wslDistro = getWorktreeMirrorDistroForRuntime(runtimes.get(repo.id))
const roots = new Set([resolve(repo.path)])
let listingFailed = false
try {
for (const worktree of await listRepoWorktreeGraph(repo)) {
for (const worktree of await listRepoWorktreeGraph(
repo,
wslDistro ? { wslDistro } : {}
)) {
roots.add(resolve(worktree.path))
}
} catch (error) {
@@ -35,7 +50,7 @@ export async function listWorktreeRootsWithConcurrency(
)
listingFailed = true
}
results[index] = { roots, listingFailed }
results[index] = { roots, listingFailed, wslDistro }
}
}
)
@@ -4,6 +4,12 @@
* Split out of registered-worktree-roots-cache so the policy is testable on its own
* and the cache module stays within its line budget.
*/
import type { Repo } from '../../shared/repo-types'
import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution'
import type { Store } from '../persistence'
import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options'
import type { RegisteredOwner } from './registered-worktree-root-owner'
/** The owner fields the policy reads; `undefined` means no owner record yet. */
export type RelistCandidate =
| { dirty: boolean; listed: unknown; recovered: { size: number } }
@@ -24,3 +30,26 @@ export function shouldRelistOwner(owner: RelistCandidate, onlyDirty: boolean): b
}
return owner.dirty || owner.listed == null || owner.recovered.size > 0
}
/**
* Owners whose roots came from a Git other than the one their project runtime now selects.
*
* A runtime awaiting repair is skipped: it names no Git, and re-listing through host Git would
* revoke worktrees only the last real listing can name. Its owners keep that listing.
*/
export function findOwnersListedThroughAnotherGit(
store: Store,
repos: ReadonlyMap<string, Repo>,
owners: ReadonlyMap<string, RegisteredOwner>
): RegisteredOwner[] {
const runtimes = resolveLocalProjectRuntimesForRepos(store, [...repos.values()])
return [...repos].flatMap(([key, repo]) => {
const owner = owners.get(key)
const runtime = runtimes.get(repo.id)
return owner?.listed &&
runtime?.status !== 'repair-required' &&
owner.listedWslDistro !== getWorktreeMirrorDistroForRuntime(runtime)
? [owner]
: []
})
}
@@ -0,0 +1,219 @@
import { join, resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/repo-types'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
const mocks = vi.hoisted(() => ({ graph: vi.fn(), runtimes: vi.fn(), realpath: vi.fn() }))
vi.mock('node:fs', () => ({ realpathSync: (path: string) => path }))
vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({})), realpath: mocks.realpath }))
vi.mock('../repo-worktrees', () => ({
listRepoWorktreeGraph: mocks.graph,
isRepoRoot: vi.fn(() => false)
}))
vi.mock('../local-project-runtime-resolution', () => ({
resolveLocalProjectRuntimesForRepos: mocks.runtimes
}))
import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from './filesystem-auth'
import {
__resetCreatedWorktreeRootsForTests,
invalidateAuthorizedRootsCache,
registerWorktreeRootsForRepo,
resolveRegisteredWorktreePath
} from './registered-worktree-roots-cache'
const DISTRO = 'Ubuntu-24.04'
const repo: Repo = {
id: 'wsl-drive-repo',
path: resolve('/wsl-drive/repo'),
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
// The same checkout as each Git names it: WSL Git's `/mnt/c/...` translated to the drive spelling
// every other consumer sends, and host Git reading that `/mnt/c` metadata as a different path.
const worktree = resolve('/wsl-drive/workspaces/feature-extra')
const hostGitSpelling = resolve('/mnt/c/wsl-drive/workspaces/feature-extra')
const hostRepo: Repo = { ...repo, id: 'host-repo', path: resolve('/host-drive/repo') }
const hostRepoWorktree = resolve('/host-drive/workspaces/feature')
const wslRuntime: ProjectExecutionRuntimeResolution = {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
distro: DISTRO,
projectId: 'project',
reason: 'project-override',
cacheKey: `project:wsl:${DISTRO}`
}
}
const hostRuntime: ProjectExecutionRuntimeResolution = {
status: 'resolved',
runtime: {
kind: 'windows-host',
hostPlatform: 'win32',
projectId: 'project',
reason: 'project-override',
cacheKey: 'project:windows-host'
}
}
const repairRuntime: ProjectExecutionRuntimeResolution = {
status: 'repair-required',
repair: {
projectId: 'project',
preferredRuntime: { kind: 'wsl', distro: DISTRO },
reason: 'wsl-distro-missing',
source: 'project-override',
cacheKey: `project:repair:wsl-distro-missing:${DISTRO}`
}
}
function fixture(repos: Repo[] = [repo]): Store {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these store methods; listing and runtime resolution are mocked.
return {
getRepos: () => repos,
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({})
} as unknown as Store
}
function useRuntime(runtime: ProjectExecutionRuntimeResolution): void {
mocks.runtimes.mockImplementation(
(_store: Store, repos: readonly Repo[]) =>
new Map(repos.map((entry) => [entry.id, entry.id === hostRepo.id ? hostRuntime : runtime]))
)
}
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.resetAllMocks()
mocks.realpath.mockImplementation(async (path: string) => path)
mocks.graph.mockImplementation(async (listed: Repo, options?: { wslDistro?: string }) => [
{
path:
listed.id === hostRepo.id
? hostRepoWorktree
: options?.wslDistro === DISTRO
? worktree
: hostGitSpelling
}
])
useRuntime(wslRuntime)
})
describe('authorized worktree roots for a drive repo whose project runtime is WSL', () => {
it('lists through the project distro, so the path delete and git status send is authorized', async () => {
await expect(resolveAuthorizedPath(worktree, fixture())).resolves.toBe(worktree)
await expect(resolveRegisteredWorktreePath(worktree, fixture())).resolves.toBe(worktree)
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO })
})
it('still refuses a path outside every root, including the spelling only host Git lists', async () => {
const store = fixture()
await expect(
resolveAuthorizedPath(resolve('/wsl-drive/elsewhere/secret'), store)
).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE)
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow(
PATH_ACCESS_DENIED_MESSAGE
)
})
it('re-lists through the new Git when the project runtime changes after a listing', async () => {
useRuntime(hostRuntime)
const store = fixture()
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).resolves.toBe(
join(hostGitSpelling, 'file')
)
useRuntime(wslRuntime)
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
join(worktree, 'file')
)
expect(mocks.graph).toHaveBeenLastCalledWith(repo, { wslDistro: DISTRO })
// The host-Git listing is retired with its owner, not kept beside the new one.
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow(
PATH_ACCESS_DENIED_MESSAGE
)
})
it('keeps a registration WSL Git produced without re-listing', async () => {
const store = fixture()
registerWorktreeRootsForRepo(store, repo.id, [repo.path, worktree], { wslDistro: DISTRO })
await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow(
PATH_ACCESS_DENIED_MESSAGE
)
expect(mocks.graph).not.toHaveBeenCalled()
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
join(worktree, 'file')
)
})
it('re-lists a registration host Git produced for a WSL project on the next miss', async () => {
const store = fixture()
registerWorktreeRootsForRepo(store, repo.id, [repo.path, hostGitSpelling])
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
join(worktree, 'file')
)
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO })
})
it('lists with host Git while the runtime awaits repair, as before routing', async () => {
useRuntime(repairRuntime)
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), fixture())).resolves.toBe(
join(hostGitSpelling, 'file')
)
expect(mocks.graph).toHaveBeenCalledWith(repo, {})
})
it('keeps the WSL listing when a resolved runtime starts awaiting repair', async () => {
const store = fixture()
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
join(worktree, 'file')
)
useRuntime(repairRuntime)
await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow(
PATH_ACCESS_DENIED_MESSAGE
)
expect(mocks.graph).toHaveBeenCalledTimes(1)
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
join(worktree, 'file')
)
})
it('re-lists in the same request when the runtime changes during an in-flight rebuild', async () => {
useRuntime(hostRuntime)
const store = fixture()
let releaseHostListing = (): void => {}
mocks.graph.mockImplementationOnce(
(_repo: Repo) =>
new Promise((resolveListing) => {
releaseHostListing = () => resolveListing([{ path: hostGitSpelling }])
})
)
const earlier = resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)
await vi.waitFor(() => expect(mocks.graph).toHaveBeenCalledTimes(1))
useRuntime(wslRuntime)
const afterSwitch = resolveAuthorizedPath(join(worktree, 'file'), store)
releaseHostListing()
await expect(earlier).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE)
await expect(afterSwitch).resolves.toBe(join(worktree, 'file'))
})
it('keeps a host-runtime repo in the same cache on host Git', async () => {
const store = fixture([repo, hostRepo])
await expect(resolveAuthorizedPath(join(hostRepoWorktree, 'file'), store)).resolves.toBe(
join(hostRepoWorktree, 'file')
)
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
join(worktree, 'file')
)
expect(mocks.graph).toHaveBeenCalledWith(hostRepo, {})
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO })
expect(mocks.graph).toHaveBeenCalledTimes(2)
})
})
+39 -34
View File
@@ -6,21 +6,17 @@ import {
pruneCreatedWorktreeRoots
} from './registered-worktree-root-probes'
import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment'
import { shouldRelistOwner } from './registered-worktree-root-relist-policy'
import {
findOwnersListedThroughAnotherGit,
shouldRelistOwner
} from './registered-worktree-root-relist-policy'
import {
createRegisteredOwner,
getLocalWorktreeRootOwners,
resolveWorktreeRootOwner
resolveWorktreeRootOwner,
type RegisteredOwner
} from './registered-worktree-root-owner'
type RegisteredOwner = {
repoId: string
listed: Set<string> | null
recovered: Set<string>
aliases: Set<string>
revision: number
dirty: boolean
}
const registeredWorktreeRoots = new Set<string>()
const registeredOwners = new Map<string, RegisteredOwner>()
const registeredWorktreeRootsRevisionByRepo = new Map<string, number>()
@@ -45,6 +41,16 @@ function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner
return key === undefined ? undefined : registeredOwners.get(key)
}
function relistOwners(owners: Iterable<RegisteredOwner>): void {
for (const owner of owners) {
owner.listed = null
owner.dirty = true
advanceOwner(owner)
}
refreshRegisteredWorktreeRoots()
registeredWorktreeRootsDirty = true
}
function synchronizeOwners(store: Store): Repo[] {
const repos = store.getRepos()
const owners = getLocalWorktreeRootOwners(repos)
@@ -63,14 +69,7 @@ function synchronizeOwners(store: Store): Repo[] {
}
for (const [key, repo] of owners) {
if (!registeredOwners.has(key)) {
registeredOwners.set(key, {
repoId: repo.id,
listed: null,
recovered: new Set(),
aliases: new Set(),
revision: ++revisionSequence,
dirty: true
})
registeredOwners.set(key, createRegisteredOwner(repo.id, ++revisionSequence))
changed = true
}
}
@@ -84,13 +83,7 @@ function synchronizeOwners(store: Store): Repo[] {
export function invalidateAuthorizedRootsCache(): void {
invalidationGeneration++
for (const owner of registeredOwners.values()) {
owner.listed = null
owner.dirty = true
advanceOwner(owner)
}
refreshRegisteredWorktreeRoots()
registeredWorktreeRootsDirty = true
relistOwners(registeredOwners.values())
baseRevision = ++revisionSequence
registeredWorktreeRootsRevisionByRepo.clear()
}
@@ -111,7 +104,10 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals
// every registered repo. An owner with no listing yet (`listed === null`) is
// always included, so a first rebuild is unchanged.
.filter((entry) => shouldRelistOwner(entry.owner, onlyDirty))
const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo))
const listings = await listWorktreeRootsWithConcurrency(
store,
pending.map((entry) => entry.repo)
)
const results = pending.map((entry, index) => ({ ...entry, ...listings[index] }))
const isCurrent = (entry: (typeof pending)[number]): boolean =>
generation === invalidationGeneration &&
@@ -134,6 +130,7 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals
continue
}
entry.owner.listed = entry.roots
entry.owner.listedWslDistro = entry.wslDistro
entry.owner.dirty = false
entry.owner.recovered = entry.recovered
advanceOwner(entry.owner)
@@ -155,24 +152,23 @@ export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string)
if (!owner) {
return false
}
owner.listed = null
owner.dirty = true
advanceOwner(owner)
refreshRegisteredWorktreeRoots()
registeredWorktreeRootsDirty = true
relistOwners([owner])
return true
}
/** `listing.wslDistro` is the distro whose Git produced `worktreeRoots`; omitted is host Git. */
export function registerWorktreeRootsForRepo(
store: Store,
repo: Repo | string,
worktreeRoots: string[]
worktreeRoots: string[],
listing: { wslDistro?: string } = {}
): void {
const owner = resolveOwnerForRepo(store, repo)
if (!owner) {
return
}
owner.listed = new Set(worktreeRoots.map((root) => resolve(root)))
owner.listedWslDistro = listing.wslDistro
owner.dirty = false
advanceOwner(owner)
refreshRegisteredWorktreeRoots()
@@ -218,7 +214,16 @@ export function getRegisteredWorktreeRootsRevision(repoId: string): number {
export async function ensureAuthorizedRootsCache(store: Store): Promise<void> {
synchronizeOwners(store)
// Follow one superseded refresh; continuous catalog churn must not pin authorization forever.
for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) {
for (let attempt = 0; attempt < 2; attempt++) {
// Why re-derived each attempt: nothing invalidates this cache when a project's runtime changes,
// and a refresh that began before the change commits a listing from the old Git.
const drifted = findOwnersListedThroughAnotherGit(store, currentOwners, registeredOwners)
if (drifted.length > 0) {
relistOwners(drifted)
}
if (!registeredWorktreeRootsDirty) {
break
}
if (!registeredWorktreeRootsRefresh) {
registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store, true).finally(() => {
registeredWorktreeRootsRefresh = null
@@ -0,0 +1,159 @@
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { subscribeViaWatcherProcess, type WatcherProcessCallback } from './parcel-watcher-process'
import { startGitCommonPolling } from './worktree-git-common-polling'
import { startGitCommonNarrowWatch } from './worktree-git-common-narrow-watch'
import { createGitCommonWatchReconciliation } from './worktree-git-common-watch-reconciliation'
import type {
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({ isDirectory: () => true })) }))
vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: vi.fn() }))
vi.mock('./worktree-git-common-polling', () => ({ startGitCommonPolling: vi.fn() }))
const commonDirPath = resolve('repository', '.git')
const visibility: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
const pollMock = vi.mocked(startGitCommonPolling)
const subscribeMock = vi.mocked(subscribeViaWatcherProcess)
const baselines: {
settle: () => void
unsubscribe: ReturnType<typeof vi.fn<() => Promise<void>>>
onVisible: ReturnType<typeof vi.fn<() => void>>
}[] = []
const starts: Promise<WorktreeBaseSubscription>[] = []
const subscriptions: { callback: WatcherProcessCallback }[] = []
const reconciliations: ReturnType<typeof createGitCommonWatchReconciliation>[] = []
beforeEach(() => {
pollMock.mockReset()
subscribeMock.mockReset()
pollMock.mockImplementation((_path, _onEvents, _interval, pollVisibility) => {
const { promise, resolve } = Promise.withResolvers<WorktreeBaseSubscription>()
const onVisible = vi.fn<() => void>()
let removeVisibilityListener = (): void => {}
let ready = false
const unsubscribe = vi.fn(async () => removeVisibilityListener())
baselines.push({
settle: () => {
if (!ready) {
ready = true
removeVisibilityListener = pollVisibility.onWindowBecameVisible(onVisible)
resolve({ unsubscribe })
}
},
unsubscribe,
onVisible
})
return promise
})
subscribeMock.mockImplementation(async (_path, callback) => {
subscriptions.push({ callback })
return { unsubscribe: vi.fn<() => Promise<void>>().mockResolvedValue(undefined) }
})
})
afterEach(async () => {
for (const baseline of baselines) {
baseline.settle()
}
for (const starting of starts) {
await (await starting).unsubscribe()
}
for (const reconciliation of reconciliations) {
await reconciliation.unsubscribe()
}
baselines.length = 0
starts.length = 0
subscriptions.length = 0
reconciliations.length = 0
vi.useRealTimers()
})
function createReconciliation(shouldKeep = () => true) {
const reconciliation = createGitCommonWatchReconciliation({
commonDirPath,
pollIntervalMs: 10,
visibility,
canStart: () => true,
shouldKeep,
onRootReplacement: vi.fn(),
onEvents: vi.fn()
})
reconciliations.push(reconciliation)
return reconciliation
}
describe('Git common watcher reconciliation ownership', () => {
it('shares one pending baseline between concurrent starts', async () => {
const reconciliation = createReconciliation()
const first = reconciliation.ensureStarted()
const second = reconciliation.ensureStarted()
expect(pollMock).toHaveBeenCalledOnce()
baselines[0]?.settle()
await Promise.all([first, second])
reconciliation.notifyWindowBecameVisible()
expect(baselines[0]?.onVisible).toHaveBeenCalledOnce()
await reconciliation.unsubscribe()
expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce()
})
it('keeps one poller when a native failure rearms during the initial baseline', async () => {
vi.useFakeTimers()
const starting = startGitCommonNarrowWatch(
{ key: 'git-common', kind: 'git-common', path: commonDirPath, repos: new Map() },
vi.fn(),
10,
'darwin',
visibility
)
starts.push(starting)
await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce())
subscriptions[0]?.callback(new Error('root failed during baseline'), [])
await vi.advanceTimersByTimeAsync(20)
expect(subscribeMock).toHaveBeenCalledTimes(2)
expect(subscribeMock.mock.calls[1]?.[0]).toBe(join(commonDirPath, 'worktrees'))
expect(pollMock).toHaveBeenCalledOnce()
baselines[0]?.settle()
const subscription = await starting
await vi.advanceTimersByTimeAsync(0)
await subscription.unsubscribe()
expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce()
})
it.each([true, false])(
'waits for and releases a late baseline when shouldKeep returns %s',
async (keep) => {
const reconciliation = createReconciliation(() => keep)
const starting = reconciliation.ensureStarted()
const stopping = reconciliation.unsubscribe()
const stopped = vi.fn()
void stopping.then(stopped)
await Promise.resolve()
await Promise.resolve()
expect(stopped).not.toHaveBeenCalled()
baselines[0]?.settle()
await Promise.all([starting, stopping])
expect(stopped).toHaveBeenCalledOnce()
expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce()
reconciliation.notifyWindowBecameVisible()
expect(baselines[0]?.onVisible).not.toHaveBeenCalled()
}
)
it('retries a rejected baseline and releases the successful retry', async () => {
pollMock.mockRejectedValueOnce(new Error('baseline failed'))
const reconciliation = createReconciliation()
await expect(reconciliation.ensureStarted()).rejects.toThrow('baseline failed')
const retry = reconciliation.ensureStarted()
expect(pollMock).toHaveBeenCalledTimes(2)
baselines[0]?.settle()
await retry
await reconciliation.unsubscribe()
expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -5,6 +5,7 @@ import type {
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
import { startGitCommonPolling } from './worktree-git-common-polling'
import { createSingleFlight } from './single-flight-promise'
// The native stream is still the fast path. A scheduled 15-tick reconciliation
// bounds silent watcher loss at the existing 30-second backstop without joining
@@ -38,6 +39,8 @@ export function createGitCommonWatchReconciliation({
}: GitCommonWatchReconciliationOptions): GitCommonWatchReconciliation {
const worktreesDir = join(commonDirPath, 'worktrees')
let subscription: WorktreeBaseSubscription | null = null
const starting = createSingleFlight()
let generation = 0
const visibilityListeners = new Set<() => void>()
const pollVisibility: WorktreePollerWindowVisibility = {
isWindowVisible: visibility.isWindowVisible,
@@ -50,57 +53,60 @@ export function createGitCommonWatchReconciliation({
}
return {
ensureStarted: async () => {
ensureStarted: () => {
if (subscription || !canStart()) {
return
return Promise.resolve()
}
const reconciliation = await startGitCommonPolling(
commonDirPath,
(events) => {
const rootWasReplaced =
events.some((event) => event.type === 'delete' && event.path === worktreesDir) &&
events.some((event) => event.type === 'create' && event.path === worktreesDir)
// Why: this backstop lags the native stream by up to 15 ticks, so it
// routinely reports entry creates the stream already delivered. Only
// treat them as a replacement when the root itself was also recreated
// — otherwise every ordinary `git worktree add` would tear down a
// healthy stream and open a deaf window while it resubscribes.
const rootRecreated = events.some(
(event) => event.type === 'create' && event.path === worktreesDir
)
const coarseRootReplacement =
rootRecreated &&
events.some(
(event) =>
event.type === 'create' &&
event.path !== worktreesDir &&
dirname(event.path) === worktreesDir
return starting.run(async () => {
const startGeneration = generation
const reconciliation = await startGitCommonPolling(
commonDirPath,
(events) => {
const rootWasReplaced =
events.some((event) => event.type === 'delete' && event.path === worktreesDir) &&
events.some((event) => event.type === 'create' && event.path === worktreesDir)
// Why: this backstop lags the native stream by up to 15 ticks, so it
// routinely reports entry creates the stream already delivered. Only
// treat them as a replacement when the root itself was also recreated
// — otherwise every ordinary `git worktree add` would tear down a
// healthy stream and open a deaf window while it resubscribes.
const rootRecreated = events.some(
(event) => event.type === 'create' && event.path === worktreesDir
)
if (rootWasReplaced || coarseRootReplacement) {
onRootReplacement()
}
onEvents(
coarseRootReplacement
? events.map((event) =>
event.type === 'update' && event.path === worktreesDir
? { ...event, type: 'create' }
: event
)
: events
)
},
pollIntervalMs * NARROW_WATCH_RECONCILIATION_TICKS,
pollVisibility,
undefined,
false,
() => [],
{ forceFullScanEveryTick: true }
)
if (!shouldKeep()) {
await reconciliation.unsubscribe()
} else {
subscription = reconciliation
}
const coarseRootReplacement =
rootRecreated &&
events.some(
(event) =>
event.type === 'create' &&
event.path !== worktreesDir &&
dirname(event.path) === worktreesDir
)
if (rootWasReplaced || coarseRootReplacement) {
onRootReplacement()
}
onEvents(
coarseRootReplacement
? events.map((event) =>
event.type === 'update' && event.path === worktreesDir
? { ...event, type: 'create' }
: event
)
: events
)
},
pollIntervalMs * NARROW_WATCH_RECONCILIATION_TICKS,
pollVisibility,
undefined,
false,
() => [],
{ forceFullScanEveryTick: true }
)
if (startGeneration !== generation || !shouldKeep()) {
await reconciliation.unsubscribe()
} else {
subscription = reconciliation
}
})
},
notifyWindowBecameVisible: () => {
for (const listener of visibilityListeners) {
@@ -108,6 +114,8 @@ export function createGitCommonWatchReconciliation({
}
},
unsubscribe: async () => {
generation++
await starting.pending()?.catch(() => {})
const current = subscription
subscription = null
await current?.unsubscribe()
+27 -16
View File
@@ -148,7 +148,11 @@ import {
} from '../../shared/setup-runner-command'
import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing'
import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy'
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
import {
createWorktreeCreateTimingRecorder,
localWorktreeCreateExecutionHost,
type WorktreeCreateTimingRecorder
} from '../worktree-create-timing'
import {
getLocalProjectGitExecOptions,
getLocalProjectWorktreeGitOptions,
@@ -1717,9 +1721,10 @@ export async function createRemoteWorktree(
args: CreateWorktreeArgsWithSystemProvenance,
repo: Repo,
store: Store,
mainWindow: BrowserWindow
mainWindow: BrowserWindow,
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder()
): Promise<CreateWorktreeResult> {
const timing = createWorktreeCreateTimingRecorder()
timing.recordExecutionHost('ssh')
const provider = requireSshGitProvider(repo.connectionId!)
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
@@ -1976,7 +1981,8 @@ export async function createRemoteWorktree(
) {
// Why: only OLD relays (pre-allowlist-removal) throw these; surface an upgrade message. Remove after version floor moves (docs/relay-fs-allowlist-removal.md).
throw new Error(
`Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})`
`Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})`,
{ cause: err }
)
}
throw err
@@ -2030,6 +2036,7 @@ export async function createRemoteWorktree(
const gitWorktrees = await timing.time('list_created_worktree', async () =>
provider.listWorktrees(repo.path)
)
timing.recordWorktreeCount(gitWorktrees.length)
// Match the exact requested path first, then the exact branch ref. Suffix matching can
// select an older `prefix/<branchName>` worktree when the newly created row is present.
const created = findCreatedWorktree(gitWorktrees, remotePath, branchName)
@@ -2190,15 +2197,18 @@ export function createLocalWorktree(
repo: Repo,
store: Store,
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService
runtime?: OrcaRuntimeService,
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder()
): Promise<CreateWorktreeResult> {
// Why a holder fired in `finally`: consuming a prepared checkout leaves the pool one short, so a
// create that fails after that point — include copy, push target, terminal startup — must still
// arm the replacement. Fires exactly once, after startup on the success path.
const rearm: PreparationRearmHolder = { fire: () => {} }
return worktreeCreateGit
.run(() => performLocalWorktreeCreate(args, repo, store, mainWindow, rearm, runtime))
.run(() => performLocalWorktreeCreate(args, repo, store, mainWindow, rearm, timing, runtime))
.finally(() => {
// Closes the create's measured window first, so its own re-arm is not counted against it.
timing.finish()
rearm.fire()
})
}
@@ -2209,9 +2219,9 @@ async function performLocalWorktreeCreate(
store: Store,
mainWindow: BrowserWindow,
rearm: PreparationRearmHolder,
timing: WorktreeCreateTimingRecorder,
runtime?: OrcaRuntimeService
): Promise<CreateWorktreeResult> {
const timing = createWorktreeCreateTimingRecorder()
const settings = store.getSettings()
const worktreePathSettings = getWorktreePathSettings(
repo,
@@ -2220,6 +2230,7 @@ async function performLocalWorktreeCreate(
)
const localGitExecOptions = getLocalProjectGitExecOptions(store, repo)
const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
timing.recordExecutionHost(localWorktreeCreateExecutionHost(localGitExecOptions))
const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0
const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions
? [localWorktreeGitOptions]
@@ -2628,11 +2639,6 @@ async function performLocalWorktreeCreate(
options: preparedWorktreeOptions,
timing
})
timing.recordPreparedCheckout(
prepared.status === 'hit'
? { status: 'hit', retargeted: prepared.retargeted }
: { status: 'miss', reason: prepared.reason }
)
if (prepared.status === 'hit') {
// Why deferred: re-arming is a full `reset --hard`; started here it would hold a
// general admission slot for the rest of this create's own git.
@@ -2750,6 +2756,9 @@ async function performLocalWorktreeCreate(
} = await timing.time('list_created_worktree', async () =>
resolveCreatedWorktree(repo.path, worktreePath, branchName, localWorktreeGitOptions)
)
if (listingComplete) {
timing.recordWorktreeCount(gitWorktrees.length)
}
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
@@ -2828,10 +2837,12 @@ async function performLocalWorktreeCreate(
// Why gated: registration replaces the repo's root set, so registering a create recovered without
// a listing would revoke filesystem access to every worktree that listing would have named.
if (listingComplete) {
registerWorktreeRootsForRepo(store, repo, [
repo.path,
...gitWorktrees.map((worktree) => worktree.path)
])
registerWorktreeRootsForRepo(
store,
repo,
[repo.path, ...gitWorktrees.map((worktree) => worktree.path)],
localWorktreeGitOptions
)
} else {
// Recovered without a listing: authorize just the new root, or the create the user just made
// is rejected by filesystem/git-status IPC until a full scan repopulates the cache.
@@ -0,0 +1,359 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
addWorktreeMock,
getActiveMultiplexerMock,
getSshGitProviderMock,
gitExecFileAsyncMock,
listWorktreesMock,
setPlatform
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { beginPreparationWork } from '../worktree-create-concurrency'
const { trackMock, probeHookMock, telemetryEnabledMock } = vi.hoisted(() => ({
trackMock: vi.fn<(name: string, props: Record<string, unknown>) => void>(),
probeHookMock: vi.fn(),
telemetryEnabledMock: vi.fn(() => true)
}))
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
vi.mock('../git/worktree', async () =>
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
)
vi.mock('../git/runner', async () =>
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
)
vi.mock('../git/repo', async () =>
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
)
vi.mock('../git/git-username', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
.resolveLocalGitUsernameMock
}))
vi.mock('../github/client', async () =>
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
)
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-git-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
vi.mock('../ports/advertised-url-watcher', async () =>
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
)
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
)
vi.mock('../workspace-space-analysis-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
)
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
)
vi.mock('../runtime/worktree-teardown', async () =>
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
vi.mock('../telemetry/client', () => ({
track: trackMock,
isTelemetryEnabled: telemetryEnabledMock
}))
vi.mock('../git/create-event-repo-probe', () => ({
probeCreateEventRepoFacts: probeHookMock
}))
function makeRepo(fields: Record<string, unknown>) {
return {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: 'origin/main',
...fields
}
}
function useRepo(repo: ReturnType<typeof makeRepo>): void {
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta)
getActiveMultiplexerMock.mockReturnValue({
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
})
}
function useLocalListing(): void {
listWorktreesMock.mockResolvedValue([
{ path: '/workspace/repo', head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{ path: '/workspace/wt', head: 'abc123', branch: 'wt', isBare: false, isMainWorktree: false }
])
}
function useSshProvider() {
useRepo(makeRepo({ path: '/remote/repo', executionHostId: 'ssh:target-a' }))
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'show-ref') {
throw Object.assign(new Error('missing exact ref'), { code: 1 })
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo-wt',
head: 'abc',
branch: 'refs/heads/wt',
isBare: false,
isMainWorktree: false
}
])
}
getSshGitProviderMock.mockImplementation((connectionId: string) =>
connectionId === 'target-a' ? provider : undefined
)
return provider
}
function trackedEvents(name: string): Record<string, unknown>[] {
return trackMock.mock.calls.filter(([eventName]) => eventName === name).map(([, props]) => props)
}
function trackedEvent(name: string): Record<string, unknown> | undefined {
const call = trackMock.mock.calls.find(([eventName]) => eventName === name)
return call?.[1]
}
function gitWorkCallCount(): number {
return (
gitExecFileAsyncMock.mock.calls.length +
addWorktreeMock.mock.calls.length +
listWorktreesMock.mock.calls.length
)
}
describe('worktrees:create event timing fields', () => {
beforeEach(() => {
setupWorktreeHandlers()
trackMock.mockReset()
probeHookMock.mockReset()
telemetryEnabledMock.mockReset()
telemetryEnabledMock.mockReturnValue(true)
})
it('sends timing after the create returns, without any further git work', async () => {
useRepo(makeRepo({}))
useLocalListing()
let resolveProbe: (value: {
postCheckoutHook: string
indexEntryCount?: number
}) => void = () => {}
probeHookMock.mockReturnValue(
new Promise((resolve) => {
resolveProbe = resolve
})
)
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
// The create already answered; the hook probe has not, so the event is not sent yet.
expect(trackedEvent('workspace_created')).toBeUndefined()
const gitCallsAtReturn = gitWorkCallCount()
resolveProbe({ postCheckoutHook: 'present', indexEntryCount: 6_000 })
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
expect(gitWorkCallCount()).toBe(gitCallsAtReturn)
expect(probeHookMock).toHaveBeenCalledWith('/workspace/repo')
const props = trackedEvent('workspace_created')
expect(props).toMatchObject({
source: 'unknown',
from_existing_branch: false,
create_entry_point: 'app',
execution_host: 'local',
worktree_count_bucket: '2-5',
concurrent_creates: 0,
concurrent_preparations: 0,
repo_file_count_bucket: '1k-10k',
post_checkout_hook: 'present'
})
// One create, one event: the runtime entry point never runs for an app create.
expect(trackedEvents('workspace_created')).toHaveLength(1)
expect(trackedEvents('workspace_create_failed')).toHaveLength(0)
expect(typeof props?.total_ms).toBe('number')
expect(typeof props?.git_worktree_add_ms).toBe('number')
expect(props).toHaveProperty('prepared_checkout')
// Nothing that names the repo, the branch or a path rides along.
expect(JSON.stringify(props)).not.toMatch(/workspace|wt|repo-1/)
})
it('counts prepared-checkout work that ran alongside the create', async () => {
useRepo(makeRepo({}))
useLocalListing()
probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' })
const build = beginPreparationWork()
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
build.end()
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
expect(trackedEvent('workspace_created')).toMatchObject({ concurrent_preparations: 1 })
})
it('still answers the create, or its error, when sending the event throws', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
useRepo(makeRepo({}))
useLocalListing()
probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' })
trackMock.mockImplementation(() => {
throw new Error('telemetry broke')
})
await expect(
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
).resolves.toHaveProperty('worktree')
addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir'))
await expect(
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
).rejects.toThrow('could not create work tree dir')
})
it('does not read the repo for hooks when telemetry is off', async () => {
useRepo(makeRepo({}))
useLocalListing()
telemetryEnabledMock.mockReturnValue(false)
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
expect(probeHookMock).not.toHaveBeenCalled()
expect(trackedEvent('workspace_created')).not.toHaveProperty('post_checkout_hook')
})
it.each([
{ repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', host: 'wsl' },
{ repoPath: '/workspace/repo', host: 'local' }
])(
'labels $repoPath as $host from where Git runs, with no WSL project runtime',
async ({ repoPath, host }) => {
setPlatform('win32')
useRepo(makeRepo({ path: repoPath }))
useLocalListing()
probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' })
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
expect(trackedEvent('workspace_created')).toMatchObject({ execution_host: host })
}
)
it('records an SSH create without probing the remote for hooks', async () => {
useSshProvider()
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
expect(probeHookMock).not.toHaveBeenCalled()
const props = trackedEvent('workspace_created')
expect(props).toMatchObject({ execution_host: 'ssh', worktree_count_bucket: '1' })
expect(props).not.toHaveProperty('post_checkout_hook')
expect(props).not.toHaveProperty('prepared_checkout')
})
it('attributes an old-relay SSH add error to the add through its cause', async () => {
const provider = useSshProvider()
provider.addWorktree.mockRejectedValue(new Error('Path outside authorized workspace: /x'))
let caught: unknown
try {
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
expect(caught instanceof Error && caught.message).toMatch(/^Older relay reported/)
expect(caught instanceof Error && caught.cause).toBeInstanceOf(Error)
expect(trackedEvent('workspace_create_failed')).toMatchObject({
failed_phase: 'git_worktree_add',
execution_host: 'ssh'
})
})
it('names the phase a failed create died in', async () => {
useRepo(makeRepo({}))
useLocalListing()
addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir'))
await expect(
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
).rejects.toThrow()
const props = trackedEvent('workspace_create_failed')
expect(props).toMatchObject({
failed_phase: 'git_worktree_add',
create_entry_point: 'app',
execution_host: 'local',
concurrent_creates: 0,
concurrent_preparations: 0,
prepared_checkout: 'miss',
prepared_checkout_miss_reason: 'none_armed'
})
expect(trackedEvents('workspace_create_failed')).toHaveLength(1)
expect(trackedEvents('workspace_created')).toHaveLength(0)
expect(typeof props?.total_ms).toBe('number')
expect(JSON.stringify(props)).not.toMatch(/fatal|work tree/)
})
})
@@ -32,6 +32,7 @@ import { createFolderWorkspace } from './folder-workspace-creation'
import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership'
import { requireWorktreeCreateRoute } from '../../../worktree-create-execution-host-route'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { beginWorkspaceCreateTelemetry } from '../../../workspace-create-telemetry'
export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void {
const { mainWindow, store, runtime, options } = context
@@ -47,9 +48,6 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
throw new Error(`Repo not found: ${args.repoId}`)
}
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
const automationProvenance = resolveAutomationWorkspaceProvenance({
authority: runtime,
repoSelector: args.repoId,
@@ -62,6 +60,14 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
}
let result: CreateWorktreeResult
// The handler owns the events so a failed create can still say where it died.
const telemetry = beginWorkspaceCreateTelemetry({
source: args.telemetrySource,
entryPoint: 'app',
repoPath: repo.path,
fromExistingBranch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0,
isFolder: isFolderRepo(repo)
})
try {
// Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks.
if (isFolderRepo(repo)) {
@@ -74,16 +80,25 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
const createRoute = requireWorktreeCreateRoute(repo)
result =
createRoute.kind === 'ssh'
? await createRemoteWorktree(createArgs, createRoute.repo, store, mainWindow)
: await createLocalWorktree(createArgs, repo, store, mainWindow, runtime)
? await createRemoteWorktree(
createArgs,
createRoute.repo,
store,
mainWindow,
telemetry.timing
)
: await createLocalWorktree(
createArgs,
repo,
store,
mainWindow,
runtime,
telemetry.timing
)
}
} catch (error) {
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
track('workspace_create_failed', {
source,
error_class: classifyWorkspaceCreateError(error),
...getCohortAtEmit()
})
telemetry.failed(error)
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
@@ -92,14 +107,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
}
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
track('workspace_created', {
source,
from_existing_branch:
!isFolderRepo(repo) &&
typeof args.baseBranch === 'string' &&
args.baseBranch.length > 0,
...getCohortAtEmit()
})
telemetry.succeeded(result.timing)
if (isFolderRepo(repo)) {
notifyWorktreesChanged(mainWindow, repo.id)
@@ -47,6 +47,8 @@ export type DetectedWorktreeScan = {
export type DetectedWorktreeSideEffectToken = Readonly<{
generation: number
authorizedRootsRevision: number
/** The distro whose Git listed the scan; undefined is host Git. */
wslDistro?: string
}>
export type DetectedWorktreeMetadataPrune = Readonly<{
@@ -173,7 +175,7 @@ export async function listDetectedGitWorktrees(
const scan: DetectedWorktreeScan = {
invalidated: false,
promise: listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions),
sideEffectToken: { generation, authorizedRootsRevision },
sideEffectToken: { generation, authorizedRootsRevision, ...localWorktreeGitOptions },
hygieneDue,
...(metadataPruneExpectation
? {
@@ -269,7 +271,7 @@ export async function applyFreshDetectedWorktreeScanSideEffects(
) {
return false
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
rememberLocalWorktreeRoots(store, repo, gitWorktrees, sideEffectToken)
// Why: lineage retention is decided against the metadata rows the prune preserved, so running it
// without that pass would drop lineage for rows the pass would have kept. Both halves share the
// hygiene cadence instead.
@@ -294,14 +296,17 @@ export function getDetectedWorktreeScanCacheKey(
export function rememberLocalWorktreeRoots(
store: Store,
repo: Repo,
gitWorktrees: GitWorktreeInfo[]
gitWorktrees: GitWorktreeInfo[],
listing: { wslDistro?: string } = {}
): void {
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
return
}
// Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts.
registerWorktreeRootsForRepo(store, repo, [
repo.path,
...gitWorktrees.map((worktree) => worktree.path)
])
registerWorktreeRootsForRepo(
store,
repo,
[repo.path, ...gitWorktrees.map((worktree) => worktree.path)],
listing
)
}
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { Store } from '../../../persistence/loading-store/store'
const mocks = vi.hoisted(() => ({
list: vi.fn(),
gitOptions: vi.fn(),
registerRoots: vi.fn()
}))
vi.mock('../../../repo-worktrees', () => ({ listRepoWorktreesForDetectedScan: mocks.list }))
vi.mock('../../../project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: mocks.gitOptions
}))
vi.mock('../../registered-worktree-roots-cache', () => ({
getRegisteredWorktreeRootsRevision: () => 1,
registerWorktreeRootsForRepo: mocks.registerRoots
}))
vi.mock('../../../worktree-lineage-pruning', () => ({
pruneLineageForMissingRepoWorktrees: vi.fn()
}))
const {
__resetDetectedWorktreeScanCacheForTests,
applyFreshDetectedWorktreeScanSideEffects,
listDetectedGitWorktrees
} = await import('./detected-worktree-scan-cache')
const repo: Repo = {
id: 'repo-1',
path: '/repos/one',
displayName: 'one',
badgeColor: '#000',
addedAt: 0
}
const worktree = { path: '/repos/one-feature', head: 'abc', branch: 'feature', isBare: false }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a first local scan reads only this store method; routing, listing and registration are mocked.
const store = { captureNativeLocalWorktreeMetadataScanExpectation: vi.fn() } as unknown as Store
async function scanAndRegister(): Promise<void> {
const scan = await listDetectedGitWorktrees(store, repo)
await applyFreshDetectedWorktreeScanSideEffects(store, repo, scan.gitWorktrees, undefined, {
sideEffectToken: scan.sideEffectToken,
hygieneDue: false
})
}
describe('detected worktree scan root registration', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.list.mockResolvedValue([{ ...worktree, isMainWorktree: false }])
__resetDetectedWorktreeScanCacheForTests()
})
it('records the distro whose Git listed the scan', async () => {
mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' })
await scanAndRegister()
expect(mocks.registerRoots).toHaveBeenCalledWith(
store,
repo,
[repo.path, worktree.path],
expect.objectContaining({ wslDistro: 'Ubuntu-24.04' })
)
})
it('records host Git for a scan listed without a distro', async () => {
mocks.gitOptions.mockReturnValue({})
await scanAndRegister()
expect(mocks.registerRoots).toHaveBeenCalledTimes(1)
expect(mocks.registerRoots.mock.calls[0]?.[3]?.wslDistro).toBeUndefined()
})
})
+11 -4
View File
@@ -219,16 +219,23 @@ describe('addWorktreeCreatePhaseAttributes', () => {
expect(attributes['worktree.create.unattributed_ms']).toBe(200)
})
it('records a prepared-checkout hit and whether it had to be retargeted', () => {
it('records a prepared-checkout hit, the reset it needed and who armed it', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 900,
phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }],
preparedCheckout: { status: 'hit', retargeted: true }
preparedCheckout: {
status: 'hit',
reset: 'retargeted',
origin: 'rearm',
buildMs: 1,
idleMs: 0
}
})
expect(attributes['worktree.create.prepared_checkout']).toBe('hit')
expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true)
expect(attributes['worktree.create.prepared_checkout_reset']).toBe('retargeted')
expect(attributes['worktree.create.prepared_checkout_origin']).toBe('rearm')
expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined()
expect(attributes['worktree.create.unattributed_ms']).toBe(500)
})
@@ -243,7 +250,7 @@ describe('addWorktreeCreatePhaseAttributes', () => {
expect(attributes['worktree.create.prepared_checkout']).toBe('miss')
expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch')
expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined()
expect(attributes['worktree.create.prepared_checkout_reset']).toBeUndefined()
})
it('stays silent on paths that never consult the prepared checkout', () => {
+12 -9
View File
@@ -270,11 +270,9 @@ export function addWorktreeCreatePhaseAttributes(
if (timing.preparedCheckout) {
span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status)
if (timing.preparedCheckout.status === 'hit') {
// A retargeted hit still pays a reset, so it must not be read as a free hit.
span.setAttribute(
'worktree.create.prepared_checkout_retargeted',
timing.preparedCheckout.retargeted
)
// A hit that had to reset still paid for it, so it must not be read as a free hit.
span.setAttribute('worktree.create.prepared_checkout_reset', timing.preparedCheckout.reset)
span.setAttribute('worktree.create.prepared_checkout_origin', timing.preparedCheckout.origin)
} else {
span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason)
}
@@ -284,10 +282,15 @@ export function addWorktreeCreatePhaseAttributes(
}
// What the phases do not cover is the number that matters when create feels slow for no visible
// reason, so name it rather than leaving it to subtraction.
span.setAttribute(
'worktree.create.unattributed_ms',
Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases)))
)
span.setAttribute('worktree.create.unattributed_ms', worktreeCreateUnattributedMs(timing))
}
/** Wall-clock time of a create that no timed phase covers; overlapping phases count once. */
export function worktreeCreateUnattributedMs(timing: {
totalDurationMs: number
phases: readonly WorktreePhaseInterval[]
}): number {
return Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases)))
}
/** Closed set so a typo can't silently mint an orphan span name. */
+7
View File
@@ -279,6 +279,13 @@ async function startOrcadRuntime(
})
getAppEnvironment().onWillQuit(() => sessionSearch?.dispose())
// Why: this host evaluates its own panes, so it keeps its own rules current.
const { startAgentStateRulesLiveUpdates } =
await import('../runtime/agent-state-rules/agent-state-rules-live-update')
startAgentStateRulesLiveUpdates(profileStore, (rules) =>
console.info(`[orcad] agent state rules ${rules.version} (${rules.source})`)
)
// Why here too and not only on the desktop: nothing else republishes `session.tabs` when a
// pane's status row changes, and orcad's whole job is serving paired clients.
uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish(
@@ -37,6 +37,10 @@ vi.mock('./orcad-daemon-supervision', () => ({
stopOrcadDaemon: async () => {}
}))
vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) }))
// Why: the real updater would fetch rules from GitHub inside a unit test.
vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({
startAgentStateRulesLiveUpdates: () => {}
}))
vi.mock('../daemon/daemon-init', () => ({ daemonOwnsFreshPersistentPtys: () => false }))
vi.mock('../ipc/pty', () => ({
registerHeadlessPtyRuntime: async () => {},
@@ -20,7 +20,9 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map<string, number>([
['main/rate-limits/minimax/minimax-request-context.ts', 2],
// Injected HttpClient, not a session: resolves to net.fetch on defaultSession
// (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback.
['main/jira/authenticated-request.ts', 1]
['main/jira/authenticated-request.ts', 1],
// The same injected HttpClient, and the updater's deps.fetch that it is passed as.
['main/runtime/agent-state-rules/agent-state-rules-live-update.ts', 2]
])
// `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts.
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-02T05:46:28.602Z",
"platform": "darwin",
"command": ["codex", "--no-daemon", "-c", "check_for_update_on_startup=false"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.160.0, isolated HOME/CODEX_HOME with the project pre-trusted. A Plan-mode turn ends in the 'Implement this plan?' menu, which owns the screen until '3' (No, stay in Plan mode) answers it at 70s; cut 25s later at the empty composer.",
"exitCode": 0
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
{
"capturedAt": "2026-10-01T05:58:52.162Z",
"platform": "darwin",
"command": [
"/bin/zsh",
"-f",
"-c",
"cd <isolated-capture-cwd> && exec <bun> <isolated-omp-18.4.5>/dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk"
],
"cols": 60,
"rows": 24,
"note": "OMP 18.4.5; isolated HOME; no credentials; previously skipped setup; narrow 60x24 composer; updates disabled; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements",
"exitCode": 129,
"sends": []
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
{
"capturedAt": "2026-10-01T05:52:51.152Z",
"platform": "darwin",
"command": [
"/bin/zsh",
"-f",
"-c",
"cd <isolated-capture-cwd> && exec <bun> <isolated-omp-18.4.5>/dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk"
],
"cols": 120,
"rows": 40,
"note": "OMP 18.4.5; isolated HOME; no credentials; all five optional setup steps skipped with Esc; updates disabled; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements",
"exitCode": 129,
"sends": [
{
"atMs": 12000,
"text": "Esc"
},
{
"atMs": 14000,
"text": "Esc"
},
{
"atMs": 16000,
"text": "Esc"
},
{
"atMs": 18000,
"text": "Esc"
},
{
"atMs": 20000,
"text": "Esc"
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
{
"capturedAt": "2026-10-01T05:49:50.289Z",
"platform": "darwin",
"command": [
"/bin/zsh",
"-f",
"-c",
"cd <isolated-capture-cwd> && exec <bun> <isolated-omp-18.4.5>/dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk"
],
"cols": 120,
"rows": 40,
"note": "OMP 18.4.5 isolated HOME, no credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements",
"exitCode": 129,
"sends": []
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
{
"description": "codex recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"22-36: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"37-44: now=working edge=working quiet=ready-strong wait=ready@start",
"45-54: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"55-60: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"61-111: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"112-118: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"119-121: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"122-123: now=working edge=working quiet=ready-strong wait=ready@start",
"124-180: now=working edge=working quiet=working wait=pending",
"181-212: now=working edge=working quiet=ready-strong wait=ready@start",
"213-435: now=working edge=working quiet=working wait=pending",
"436-500: now=working edge=working quiet=ready-strong wait=ready@start",
"501-502: now=working edge=working quiet=working wait=pending",
"503-507: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"508-547: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"548-551: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"552-558: now=blocked:agent-interactive-prompt edge=blocked:agent-interactive-prompt quiet=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start",
"559-596: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"597-601: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-36: verdict=pending:closed wait=pending",
"37-44: verdict=working wait=pending",
"45-121: verdict=pending:closed wait=pending",
"122-502: verdict=working wait=pending",
"503-551: verdict=pending:closed wait=pending",
"552-558: verdict=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start",
"559-601: verdict=pending:closed wait=pending"
]
}
}
@@ -0,0 +1,35 @@
{
"description": "codex recording at 120x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-36: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"37-44: now=working edge=working quiet=ready-strong wait=ready@start",
"45-54: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"55-60: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"61-111: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"112-118: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"119-121: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"122-123: now=working edge=working quiet=ready-strong wait=ready@start",
"124-180: now=working edge=working quiet=working wait=pending",
"181-212: now=working edge=working quiet=ready-strong wait=ready@start",
"213-435: now=working edge=working quiet=working wait=pending",
"436-500: now=working edge=working quiet=ready-strong wait=ready@start",
"501-502: now=working edge=working quiet=working wait=pending",
"503-507: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"508-547: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"548-551: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"552-558: now=blocked:agent-interactive-prompt edge=blocked:agent-interactive-prompt quiet=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start",
"559-596: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"597-601: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-36: verdict=pending:open wait=ready@poll",
"37-44: verdict=working wait=pending",
"45-121: verdict=pending:closed wait=pending",
"122-502: verdict=working wait=pending",
"503-551: verdict=pending:closed wait=pending",
"552-558: verdict=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start",
"559-601: verdict=pending:closed wait=pending"
]
}
}
@@ -0,0 +1,15 @@
{
"description": "omp recording at 60x24 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-303: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"304-305: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"306-364: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start",
"365-707: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-303: verdict=pending:closed wait=pending",
"304-707: verdict=ready-strong wait=ready@start"
]
}
}
@@ -0,0 +1,15 @@
{
"description": "omp recording at 60x24 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-303: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"304-305: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"306-364: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start",
"365-707: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-303: verdict=pending:open wait=ready@poll",
"304-707: verdict=ready-strong wait=ready@start"
]
}
}
@@ -0,0 +1,19 @@
{
"description": "omp recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-510: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"511: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start",
"512-570: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start",
"571-587: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start",
"588-25926: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"25927-26866: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-510: verdict=pending:closed wait=pending",
"511-587: verdict=ready-strong wait=ready@start",
"588-25926: verdict=pending:closed wait=pending",
"25927-26866: verdict=ready-strong wait=ready@start"
]
}
}

Some files were not shown because too many files have changed in this diff Show More