mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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*'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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' }}"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
+36
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+35
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+15
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+15
@@ -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
Reference in New Issue
Block a user