mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/nc-reasoning-row
# Conflicts: # mobile/src/session/MobileNativeChatMessage.tsx # src/main/codex/codex-structured-item-streams.ts # src/renderer/src/i18n/locales/en.json # src/renderer/src/i18n/locales/es.json # src/renderer/src/i18n/locales/fr.json # src/renderer/src/i18n/locales/ja.json # src/renderer/src/i18n/locales/ko.json # src/renderer/src/i18n/locales/zh.json # src/shared/agent-session-journal-schemas.ts
This commit is contained in:
@@ -63,9 +63,10 @@ CI enforces this for `src/preload/` and `src/shared/`.
|
||||
|
||||
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
|
||||
|
||||
- if you are an outside contributor, link the issue your PR addresses
|
||||
- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner)
|
||||
- explain what changed and why, and stay focused on a single topic when possible
|
||||
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why
|
||||
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual or interaction change, write `N/A` and briefly explain why
|
||||
- include high-quality tests when behavior changes or bug fixes warrant them
|
||||
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
|
||||
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
|
||||
|
||||
@@ -2,6 +2,14 @@ name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching.
|
||||
|
||||
inputs:
|
||||
cache-pnpm-store:
|
||||
description: Restore or save the pnpm download store; verification and native caches are independent.
|
||||
required: false
|
||||
default: 'true'
|
||||
cache-pnpm-store-lookup-only:
|
||||
description: Auto uses measured hosted Node 24 root installs; true forces lookup, false retains archive restoration.
|
||||
required: false
|
||||
default: auto
|
||||
cache-pnpm-verification:
|
||||
description: Restore pnpm's policy-checked lockfile verification record.
|
||||
required: false
|
||||
@@ -28,9 +36,12 @@ inputs:
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
pnpm-store-cache-hit:
|
||||
description: Whether the requested download store matched an existing cache.
|
||||
value: ${{ steps.pnpm-store-lookup.outputs.cache-hit || steps.pnpm-store-restore.outputs.cache-hit || steps.requested-node.outputs.cache-hit || steps.default-node.outputs.cache-hit }}
|
||||
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 }}
|
||||
@@ -56,6 +67,30 @@ outputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve pnpm store mode
|
||||
id: pnpm-store-mode
|
||||
if: >-
|
||||
github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' &&
|
||||
(inputs.cache-pnpm-store-lookup-only == 'true' ||
|
||||
(inputs.cache-pnpm-store-lookup-only == 'auto' &&
|
||||
inputs.cache-dependency-path == 'pnpm-lock.yaml' &&
|
||||
runner.environment == 'github-hosted' && job.container.id == '' &&
|
||||
(runner.os == 'Linux' || runner.os == 'macOS' || runner.os == 'Windows') &&
|
||||
(runner.arch == 'X64' || runner.arch == 'ARM64') &&
|
||||
(inputs.node-version == '' || inputs.node-version == '24')))
|
||||
shell: bash
|
||||
env:
|
||||
LOOKUP_REQUEST: ${{ inputs.cache-pnpm-store-lookup-only }}
|
||||
run: |
|
||||
lookup_only=true
|
||||
case "$LOOKUP_REQUEST" in
|
||||
[aA][uU][tT][oO])
|
||||
# Hosted runners have Node for this manifest-only check before toolchain setup.
|
||||
lookup_only="$(node -p 'const p = require("./package.json"); p.engines?.node === "24" && typeof p.packageManager === "string" && p.packageManager.split("+")[0] === "pnpm@12.8.1"')"
|
||||
;;
|
||||
esac
|
||||
printf 'lookup-only=%s\n' "$lookup_only" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# setup-node needs pnpm on PATH to locate and restore its store.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
@@ -69,7 +104,7 @@ runs:
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
|
||||
cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}
|
||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||
package-manager-cache: false
|
||||
|
||||
@@ -79,7 +114,7 @@ runs:
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
|
||||
cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}
|
||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||
package-manager-cache: false
|
||||
|
||||
@@ -87,51 +122,58 @@ runs:
|
||||
- name: Resolve pnpm download store
|
||||
id: pnpm-store
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml'))
|
||||
github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' &&
|
||||
!((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') &&
|
||||
(runner.os != 'Windows' ||
|
||||
!(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) &&
|
||||
!((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) ||
|
||||
(github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' &&
|
||||
steps.pnpm-store-mode.outputs.lookup-only == 'true')
|
||||
shell: bash
|
||||
env:
|
||||
LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }}
|
||||
STORE_LOOKUP_ONLY: ${{ steps.pnpm-store-mode.outputs.lookup-only == 'true' }}
|
||||
run: |
|
||||
test -n "$LOCKFILE_HASH"
|
||||
cache_path="$(pnpm store path --silent)"
|
||||
test -n "$cache_path"
|
||||
printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT"
|
||||
printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT"
|
||||
if [ "$STORE_LOOKUP_ONLY" = 'true' ]; then
|
||||
printf 'ORCA_PNPM_STORE_CACHE_PATH=%s\n' "$cache_path" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
# Match setup-node's key and path so existing default-branch stores remain reusable.
|
||||
# Hosted Windows x64 mixed installs cost less than restoring their root/mobile store.
|
||||
# Direct downloads beat store restoration for the measured Linux, macOS and Windows installs.
|
||||
- name: Restore pnpm download store without saving
|
||||
id: pnpm-store-restore
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml'))
|
||||
github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' &&
|
||||
!((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') &&
|
||||
(runner.os != 'Windows' ||
|
||||
!(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) &&
|
||||
!((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
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"
|
||||
# Producers can refresh access and publish misses without downloading existing archives.
|
||||
- name: Keep pnpm download store without restoring
|
||||
id: pnpm-store-lookup
|
||||
if: steps.pnpm-store-mode.outputs.lookup-only == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
# Twice-nested composite cleanup loses internal step outputs.
|
||||
path: ${{ env.ORCA_PNPM_STORE_CACHE_PATH }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}
|
||||
lookup-only: true
|
||||
|
||||
- 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 +211,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:
|
||||
|
||||
@@ -10,7 +10,7 @@ runs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/orca-git-compat/git-2.25.5
|
||||
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
|
||||
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5-submodule
|
||||
|
||||
# Finish the CPU-heavy build before any timed compatibility lanes start.
|
||||
- name: Build the baseline Git binary
|
||||
@@ -18,7 +18,9 @@ runs:
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$HOME/.cache/orca-git-compat/git-2.25.5"
|
||||
if [ -x "$source/git" ]; then
|
||||
if [ -x "$source/git" ] && [ -x "$source/git-submodule" ] \
|
||||
&& [ -f "$source/git-sh-setup" ] && [ -f "$source/git-sh-i18n" ] \
|
||||
&& [ -f "$source/git-parse-remote" ] && [ -x "$source/git-sh-i18n--envsubst" ]; then
|
||||
exit 0
|
||||
fi
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
@@ -27,6 +29,7 @@ runs:
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j"$(nproc)" \
|
||||
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease \
|
||||
git git-submodule git-sh-setup git-sh-i18n git-parse-remote git-sh-i18n--envsubst
|
||||
# Object files are no longer needed after linking the cached binary.
|
||||
find "$source" -name '*.o' -delete
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Prepare headless detector compiler
|
||||
description: Reuse the policy-checked compiler from main; callers install normally on a miss.
|
||||
inputs:
|
||||
seed:
|
||||
description: Pack an already installed compiler instead of activating a cached compiler.
|
||||
default: 'false'
|
||||
outputs:
|
||||
available:
|
||||
description: Whether the cached compiler was validated and activated.
|
||||
value: ${{ steps.activate.outputs.available }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: identity
|
||||
shell: bash
|
||||
env:
|
||||
COMPILER_POLICY_HASH: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', 'config/patches/**', '.github/actions/install-node-dependencies/**', '.github/actions/restore-pnpm-verification/**', 'config/scripts/headless-detector-compiler-cache.mjs', '.github/actions/prepare-headless-compiler/action.yml') }}
|
||||
run: node config/scripts/headless-detector-compiler-cache.mjs identity
|
||||
- id: cache
|
||||
uses: actions/cache/restore@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ steps.identity.outputs.path }}
|
||||
key: ${{ steps.identity.outputs.key }}
|
||||
- id: activate
|
||||
if: inputs.seed != 'true' && steps.cache.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
COMPILER_CACHE_KEY: ${{ steps.identity.outputs.key }}
|
||||
COMPILER_CACHE_PATH: ${{ steps.identity.outputs.path }}
|
||||
run: node config/scripts/headless-detector-compiler-cache.mjs activate
|
||||
- name: Pack installed compiler
|
||||
if: inputs.seed == 'true' && steps.cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
COMPILER_CACHE_KEY: ${{ steps.identity.outputs.key }}
|
||||
COMPILER_CACHE_PATH: ${{ steps.identity.outputs.path }}
|
||||
run: node config/scripts/headless-detector-compiler-cache.mjs pack
|
||||
- name: Save compiler only from main
|
||||
if: inputs.seed == 'true' && steps.cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ steps.identity.outputs.path }}
|
||||
key: ${{ steps.identity.outputs.key }}
|
||||
@@ -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 }}
|
||||
@@ -13,9 +13,8 @@ function readRootEntries(sha) {
|
||||
return stdout.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
// Why: the Cloud workspace import is the one reviewed root addition; it stays
|
||||
// listed until it lands on main, after which the base tree carries it.
|
||||
const REVIEWED_ROOT_ENTRIES = new Set(['cloud'])
|
||||
// These reviewed additions stay listed until the base tree carries them.
|
||||
const REVIEWED_ROOT_ENTRIES = new Set(['cloud', 'opencode.json'])
|
||||
|
||||
function checkRootDirectoryEntries(argv) {
|
||||
if (argv.length !== 2) {
|
||||
|
||||
@@ -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,16 +1,20 @@
|
||||
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-headless-compiler/**'
|
||||
- 'config/scripts/headless-detector-compiler-cache*'
|
||||
- '.github/actions/prepare-linux-package-fixture/**'
|
||||
- 'config/docker/headless-serve-shutdown/**'
|
||||
- 'config/docker/cli-launch-contract/**'
|
||||
@@ -33,13 +37,14 @@ on:
|
||||
- 'src/shared/zip-extractor-command.ts'
|
||||
- 'config/scripts/shared-electron-dist-cache.mjs'
|
||||
- 'config/scripts/space-sharing-copy.mjs'
|
||||
- 'config/patches/node-pty@1.1.0.patch'
|
||||
- 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
- 'config/patches/**'
|
||||
- 'native/windows-registry/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-cache-warmup.yml'
|
||||
- '.github/actions/prepare-git-compatibility/**'
|
||||
- '.github/actions/prepare-headless-compiler/**'
|
||||
- 'config/scripts/headless-detector-compiler-cache*'
|
||||
- '.github/actions/prepare-linux-package-fixture/**'
|
||||
- 'config/docker/headless-serve-shutdown/**'
|
||||
- 'config/docker/cli-launch-contract/**'
|
||||
@@ -51,7 +56,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:
|
||||
@@ -71,6 +76,11 @@ jobs:
|
||||
native-runtime: node
|
||||
node-version: '24'
|
||||
cache-electron-package: 'true'
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
|
||||
- uses: ./.github/actions/prepare-headless-compiler
|
||||
with:
|
||||
seed: 'true'
|
||||
|
||||
- name: Populate shared Electron archive
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
@@ -104,6 +114,7 @@ jobs:
|
||||
native-runtime: node
|
||||
node-version: '24'
|
||||
cache-electron-package: 'true'
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
- name: Populate shared Electron archive
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
- name: Verify native cache is usable
|
||||
@@ -125,6 +136,7 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
- name: Verify native cache is usable
|
||||
run: node config/scripts/ensure-native-runtime.mjs --check-only
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
production:production-gce-c30) ;;
|
||||
production:production-gce-c31) ;;
|
||||
production:production-gce-c32,production-gce-c33) target_region=us-central1 ;;
|
||||
production:production-gce-c34) ;;
|
||||
*) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "TARGET_REGION=${target_region}" >> "${GITHUB_ENV}"
|
||||
|
||||
@@ -194,7 +194,7 @@ jobs:
|
||||
EXPECTED_REGION=us-central1
|
||||
EXPECTED_DATABASE_POOL_MAX=
|
||||
;;
|
||||
c27|c28|c29|c30|c31)
|
||||
c27|c28|c29|c30|c31|c34)
|
||||
EXPECTED_HARD_CAP=3000
|
||||
EXPECTED_REGION=asia-east2
|
||||
EXPECTED_DATABASE_POOL_MAX=16
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
version: 10.34.6
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
version: 10.34.6
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
|
||||
+21
-14
@@ -119,7 +119,22 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
env:
|
||||
ORCA_E2E_APT_PACKAGES: build-essential python3
|
||||
run: &install_e2e_tools |
|
||||
read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"
|
||||
for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
if [ -f "$source" ]; then
|
||||
sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source"
|
||||
fi
|
||||
done
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
timeout 120 sudo apt-get update
|
||||
timeout 300 sudo apt-get install -y "${packages[@]}"
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -178,19 +193,9 @@ jobs:
|
||||
- name: Install native build and headless UI tools
|
||||
# The Azure archive took 16 minutes for one font package; bound setup
|
||||
# separately so a slow mirror cannot consume the shard's test budget.
|
||||
run: &install_e2e_tools |
|
||||
for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
if [ -f "$source" ]; then
|
||||
sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source"
|
||||
fi
|
||||
done
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
timeout 120 sudo apt-get update
|
||||
timeout 300 sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
env: &e2e_tool_packages
|
||||
ORCA_E2E_APT_PACKAGES: build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
run: *install_e2e_tools
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -281,6 +286,7 @@ jobs:
|
||||
# unbounded inventory fallback; the paired fixture exercises that real boundary.
|
||||
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
env: *e2e_tool_packages
|
||||
run: *install_e2e_tools
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
@@ -417,6 +423,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
env: *e2e_tool_packages
|
||||
run: *install_e2e_tools
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: macOS updater regression tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/macos-updater-tests.yml'
|
||||
- 'src/main/macos-update-running-instances*'
|
||||
- 'src/main/updater*'
|
||||
- 'src/main/updater/**'
|
||||
- 'src/main/startup/main-process-quit*'
|
||||
- 'src/main/window/main-window-state-lifecycle*'
|
||||
- 'src/main/window/dashboard-popout-window*'
|
||||
- 'src/shared/child-process/**'
|
||||
- 'src/shared/update-status-types.ts'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: macos-updater-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
updater:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
- name: Exercise native application registry and update shutdown
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/macos-update-running-instances.test.ts
|
||||
src/main/macos-update-running-instances.integration.test.ts
|
||||
src/main/updater.mac-install.test.ts
|
||||
src/main/updater.headless-serve-install.test.ts
|
||||
src/main/updater-mac-quit-guard.test.ts
|
||||
src/main/startup/desktop-startup-ordering.test.ts
|
||||
src/main/startup/main-process-quit-update-veto.test.ts
|
||||
src/main/window/main-window-state-lifecycle.test.ts
|
||||
src/main/window/dashboard-popout-window.test.ts
|
||||
@@ -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*'
|
||||
@@ -113,18 +114,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Both compilers are read-only; finish them before starting the test workers.
|
||||
# Call installed tools so pnpm's dependency refresh cannot race between checks.
|
||||
- name: Typecheck
|
||||
id: production-types
|
||||
background: true
|
||||
run: pnpm typecheck
|
||||
run: node node_modules/typescript/bin/tsc --noEmit
|
||||
|
||||
# Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until
|
||||
# tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had
|
||||
# drifted. This fails when a test file that checks today stops checking, when a test leaves
|
||||
# the program, and on @ts-nocheck; the baseline may only shrink.
|
||||
- name: Typecheck tests (ratchet)
|
||||
run: pnpm run check:tests-typecheck
|
||||
run: node scripts/check-tests-typecheck-ratchet.mjs
|
||||
|
||||
- wait: production-types
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/restore-pnpm-verification/**'
|
||||
- '.github/actions/prepare-headless-compiler/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-orcad-prebuilds/**'
|
||||
- '.github/workflows/node-server-tests.yml'
|
||||
@@ -35,6 +37,8 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/restore-pnpm-verification/**'
|
||||
- '.github/actions/prepare-headless-compiler/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-orcad-prebuilds/**'
|
||||
- '.github/workflows/node-server-tests.yml'
|
||||
@@ -74,15 +78,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 +101,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 +109,30 @@ 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/prepare-headless-compiler
|
||||
id: compiler
|
||||
if: steps.scope.outputs.graph_required == 'true'
|
||||
continue-on-error: true
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
if: steps.scope.outputs.graph_required == 'true' && steps.compiler.outputs.available != '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:
|
||||
@@ -121,7 +147,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }}
|
||||
os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-15","macos-15-intel","windows-2022","windows-11-arm"]') }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
@@ -134,6 +160,29 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }}
|
||||
cache-pnpm-store: ${{ runner.os != 'Windows' }}
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
# Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped
|
||||
# it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION
|
||||
# moves past the Bun daemon's. Install its pinned dependencies independently of this checkout.
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- name: Build the last Bun orcad for the cross-runtime tests
|
||||
id: bun-orcad
|
||||
background: true
|
||||
shell: bash
|
||||
env:
|
||||
BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc
|
||||
run: |
|
||||
if [ "$RUNNER_OS" != Linux ]; then exit 0; fi
|
||||
git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT"
|
||||
git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT"
|
||||
pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts
|
||||
node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad"
|
||||
echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"
|
||||
echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"
|
||||
# Linux release slots come from the floor and Alpine lanes; this slot serves local tests.
|
||||
- uses: ./.github/actions/prepare-orcad-prebuilds
|
||||
id: orcad-prebuild
|
||||
@@ -144,26 +193,11 @@ jobs:
|
||||
(github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }}
|
||||
restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
- run: pnpm build:orcad
|
||||
# Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped
|
||||
# it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION
|
||||
# moves past the Bun daemon's. Same lockfile, so its build reuses this checkout's node_modules.
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- name: Build the last Bun orcad for the cross-runtime tests
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
env:
|
||||
BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT"
|
||||
git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT"
|
||||
ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules"
|
||||
node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad"
|
||||
echo "ORCA_BUN_ORCAD_SLOT=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_ENV"
|
||||
echo "BUN_EXECUTABLE=$(command -v bun)" >> "$GITHUB_ENV"
|
||||
- wait: bun-orcad
|
||||
- run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }}
|
||||
env:
|
||||
ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }}
|
||||
BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }}
|
||||
# Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay.
|
||||
# Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load.
|
||||
- name: Build the Windows process-table addons for the desktop template
|
||||
@@ -363,11 +397,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
|
||||
|
||||
+149
-62
@@ -154,15 +154,17 @@ jobs:
|
||||
echo "No specs requiring the reusable E2E workflow"
|
||||
fi
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
preflight:
|
||||
name: static analysis and typecheck
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true'
|
||||
# Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster --
|
||||
# type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke
|
||||
# 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so
|
||||
# the whole toolchain resolves. Free for public repositories, same as the typecheck job.
|
||||
# the whole toolchain resolves. Free for public repositories.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
shards: ${{ steps.unit-plan.outputs.shards }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -197,21 +199,35 @@ jobs:
|
||||
|
||||
# Keep each check in its own log while sharing this runner.
|
||||
- name: Lint
|
||||
if: '!cancelled()'
|
||||
id: root-lint
|
||||
background: true
|
||||
run: pnpm exec oxlint --format github
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm exec oxlint --format github
|
||||
|
||||
- name: Reject low-evidence patterns
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run audit:anti-slop
|
||||
|
||||
- wait: root-lint
|
||||
|
||||
- name: Enforce focused code-quality plugins
|
||||
if: '!cancelled()'
|
||||
id: native-code-quality
|
||||
background: true
|
||||
run: pnpm run audit:code-quality:native
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run audit:code-quality:native
|
||||
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
# Mobile installation changes import resolution for the native cycle check.
|
||||
@@ -221,28 +237,39 @@ jobs:
|
||||
# resolves types from mobile/node_modules. Without the install every mobile type
|
||||
# degrades to an `error` type — reported as phantom findings against the changed lines.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
if: '!cancelled()'
|
||||
id: changed-code-quality
|
||||
background: true
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Enforce React Doctor on changed lines
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- wait: changed-code-quality
|
||||
|
||||
- name: Check Zustand selector fan-out budget
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:zustand-selector-fanout
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
- name: Enforce dead design-system classes
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:dead-classes
|
||||
|
||||
- name: Check VM runtime rollback compatibility
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
@@ -264,25 +291,33 @@ jobs:
|
||||
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
|
||||
|
||||
- name: Enforce max-lines ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Enforce ts-nocheck ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:ts-nocheck-ratchet
|
||||
|
||||
- name: Enforce runtime Electron-import ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:runtime-electron-ratchet
|
||||
|
||||
- name: Check Node runtime pin
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:node-runtime-pin
|
||||
|
||||
# Why: extraction writes sorted evidence to an isolated temporary path,
|
||||
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
|
||||
- name: Verify localization extraction
|
||||
if: '!cancelled()'
|
||||
id: localization-extraction
|
||||
background: true
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
# Detection failures run the full check; renames retain the removed input path.
|
||||
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
|
||||
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" &&
|
||||
@@ -296,6 +331,7 @@ jobs:
|
||||
# which is a property of the import graph. This proves the Node artifact it enables
|
||||
# actually boots, pairs, creates a worktree and round-trips a real PTY.
|
||||
- name: Boot orcad and round-trip a terminal
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
@@ -310,20 +346,30 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify the generated RPC params catalog
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:rpc-params-catalog
|
||||
|
||||
- name: Verify bundled skill guides
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
- name: Verify localization catalogs
|
||||
if: '!cancelled()'
|
||||
id: localization-catalogs
|
||||
background: true
|
||||
run: pnpm run verify:localization-catalogs
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run verify:localization-catalogs
|
||||
|
||||
- name: Verify localization coverage
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
- wait: [localization-catalogs, localization-extraction]
|
||||
@@ -334,6 +380,7 @@ jobs:
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
@@ -346,33 +393,19 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check feature wall asset budget
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm check:feature-wall-assets
|
||||
|
||||
- name: Verify macOS entitlements
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
# Typechecking uses no native runtime, so it can use the free public ARM runner.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
shards: ${{ steps.unit-plan.outputs.shards }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
|
||||
# the next run skip unchanged files. Share one cache entry across commits while the
|
||||
# PR base stays stable; actions/cache keeps the first successful graph and the
|
||||
# compiler still invalidates stale files from its content hashes.
|
||||
- name: Cache TypeScript incremental state
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: config/*.tsbuildinfo
|
||||
@@ -382,17 +415,24 @@ jobs:
|
||||
|
||||
# Planning shares setup and stays off the compiler's critical path.
|
||||
- name: Plan unit selection
|
||||
if: '!cancelled()'
|
||||
id: unit-plan
|
||||
background: true
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
|
||||
run: node config/scripts/ci-unit-plan.mjs
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
node config/scripts/ci-unit-plan.mjs
|
||||
|
||||
- run: pnpm run typecheck
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
|
||||
- wait: unit-plan
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
with:
|
||||
name: unit-selection-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/unit-selection.json
|
||||
@@ -400,8 +440,12 @@ jobs:
|
||||
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.git_compatibility == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.git_compatibility == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -415,6 +459,8 @@ jobs:
|
||||
- uses: ./.github/actions/prepare-git-compatibility
|
||||
|
||||
- name: Verify Git binary compatibility matrix
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: |
|
||||
specs=(
|
||||
"alpine/git:edge-2.38.1|2.38.1"
|
||||
@@ -429,9 +475,13 @@ jobs:
|
||||
pids=()
|
||||
(
|
||||
ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git" \
|
||||
GIT_EXEC_PATH="$HOME/.cache/orca-git-compat/git-2.25.5" \
|
||||
ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
src/shared/git-binary-compatibility.test.ts \
|
||||
src/main/git/worktree-safety-real-git.test.ts \
|
||||
src/main/git/worktree-rebase-update-refs-real-git.test.ts \
|
||||
src/relay/git-review-draft-binary-compatibility.test.ts
|
||||
) &
|
||||
pids+=("$!")
|
||||
|
||||
@@ -441,7 +491,10 @@ jobs:
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
src/shared/git-binary-compatibility.test.ts \
|
||||
src/main/git/worktree-safety-real-git.test.ts \
|
||||
src/main/git/worktree-rebase-update-refs-real-git.test.ts \
|
||||
src/relay/git-review-draft-binary-compatibility.test.ts
|
||||
) &
|
||||
pids+=("$!")
|
||||
done
|
||||
@@ -460,8 +513,12 @@ jobs:
|
||||
# repair stops happening. Pinned because the binary is the thing expected to drift.
|
||||
codex_index_heal_contract:
|
||||
name: Codex index-heal contract
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.codex_index_heal_contract == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.codex_index_heal_contract == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: @openai/codex ships @openai/codex-linux-arm64.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
env:
|
||||
@@ -526,8 +583,12 @@ jobs:
|
||||
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.xterm_patch_sync == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the
|
||||
# checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on
|
||||
# darwin-arm64, so the output is neither host-arch nor host-OS dependent.
|
||||
@@ -555,8 +616,12 @@ jobs:
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.shell_contracts == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.shell_contracts == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
@@ -671,6 +736,7 @@ jobs:
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/fish-xdg-data-dirs-handoff.test.ts \
|
||||
src/main/shell-startup-feature-channel.test.ts \
|
||||
src/main/zsh-deferred-startup-line-init.live-shell.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
@@ -682,35 +748,38 @@ jobs:
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
# Static analysis saves the Node cache; typecheck publishes the plan before tests fan out.
|
||||
# Preflight saves the Node cache and publishes the plan before tests fan out.
|
||||
test:
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
# Cancellation and failed prerequisites stop the expensive matrix.
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
needs.static_analysis.result == 'success' &&
|
||||
needs.typecheck.result == 'success'
|
||||
needs.preflight.result == 'success'
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
shards: ${{ needs.typecheck.outputs.shards }}
|
||||
shards: ${{ needs.preflight.outputs.shards }}
|
||||
|
||||
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
|
||||
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
|
||||
# after the last shard. Deliberately absent from verify's needs for the same reason.
|
||||
unit_selection_evidence:
|
||||
needs: [test]
|
||||
if: ${{ !cancelled() && needs.test.result == 'success' }}
|
||||
if: ${{ !cancelled() && (needs.test.result == 'success' || needs.test.result == 'failure') }}
|
||||
uses: ./.github/workflows/unit-selection-evidence.yml
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.orcad_browser == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.orcad_browser == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -762,7 +831,7 @@ jobs:
|
||||
# in this job loads node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
native-runtime: none
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
@@ -823,8 +892,12 @@ jobs:
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.cross-version-wire == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.cross-version-wire == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
@@ -867,8 +940,12 @@ jobs:
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.managed_hook_node18 == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
@@ -893,7 +970,7 @@ jobs:
|
||||
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
|
||||
@@ -1050,7 +1127,7 @@ jobs:
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
@@ -1128,6 +1205,7 @@ jobs:
|
||||
src/shared/child-process/windows-cmd-shim-resolution.test.ts
|
||||
src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts
|
||||
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
|
||||
src/main/jcode/hook-gate-script.test.ts
|
||||
src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts
|
||||
src/main/codex/windows-hook-command.test.ts
|
||||
src/main/codex/windows-hook-upgrade.test.ts
|
||||
@@ -1158,6 +1236,8 @@ jobs:
|
||||
src/main/runtime/unreadable-secret-store-preservation.win32.test.ts
|
||||
src/main/ipc/pty-codex-account-attribution.test.ts
|
||||
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
|
||||
src/main/ipc/preflight-provider-command-selection.test.ts
|
||||
src/main/ipc/preflight-runnable-local-cli.test.ts
|
||||
src/relay/windows-port-scan.win32.test.ts
|
||||
src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts
|
||||
src/main/ssh/remote-node-runtime-store-windows.test.ts
|
||||
@@ -1211,8 +1291,19 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e
|
||||
needs: code_paths
|
||||
if: needs.code_paths.outputs.e2e_should_run == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.e2e_should_run == 'true' &&
|
||||
(
|
||||
needs.preflight.result == 'success' ||
|
||||
(
|
||||
needs.preflight.result == 'skipped' &&
|
||||
needs.code_paths.outputs.static_analysis == 'false' &&
|
||||
needs.code_paths.outputs.typecheck == 'false'
|
||||
)
|
||||
)
|
||||
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1255,8 +1346,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- code_paths
|
||||
- static_analysis
|
||||
- typecheck
|
||||
- preflight
|
||||
- git_compatibility
|
||||
- codex_index_heal_contract
|
||||
- xterm_patch_sync
|
||||
@@ -1285,10 +1375,8 @@ jobs:
|
||||
env:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
PREFLIGHT: ${{ needs.preflight.result }}
|
||||
PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }}
|
||||
@@ -1334,8 +1422,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
|
||||
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
|
||||
check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"
|
||||
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
|
||||
check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN"
|
||||
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
|
||||
|
||||
@@ -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:
|
||||
@@ -56,7 +57,17 @@ jobs:
|
||||
PYTHON: /opt/python/cp312-cp312/bin/python3
|
||||
steps:
|
||||
- name: Install glibc 2.28 prerequisites
|
||||
run: dnf install -y git procps-ng unzip which xz
|
||||
run: |
|
||||
missing_tool=false
|
||||
for tool in git ps unzip which xz; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_tool=true
|
||||
fi
|
||||
done
|
||||
if [ "$missing_tool" = true ]; then
|
||||
# The image's source-built Git needs no RPM; missing tools come from AlmaLinux.
|
||||
dnf --disablerepo='epel*' install -y git procps-ng unzip which xz
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -89,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
|
||||
@@ -198,7 +217,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
- os: macos-15
|
||||
target: darwin-arm64
|
||||
cell: macos-arm64-local-sshd
|
||||
- os: macos-15-intel
|
||||
|
||||
@@ -36,6 +36,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:
|
||||
@@ -83,10 +84,17 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
# Keep complete server/test trees; missing future imports fail the unchanged builds.
|
||||
sparse-checkout: |
|
||||
.github
|
||||
config
|
||||
native
|
||||
resources
|
||||
tests
|
||||
src/main
|
||||
src/shared
|
||||
src/relay
|
||||
src/types
|
||||
- name: Self-test the provisioning scripts before touching the machine
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -96,6 +104,23 @@ 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
|
||||
cache-pnpm-store: 'false'
|
||||
# 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.
|
||||
@@ -121,6 +146,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
|
||||
@@ -134,6 +160,8 @@ jobs:
|
||||
$cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_})
|
||||
if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out')}
|
||||
$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.
|
||||
@@ -143,7 +171,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:
|
||||
|
||||
@@ -5,12 +5,21 @@ on:
|
||||
# change is worth a real ibus session. A pull_request trigger here would run it on every PR.
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
diagnose_wayland_input:
|
||||
description: Capture passive Wayland input diagnostics while running both native lanes
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
|
||||
jobs:
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
@@ -98,6 +107,7 @@ jobs:
|
||||
- name: Run native Wayland Hangul terminating digit
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS: ${{ inputs.diagnose_wayland_input && '1' || '' }}
|
||||
run: node config/scripts/run-terminal-ibus-hangul-e2e.mjs --nested-wayland
|
||||
- name: Upload Wayland terminal IME evidence
|
||||
if: always()
|
||||
|
||||
@@ -68,25 +68,60 @@ jobs:
|
||||
- name: Install native build tools and xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
|
||||
|
||||
- name: Select dependency preparation
|
||||
id: install-mode
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_KIND: ${{ runner.environment }}
|
||||
JOB_CONTAINER: ${{ job.container.id }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs')
|
||||
const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8'))
|
||||
const actionPath = '.github/actions/install-node-dependencies/action.yml'
|
||||
const action = fs.existsSync(actionPath) ? fs.readFileSync(actionPath, 'utf8') : ''
|
||||
const inputs = action.split(/^inputs:[ \t]*\r?$/m)[1]?.split(/^\S/m)[0] ?? ''
|
||||
const shared = process.env.RUNNER_KIND === 'github-hosted' && !process.env.JOB_CONTAINER &&
|
||||
process.env.RUNNER_OS === 'Linux' && process.env.RUNNER_ARCH === 'X64' &&
|
||||
manifest.engines?.node === '24' && typeof manifest.packageManager === 'string' &&
|
||||
manifest.packageManager.split('+')[0] === 'pnpm@12.8.1' &&
|
||||
manifest.scripts?.postinstall === 'node config/scripts/rebuild-native-deps.mjs' &&
|
||||
/^ native-runtime:/m.test(inputs) && /^ cache-pnpm-store-lookup-only:/m.test(inputs) &&
|
||||
fs.existsSync('.github/actions/prepare-native-runtime/action.yml') &&
|
||||
fs.existsSync('config/scripts/ensure-native-runtime.mjs')
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `shared=${shared}\n`)
|
||||
NODE
|
||||
|
||||
- name: Prepare current dependencies
|
||||
if: steps.install-mode.outputs.shared == 'true'
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
cache-electron-package: 'true'
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.install-mode.outputs.shared != 'true'
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.install-mode.outputs.shared != 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this scheduled/manual workflow uses the same native install path as
|
||||
# PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py.
|
||||
# Historical refs can lack the shared action; retain their original install path.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
if: steps.install-mode.outputs.shared != 'true'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.install-mode.outputs.shared != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for terminal perf
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -82,6 +83,7 @@ jobs:
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -103,8 +105,8 @@ jobs:
|
||||
- name: Install relay integration dependencies
|
||||
working-directory: cloud
|
||||
run: |
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
|
||||
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build
|
||||
|
||||
- name: Test relay integration contracts
|
||||
env:
|
||||
|
||||
@@ -128,6 +128,7 @@ docs/**
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/ime-regression-checklist.md
|
||||
!docs/reference/jcode-hook-events.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,20 +15,20 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"@orca-cloud/postgres-schema": "workspace:*",
|
||||
"@orca-cloud/push-contract": "workspace:*",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"hono": "^4.13.7",
|
||||
"hono": "^4.13.10",
|
||||
"pg": "^8.22.0",
|
||||
"pg-connection-string": "2.14.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DurablePushStore } from './durable-push-store.js'
|
||||
import { buildPushDelivery } from './push-delivery-message.js'
|
||||
import type { PushDatabase } from './push-database.js'
|
||||
import {
|
||||
cleanupDurablePushFixtures,
|
||||
fixture,
|
||||
notification
|
||||
} from './durable-push-store.test-fixture.js'
|
||||
|
||||
type QueryCall = { sql: string; params?: unknown[] }
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await cleanupDurablePushFixtures()
|
||||
})
|
||||
|
||||
function traceDatabase(
|
||||
database: PushDatabase,
|
||||
calls: QueryCall[],
|
||||
errors: unknown[]
|
||||
): PushDatabase {
|
||||
return {
|
||||
dialect: database.dialect,
|
||||
query: async (sql, params) => {
|
||||
calls.push({ sql, params })
|
||||
try {
|
||||
return await database.query(sql, params)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
transaction: (run) => database.transaction((tx) => run(traceDatabase(tx, calls, errors))),
|
||||
lockQuotaScope: (key) => database.lockQuotaScope(key),
|
||||
tryLockScope: (key) => database.tryLockScope(key),
|
||||
tryLockSharedScope: (key) => database.tryLockSharedScope(key),
|
||||
close: () => database.close()
|
||||
}
|
||||
}
|
||||
|
||||
function payloadHash(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
}
|
||||
|
||||
it('parses each leased row once with exact complete payload, serialized key order and SQL sequence', async () => {
|
||||
const { db, store, clock } = await fixture()
|
||||
const input = {
|
||||
...notification(1),
|
||||
body: 'Unicode: 🐋\ud800',
|
||||
extra: { first: [null, false, 3], next: { z: 'last', a: 'first' } }
|
||||
}
|
||||
await store.accept('host', 'phone', input)
|
||||
const [row] = await db.query('SELECT * FROM push_delivery_batches')
|
||||
if (!row) {
|
||||
throw new Error('Missing delivery row')
|
||||
}
|
||||
const payload = String(row.payload_json)
|
||||
const calls: QueryCall[] = []
|
||||
const errors: unknown[] = []
|
||||
const owner = new DurablePushStore(traceDatabase(db, calls, errors), clock)
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
const delivery = await owner.claim()
|
||||
expect(delivery).toEqual({
|
||||
id: row.batch_id,
|
||||
registrationId: 'phone',
|
||||
hostFingerprint: 'host',
|
||||
notification: input,
|
||||
expiresAt: 1_300_000,
|
||||
lease: expect.any(String),
|
||||
attempts: 1
|
||||
})
|
||||
expect(JSON.stringify(delivery?.notification)).toBe(payload)
|
||||
expect(payloadHash(delivery?.notification)).toBe(payloadHash(input))
|
||||
if (!delivery) {
|
||||
throw new Error('Missing delivery')
|
||||
}
|
||||
const published = buildPushDelivery(delivery)
|
||||
const expected = buildPushDelivery({ ...delivery, notification: input })
|
||||
expect(JSON.stringify(published)).toBe(JSON.stringify(expected))
|
||||
expect(payloadHash(published)).toBe(payloadHash(expected))
|
||||
expect(calls.map(({ sql }) => sql.replace(/\s+/g, ' ').trim())).toEqual([
|
||||
`SELECT * FROM push_delivery_batches WHERE state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND due_at > ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.state = 'pending' AND busy.lease_until > 0 AND busy.lease_until > ?) ORDER BY due_at, created_at, batch_id LIMIT 1${db.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : ''}`,
|
||||
"SELECT (SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND expires_at > ? AND due_at > ? ORDER BY due_at, created_at, batch_id LIMIT 1) AS head, EXISTS (SELECT 1 FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND lease_until > 0 AND lease_until > ?) AS busy",
|
||||
'SELECT notification_seq FROM push_dismissed_events WHERE host_fingerprint = ? AND notification_epoch = ? AND notification_id = ?',
|
||||
'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?'
|
||||
])
|
||||
expect(calls[1]?.params).toEqual(['phone', clock(), clock() - 300_000, 'phone', clock()])
|
||||
expect(calls[2]?.params).toEqual(['host', 'epoch', 'notification-1'])
|
||||
expect(calls[3]?.params).toEqual([delivery?.lease, clock() + 30_000, row.batch_id])
|
||||
expect(errors).toEqual([])
|
||||
expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps concurrent device claims separate and returns fresh payload objects', async () => {
|
||||
const { store } = await fixture()
|
||||
const input = notification(1)
|
||||
await store.accept('host', 'phone-a', input)
|
||||
await store.accept('host', 'phone-b', input)
|
||||
const payload = JSON.stringify(input)
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
const claims = await Promise.all(Array.from({ length: 4 }, () => store.claim()))
|
||||
const delivered = claims.filter((claim) => claim !== null)
|
||||
expect(delivered).toHaveLength(2)
|
||||
expect(delivered.map((claim) => claim.registrationId).sort()).toEqual(['phone-a', 'phone-b'])
|
||||
expect(delivered.every((claim) => JSON.stringify(claim.notification) === payload)).toBe(true)
|
||||
expect(delivered[0]?.notification).not.toBe(delivered[1]?.notification)
|
||||
expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reads changed retry bytes and a later writer update without carrying a parsed result across calls', async () => {
|
||||
const { db, store, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
const first = await store.claim()
|
||||
if (!first) {
|
||||
throw new Error('Missing first delivery')
|
||||
}
|
||||
first.notification.body = 'provider changed this retry'
|
||||
await store.finish(first, 1000)
|
||||
advance(1000)
|
||||
const retriedPayload = JSON.stringify(first.notification)
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
const retry = await store.claim()
|
||||
expect(retry).toEqual({ ...first, lease: expect.any(String), attempts: 2 })
|
||||
expect(retry?.lease).not.toBe(first.lease)
|
||||
expect(retry?.notification).not.toBe(first.notification)
|
||||
expect(JSON.stringify(retry?.notification)).toBe(retriedPayload)
|
||||
expect(payloadHash(retry?.notification)).toBe(payloadHash(first.notification))
|
||||
const retryParses = parse.mock.calls.filter(([value]) => value === retriedPayload).length
|
||||
if (!retry) {
|
||||
throw new Error('Missing retry delivery')
|
||||
}
|
||||
await store.finish(retry, 1000)
|
||||
const changed = { ...notification(1), body: 'fresh database row', title: 'Changed' }
|
||||
const changedPayload = JSON.stringify(changed)
|
||||
await db.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [
|
||||
changedPayload,
|
||||
first.id
|
||||
])
|
||||
advance(1000)
|
||||
const fresh = await store.claim()
|
||||
expect(fresh).toEqual({ ...retry, notification: changed, lease: expect.any(String), attempts: 3 })
|
||||
expect(JSON.stringify(fresh?.notification)).toBe(changedPayload)
|
||||
expect(payloadHash(fresh?.notification)).toBe(payloadHash(changed))
|
||||
expect(retry.notification.body).toBe('provider changed this retry')
|
||||
expect(retryParses).toBe(1)
|
||||
expect(parse.mock.calls.filter(([value]) => value === changedPayload)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps dismissed alerts on the original single-parse delete path without leasing', async () => {
|
||||
const { db, store, clock } = await fixture()
|
||||
const input = notification(1)
|
||||
await store.accept('host', 'phone', input)
|
||||
await db.query(
|
||||
'INSERT INTO push_dismissed_events(host_fingerprint, notification_epoch, notification_id, notification_seq, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
['host', 'epoch', input.notificationId, 1, clock()]
|
||||
)
|
||||
const calls: QueryCall[] = []
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
expect(await new DurablePushStore(traceDatabase(db, calls, []), clock).claim()).toBeNull()
|
||||
expect(await store.pendingCount('phone')).toBe(0)
|
||||
expect(calls.at(-1)?.sql).toBe('DELETE FROM push_delivery_batches WHERE batch_id = ?')
|
||||
expect(calls.some(({ sql }) => sql.startsWith('UPDATE'))).toBe(false)
|
||||
expect(parse.mock.calls.filter(([value]) => value === JSON.stringify(input))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves the existing trust boundary for an object missing notification fields', async () => {
|
||||
const { db, store } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
await db.query('UPDATE push_delivery_batches SET payload_json = ?', ['{}'])
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
const delivery = await store.claim()
|
||||
expect(delivery?.notification).toEqual({})
|
||||
expect(JSON.stringify(delivery?.notification)).toBe('{}')
|
||||
expect(parse.mock.calls.filter(([value]) => value === '{}')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['not JSON', 'undefined', 'null', '[]'])(
|
||||
'preserves invalid payload rejection and rolls back the lease for %s',
|
||||
async (payload) => {
|
||||
const { db, store } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
await db.query('UPDATE push_delivery_batches SET payload_json = ?', [payload])
|
||||
const [before] = await db.query('SELECT * FROM push_delivery_batches')
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
let caught: unknown
|
||||
try {
|
||||
await store.claim()
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
const index = parse.mock.calls.findIndex(([value]) => value === payload)
|
||||
expect(index).toBeGreaterThanOrEqual(0)
|
||||
if (payload === 'not JSON' || payload === 'undefined') {
|
||||
expect(caught).toBe(parse.mock.results[index]?.value)
|
||||
expect(caught).toBeInstanceOf(SyntaxError)
|
||||
} else {
|
||||
expect(caught).toMatchObject({ message: 'invalid_push_delivery_payload' })
|
||||
}
|
||||
expect(await db.query('SELECT * FROM push_delivery_batches')).toEqual([before])
|
||||
expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the exact database UPDATE error and retries with a fresh payload after rollback', async () => {
|
||||
const { db, store, clock } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
const [before] = await db.query('SELECT * FROM push_delivery_batches')
|
||||
if (!before) {
|
||||
throw new Error('Missing delivery row')
|
||||
}
|
||||
const originalQuery = db.query.bind(db)
|
||||
const errors: unknown[] = []
|
||||
// SQLite raises a native error in the real transaction; PostgreSQL uses its real constraint.
|
||||
await originalQuery(
|
||||
db.dialect === 'sqlite'
|
||||
? "CREATE TRIGGER deny_lease BEFORE UPDATE ON push_delivery_batches BEGIN SELECT RAISE(FAIL, 'deny_lease'); END"
|
||||
: 'ALTER TABLE push_delivery_batches ADD CONSTRAINT deny_lease CHECK (lease_until = 0)'
|
||||
)
|
||||
const owner = new DurablePushStore(traceDatabase(db, [], errors), clock)
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
let caught: unknown
|
||||
try {
|
||||
await owner.claim()
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(caught).toBe(errors[0])
|
||||
expect(await originalQuery('SELECT * FROM push_delivery_batches')).toEqual([before])
|
||||
expect(parse.mock.calls.filter(([value]) => value === String(before.payload_json))).toHaveLength(
|
||||
1
|
||||
)
|
||||
await originalQuery(
|
||||
db.dialect === 'sqlite'
|
||||
? 'DROP TRIGGER deny_lease'
|
||||
: 'ALTER TABLE push_delivery_batches DROP CONSTRAINT deny_lease'
|
||||
)
|
||||
const fresh = await owner.claim()
|
||||
expect(fresh?.notification).toEqual(notification(1))
|
||||
expect(fresh?.attempts).toBe(1)
|
||||
})
|
||||
@@ -153,15 +153,15 @@ export class DurablePushStore {
|
||||
'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?',
|
||||
[lease, now + DELIVERY_LEASE_MS, row.batch_id]
|
||||
)
|
||||
return this.delivery(row, lease)
|
||||
return this.delivery(row, lease, notification)
|
||||
}
|
||||
|
||||
private delivery(row: SqlRow, lease: string): QueuedPushDelivery {
|
||||
private delivery(row: SqlRow, lease: string, notification: PushNotification): QueuedPushDelivery {
|
||||
return {
|
||||
id: String(row.batch_id),
|
||||
registrationId: String(row.registration_id),
|
||||
hostFingerprint: String(row.host_fingerprint),
|
||||
notification: parsePushDeliveryPayload(String(row.payload_json)),
|
||||
notification,
|
||||
expiresAt: Number(row.expires_at),
|
||||
lease,
|
||||
attempts: Number(row.attempts) + 1
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"hono": "^4.13.7",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"hono": "^4.13.10",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"hono": "^4.13.7",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"hono": "^4.13.10",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"@orca-cloud/postgres-schema": "workspace:*",
|
||||
"@orca-cloud/relay-contract": "workspace:*",
|
||||
"hono": "^4.13.7",
|
||||
"jose": "^6.1.3",
|
||||
"hono": "^4.13.10",
|
||||
"jose": "^6.2.12",
|
||||
"pg": "^8.22.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.21.3",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function stubStore(overrides: Partial<AssignmentCleanupStore> = {}) {
|
||||
abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'),
|
||||
reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'),
|
||||
releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'),
|
||||
pruneReleasedControlReservations: method('pruneReleasedControlReservations'),
|
||||
releaseExpiredActivity: method('releaseExpiredActivity'),
|
||||
releaseExpiredRegionPreferences: method('releaseExpiredRegionPreferences'),
|
||||
evacuateDeadCells: method('evacuateDeadCells'),
|
||||
@@ -43,6 +44,7 @@ describe('assignment cleanup steps', () => {
|
||||
'abortExpiredRegionalRehomes',
|
||||
'reapRegionalRehomeAttempts',
|
||||
'releaseExpiredActivityLeases',
|
||||
'pruneReleasedControlReservations',
|
||||
'releaseExpiredActivity',
|
||||
'releaseExpiredRegionPreferences',
|
||||
'evacuateDeadCells'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { runRelayBackgroundOperation } from './relay-background-operation.js'
|
||||
|
||||
// The eleven periodic assignment sweeps the director runs every 30s. Each step
|
||||
// The twelve periodic assignment sweeps the director runs every 30s. Each step
|
||||
// re-derives its state from the database and is idempotent, so they carry no
|
||||
// intra-tick ordering dependency — which is what makes per-step isolation
|
||||
// sound: one failing sweep costs one tick of itself, never the other ten.
|
||||
// sound: one failing sweep costs one tick of itself, never the other eleven.
|
||||
// (A single poisoned rehome row once silenced the whole chained form
|
||||
// fleet-wide.) Sweep failures are logged, never fed into the rehome worker's
|
||||
// dispatch-failure budget: a sweep exception is not a dispatch failure and
|
||||
@@ -17,6 +17,7 @@ export type AssignmentCleanupStore = {
|
||||
abortExpiredRegionalRehomes(): Promise<unknown>
|
||||
reapRegionalRehomeAttempts(): Promise<unknown>
|
||||
releaseExpiredActivityLeases(): Promise<unknown>
|
||||
pruneReleasedControlReservations(): Promise<unknown>
|
||||
releaseExpiredActivity(): Promise<unknown>
|
||||
releaseExpiredRegionPreferences(): Promise<unknown>
|
||||
evacuateDeadCells(): Promise<unknown>
|
||||
@@ -34,6 +35,10 @@ function assignmentCleanupSteps(
|
||||
['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()],
|
||||
['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()],
|
||||
['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()],
|
||||
[
|
||||
'prune-released-control-reservations',
|
||||
() => assignments.pruneReleasedControlReservations()
|
||||
],
|
||||
['release-expired-activity', () => assignments.releaseExpiredActivity()],
|
||||
['release-expired-region-preferences', () => assignments.releaseExpiredRegionPreferences()],
|
||||
['evacuate-dead-cells', () => assignments.evacuateDeadCells()]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js'
|
||||
import { HeapWindowReaper } from './heap-window-reaper.js'
|
||||
import {
|
||||
selectIdleRegionalRehomes,
|
||||
type IdleRegionalRehomeCandidate,
|
||||
@@ -498,6 +499,19 @@ export class RelayHomeCellUnavailableError extends Error {
|
||||
// never return otherwise starves connection headroom fleet-wide and turns
|
||||
// every placement into relay_capacity_exhausted.
|
||||
const LATE_ARRIVAL_DEBT_RETENTION_MS = 10 * 60 * 1_000
|
||||
// A released reservation is read by nothing: every reader filters it out by state, and the only
|
||||
// statement that still touches it is the per-host lock, which just makes that lock set longer. A day
|
||||
// is margin for forensics, not for reads.
|
||||
export const RELEASED_CONTROL_RESERVATION_RETENTION_MS = 24 * 60 * 60 * 1_000
|
||||
// ~27 rows per page, so a statement deletes a few hundred rows at most. With ~9 director ticks a
|
||||
// minute the row cap is ~5M rows a day: the 13.7M-row backlog drains over about three days, and a
|
||||
// walk that finds nothing reads ~1 MB a tick.
|
||||
const RELEASED_CONTROL_RESERVATION_REAP_BUDGET = {
|
||||
pagesPerStatement: 16,
|
||||
maxPagesPerTick: 128,
|
||||
maxRowsPerTick: 400,
|
||||
budgetMs: 250
|
||||
}
|
||||
const CELL_FENCE_TTL_MS = 5 * 60 * 1_000
|
||||
const CELL_FENCE_ATTEMPT_TTL_MS = 60 * 60 * 1_000
|
||||
const CELL_DRAIN_SEND_PERMIT_MS = 30_000
|
||||
@@ -541,6 +555,11 @@ export class RelayAssignmentStore {
|
||||
private readonly admissionSelector: RelayCellAdmissionSelector
|
||||
private readonly migrationCellRegistrar: RelayMigrationCellRegistrar
|
||||
private readonly activityQueue = new AssignmentIdentityQueue()
|
||||
private readonly releasedReservationReaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
`state = 'released' AND released_at <= ?`,
|
||||
RELEASED_CONTROL_RESERVATION_REAP_BUDGET
|
||||
)
|
||||
private assignmentTail: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
@@ -2944,6 +2963,10 @@ export class RelayAssignmentStore {
|
||||
async evacuateDeadCells(limit = 100): Promise<number> {
|
||||
if (!this.requireLiveCells) return 0
|
||||
const cutoff = this.now() - this.heartbeatTtlMs
|
||||
const cellIds = await this.deadCellEvacuationCandidates(cutoff)
|
||||
// Why: without a candidate cell the host query below walks every assignment by primary key to
|
||||
// return nothing (574 ms per call in production, from stale existing-only cells it can never act on).
|
||||
if (cellIds.length === 0) return 0
|
||||
const rows = await this.database.query(
|
||||
`SELECT assignment.user_id, assignment.relay_host_id, assignment.cell_id
|
||||
FROM relay_assignments assignment
|
||||
@@ -3006,8 +3029,9 @@ export class RelayAssignmentStore {
|
||||
AND fence.expires_at > ?
|
||||
)
|
||||
)
|
||||
AND assignment.cell_id IN (${cellIds.map(() => '?').join(', ')})
|
||||
ORDER BY assignment.user_id, assignment.relay_host_id LIMIT ?`,
|
||||
[1, cutoff, this.now(), this.now(), limit]
|
||||
[1, cutoff, this.now(), this.now(), ...cellIds, limit]
|
||||
)
|
||||
let moved = 0
|
||||
for (const row of rows) {
|
||||
@@ -3034,6 +3058,43 @@ export class RelayAssignmentStore {
|
||||
return moved
|
||||
}
|
||||
|
||||
// The cell-level half of evacuateDeadCells' predicate, so it is a superset: every cell the host
|
||||
// query could act on is here, and only the per-host pin checks are left out.
|
||||
private async deadCellEvacuationCandidates(cutoff: number): Promise<string[]> {
|
||||
const now = this.now()
|
||||
const rows = await this.database.query(
|
||||
`SELECT cell.cell_id
|
||||
FROM relay_cells cell
|
||||
LEFT JOIN relay_cell_committed_fences committed ON committed.cell_id = cell.cell_id
|
||||
LEFT JOIN relay_cell_fence_attempts attempt ON attempt.attempt_id = committed.attempt_id
|
||||
LEFT JOIN relay_cell_fences fence ON fence.cell_id = cell.cell_id
|
||||
LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id
|
||||
WHERE (runtime.cell_id IS NULL OR runtime.ready != ? OR runtime.last_heartbeat_at <= ?)
|
||||
AND (
|
||||
(
|
||||
cell.enabled = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM relay_cell_connection_limits limits
|
||||
WHERE limits.cell_id = cell.cell_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
cell.enabled = 0
|
||||
AND attempt.completed_at IS NOT NULL
|
||||
AND attempt.aborted_at IS NULL
|
||||
AND committed.cell_incarnation = runtime.cell_incarnation
|
||||
AND fence.cell_incarnation = committed.cell_incarnation
|
||||
AND committed.attested_at >= runtime.last_heartbeat_at
|
||||
AND committed.expires_at > ?
|
||||
AND fence.expires_at > ?
|
||||
)
|
||||
)
|
||||
ORDER BY cell.cell_id`,
|
||||
[1, cutoff, now, now]
|
||||
)
|
||||
return rows.map((row) => text(row, 'cell_id'))
|
||||
}
|
||||
|
||||
async configureCell(
|
||||
cell: RelayCellConfig,
|
||||
admission: boolean | CellAdmissionState
|
||||
@@ -7158,6 +7219,12 @@ export class RelayAssignmentStore {
|
||||
return aborted
|
||||
}
|
||||
|
||||
async pruneReleasedControlReservations(): Promise<number> {
|
||||
return await this.releasedReservationReaper.reap(this.database, [
|
||||
this.now() - RELEASED_CONTROL_RESERVATION_RETENTION_MS
|
||||
])
|
||||
}
|
||||
|
||||
async releaseExpiredActivityLeases(): Promise<number> {
|
||||
const now = this.now()
|
||||
await this.database.query(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { RelayCredentialStore, type RelayIdentity } from './credential-store.js'
|
||||
import {
|
||||
AUDIT_EVENT_RETENTION_MS,
|
||||
CONFIRM_RESULT_RETENTION_MS,
|
||||
RelayCredentialStore,
|
||||
type RelayIdentity
|
||||
} from './credential-store.js'
|
||||
import { openRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
// The outage this guards against: the credential cleanup ran every 30s in all 23 cells and both
|
||||
@@ -169,9 +174,9 @@ describePostgres('credential cleanup against PostgreSQL', () => {
|
||||
await database.query(`ANALYZE relay_connection_bases`)
|
||||
|
||||
const reaper = await plan(
|
||||
`DELETE FROM relay_connection_bases WHERE ctid IN (
|
||||
`DELETE FROM relay_connection_bases WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM relay_connection_bases WHERE active = ? AND deadline <= ? LIMIT 5000
|
||||
)`,
|
||||
))`,
|
||||
[0, NOW - DAY_MS]
|
||||
)
|
||||
|
||||
@@ -280,4 +285,55 @@ describePostgres('credential cleanup against PostgreSQL', () => {
|
||||
{ state: 'invalidated', total: '3' }
|
||||
])
|
||||
})
|
||||
|
||||
it('plans the audit reaper off relay_audit_events_at when most rows are past retention', async () => {
|
||||
// Unordered, a LIMIT with this many matches is cheapest as a sequential scan from page 0, which
|
||||
// in production would reread every retained row once the oldest pages are reaped.
|
||||
await database.query(
|
||||
`INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json)
|
||||
SELECT 'old-' || n, ?, 'resume-confirmed', ?, ?, '{}' FROM generate_series(1, 20000) AS n`,
|
||||
[NOW - AUDIT_EVENT_RETENTION_MS - DAY_MS, identity.userId, identity.relayHostId]
|
||||
)
|
||||
await database.query(`ANALYZE relay_audit_events`)
|
||||
|
||||
const reaper = await plan(
|
||||
`DELETE FROM relay_audit_events WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM relay_audit_events WHERE at <= ? ORDER BY at LIMIT 5000
|
||||
))`,
|
||||
[NOW - AUDIT_EVENT_RETENTION_MS]
|
||||
)
|
||||
|
||||
expect(reaper).toContain('relay_audit_events_at')
|
||||
expect(reaper).not.toContain('Seq Scan on relay_audit_events')
|
||||
})
|
||||
|
||||
it('reaps old confirm results and audit events and keeps the ones inside retention', async () => {
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
SELECT ?, ?, 'old-' || n, 'basis-1', '{}', '{}', ? FROM generate_series(1, 200) AS n`,
|
||||
[identity.userId, identity.relayHostId, NOW - CONFIRM_RESULT_RETENTION_MS - 1]
|
||||
)
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
VALUES (?, ?, 'recent', 'basis-1', '{}', '{}', ?)`,
|
||||
[identity.userId, identity.relayHostId, NOW - DAY_MS]
|
||||
)
|
||||
await database.query(
|
||||
`INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json)
|
||||
VALUES ('old', ?, 'resume-confirmed', ?, ?, '{}'), ('recent', ?, 'resume-confirmed', ?, ?, '{}')`,
|
||||
[
|
||||
NOW - AUDIT_EVENT_RETENTION_MS - 1, identity.userId, identity.relayHostId,
|
||||
NOW - DAY_MS, identity.userId, identity.relayHostId
|
||||
]
|
||||
)
|
||||
|
||||
await store.cleanup()
|
||||
|
||||
expect(await database.query(`SELECT req_id FROM relay_confirm_results`)).toEqual([
|
||||
{ req_id: 'recent' }
|
||||
])
|
||||
expect(await database.query(`SELECT id FROM relay_audit_events`)).toEqual([{ id: 'recent' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RelayCredentialStore, type RelayIdentity } from './credential-store.js'
|
||||
import {
|
||||
AUDIT_EVENT_RETENTION_MS,
|
||||
CONFIRM_RESULT_RETENTION_MS,
|
||||
RelayCredentialStore,
|
||||
type RelayIdentity
|
||||
} from './credential-store.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
const identity: RelayIdentity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
|
||||
@@ -71,6 +76,29 @@ async function insertDirectAuthorization(
|
||||
)
|
||||
}
|
||||
|
||||
async function insertConfirmResult(
|
||||
database: RelayDatabase,
|
||||
result: { reqId: string; committedAt: number }
|
||||
): Promise<void> {
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
VALUES (?, ?, ?, 'basis-1', '{}', '{}', ?)`,
|
||||
[identity.userId, identity.relayHostId, result.reqId, result.committedAt]
|
||||
)
|
||||
}
|
||||
|
||||
async function insertAuditEvent(
|
||||
database: RelayDatabase,
|
||||
event: { id: string; at: number }
|
||||
): Promise<void> {
|
||||
await database.query(
|
||||
`INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json)
|
||||
VALUES (?, ?, 'resume-confirmed', ?, ?, '{}')`,
|
||||
[event.id, event.at, identity.userId, identity.relayHostId]
|
||||
)
|
||||
}
|
||||
|
||||
async function remainingIds(database: RelayDatabase, table: string, column: string): Promise<string[]> {
|
||||
const rows = await database.query(`SELECT ${column} FROM ${table} ORDER BY ${column}`)
|
||||
return rows.map((row) => String(row[column]))
|
||||
@@ -294,4 +322,46 @@ describe('credential cleanup invite reaper', () => {
|
||||
expect(await remainingIds(database, 'relay_direct_authorizations', 'direct_auth_id')).toEqual([])
|
||||
await database.close()
|
||||
})
|
||||
|
||||
it('reaps confirm results and audit events only past their retention windows', async () => {
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayCredentialStore(database, () => NOW)
|
||||
await insertConfirmResult(database, { reqId: 'confirm-old', committedAt: NOW - CONFIRM_RESULT_RETENTION_MS })
|
||||
await insertConfirmResult(database, {
|
||||
reqId: 'confirm-recent',
|
||||
committedAt: NOW - CONFIRM_RESULT_RETENTION_MS + 1
|
||||
})
|
||||
await insertAuditEvent(database, { id: 'audit-old', at: NOW - AUDIT_EVENT_RETENTION_MS })
|
||||
await insertAuditEvent(database, { id: 'audit-recent', at: NOW - AUDIT_EVENT_RETENTION_MS + 1 })
|
||||
|
||||
await store.cleanup()
|
||||
|
||||
expect(await remainingIds(database, 'relay_confirm_results', 'req_id')).toEqual(['confirm-recent'])
|
||||
expect(await remainingIds(database, 'relay_audit_events', 'id')).toEqual(['audit-recent'])
|
||||
await database.close()
|
||||
})
|
||||
|
||||
it('still replays a confirm result inside retention', async () => {
|
||||
// The one reader: a retried confirm on the same basis gets the stored answer back.
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayCredentialStore(database, () => NOW)
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
VALUES (?, ?, 'confirm-1', 'basis-1', '{}', ?, ?)`,
|
||||
[identity.userId, identity.relayHostId, JSON.stringify({ v: 1, reqId: 'confirm-1' }), NOW - DAY_MS]
|
||||
)
|
||||
|
||||
await store.cleanup()
|
||||
|
||||
expect(
|
||||
await store.confirmResume({
|
||||
...identity,
|
||||
reqId: 'confirm-1',
|
||||
basisConnId: 'basis-1',
|
||||
owningControlGeneration: 1
|
||||
})
|
||||
).toEqual({ v: 1, reqId: 'confirm-1' })
|
||||
await database.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DeviceResumeConfirmed
|
||||
} from '@orca-cloud/relay-contract'
|
||||
import type { RelayDatabase, SqlRow } from './database.js'
|
||||
import { HeapWindowReaper } from './heap-window-reaper.js'
|
||||
|
||||
const CREDENTIAL_GRACE_MS = 24 * 60 * 60 * 1000
|
||||
// Released desktops validate invite expiry against their own clock with zero
|
||||
@@ -20,9 +21,23 @@ const TERMINAL_INVITE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// active/unconsumed AND inside its deadline, and every deadline is set at most 30s past insert, so
|
||||
// a settled row can never authorize anything again. A day is margin for forensics, not for reads.
|
||||
const INACTIVE_AUTHORIZATION_RETENTION_MS = 24 * 60 * 60 * 1000
|
||||
// A stored confirm result only answers a retry of the same request on the same connection basis,
|
||||
// and a different basis is refused as a tuple mismatch; a basis lives for one phone connection. A
|
||||
// week matches the pairing support window above.
|
||||
export const CONFIRM_RESULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// Nothing in the relay reads audit events back; 90 days covers support and incident questions.
|
||||
export const AUDIT_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000
|
||||
// Bounded so one cycle cannot hold row locks or grow WAL without limit; the backlog drains over
|
||||
// however many cycles it takes.
|
||||
const REAP_BATCH_ROWS = 5000
|
||||
// ~14 rows per page. With ~9 director ticks a minute the row cap is ~3M rows a day, so the 7.4M-row
|
||||
// backlog drains over two to three days; a walk that finds nothing reads ~1 MB a tick.
|
||||
const CONFIRM_RESULT_REAP_BUDGET = {
|
||||
pagesPerStatement: 16,
|
||||
maxPagesPerTick: 128,
|
||||
maxRowsPerTick: 250,
|
||||
budgetMs: 250
|
||||
}
|
||||
|
||||
export type RelayIdentity = { userId: string; relayHostId: string }
|
||||
export type CredentialReservation = RelayIdentity & {
|
||||
@@ -81,6 +96,13 @@ function string(row: SqlRow, field: string): string {
|
||||
}
|
||||
|
||||
export class RelayCredentialStore {
|
||||
// committed_at has no index, so a LIMIT delete would plan as a sequential scan of the whole table.
|
||||
private readonly confirmResultReaper = new HeapWindowReaper(
|
||||
'relay_confirm_results',
|
||||
'committed_at <= ?',
|
||||
CONFIRM_RESULT_REAP_BUDGET
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly database: RelayDatabase,
|
||||
private readonly now: () => number = Date.now
|
||||
@@ -680,16 +702,29 @@ export class RelayCredentialStore {
|
||||
'consumed_at IS NOT NULL AND consumed_at <= ?',
|
||||
[now - INACTIVE_AUTHORIZATION_RETENTION_MS]
|
||||
)
|
||||
await this.confirmResultReaper.reap(this.database, [now - CONFIRM_RESULT_RETENTION_MS])
|
||||
// Ordered so the plan walks relay_audit_events_at from its oldest entry: unordered, a LIMIT
|
||||
// can plan as a sequential scan that rereads the retained rows before reaching the old ones.
|
||||
await this.reapBatch('relay_audit_events', 'at <= ?', [now - AUDIT_EVENT_RETENTION_MS], 'at')
|
||||
}
|
||||
|
||||
// ctid/rowid, not the primary key: the physical address lets the delete re-find exactly the batch
|
||||
// the subquery located instead of re-matching the predicate per row.
|
||||
private async reapBatch(table: string, predicate: string, params: unknown[]): Promise<void> {
|
||||
const address = this.database.dialect === 'sqlite' ? 'rowid' : 'ctid'
|
||||
// the subquery located instead of re-matching the predicate per row. On Postgres an array of TIDs,
|
||||
// not `IN`: IN can plan as a hash join over a sequential scan of the whole table.
|
||||
private async reapBatch(
|
||||
table: string,
|
||||
predicate: string,
|
||||
params: unknown[],
|
||||
orderBy?: string
|
||||
): Promise<void> {
|
||||
const sqlite = this.database.dialect === 'sqlite'
|
||||
const address = sqlite ? 'rowid' : 'ctid'
|
||||
const order = orderBy ? `ORDER BY ${orderBy} ` : ''
|
||||
const batch = `SELECT ${address} FROM ${table} WHERE ${predicate} ${order}LIMIT ${REAP_BATCH_ROWS}`
|
||||
await this.database.query(
|
||||
`DELETE FROM ${table} WHERE ${address} IN (
|
||||
SELECT ${address} FROM ${table} WHERE ${predicate} LIMIT ${REAP_BATCH_ROWS}
|
||||
)`,
|
||||
sqlite
|
||||
? `DELETE FROM ${table} WHERE rowid IN (${batch})`
|
||||
: `DELETE FROM ${table} WHERE ctid = ANY(ARRAY(${batch}))`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ describe('relay database', () => {
|
||||
'relay_cell_connection_runtime',
|
||||
'relay_cell_connection_snapshots',
|
||||
'relay_cell_drain_attempt_states',
|
||||
'relay_cell_drain_attempts',
|
||||
'relay_cell_drain_recovery_attempts',
|
||||
'relay_cell_fence_apply_invocations',
|
||||
'relay_cell_fence_attempts',
|
||||
@@ -80,7 +79,6 @@ describe('relay database', () => {
|
||||
'relay_cell_runtime',
|
||||
'relay_cells',
|
||||
'relay_confirm_results',
|
||||
'relay_confirmable_splices',
|
||||
'relay_connection_bases',
|
||||
'relay_control_capabilities',
|
||||
'relay_control_connection_reservations',
|
||||
@@ -88,7 +86,6 @@ describe('relay database', () => {
|
||||
'relay_direct_authorizations',
|
||||
'relay_install_results',
|
||||
'relay_invites',
|
||||
'relay_migration_leases',
|
||||
'relay_post_drain_migration_pins',
|
||||
'relay_rate_windows',
|
||||
'relay_region_decisions',
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface RelayDatabase {
|
||||
// Constraint swaps are matched by NAME in pg_constraint, never by body, because the CHECK list is
|
||||
// generated from REGION_LIST. Changing a constraint's definition under the same name therefore does
|
||||
// nothing on boot: an operator drops it, and the next boot adds the current definition back.
|
||||
// relay_confirmable_splices, relay_cell_drain_attempts and relay_migration_leases are no longer
|
||||
// created; nothing ever wrote them. Databases that have them keep them empty until a drop is safe:
|
||||
// an older image still creates them at boot, and a drop racing that CREATE can fail its schema step.
|
||||
// Account erasure in orca-cloud must be deployed with retired-table support (orca-cloud#493) first.
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS relay_invites (
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -154,19 +158,6 @@ CREATE TABLE IF NOT EXISTS relay_install_results (
|
||||
PRIMARY KEY (user_id, relay_host_id, relay_device_id, req_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_confirmable_splices (
|
||||
basis_conn_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
relay_host_id TEXT NOT NULL,
|
||||
owning_control_generation BIGINT NOT NULL,
|
||||
relay_device_id TEXT NOT NULL,
|
||||
accepted_credential_version BIGINT NOT NULL,
|
||||
accepted_as TEXT NOT NULL,
|
||||
confirm_deadline BIGINT NOT NULL,
|
||||
active BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_connection_bases (
|
||||
basis_conn_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -507,15 +498,6 @@ CREATE TABLE IF NOT EXISTS relay_cell_fence_apply_invocations (
|
||||
CREATE INDEX IF NOT EXISTS relay_cell_fence_apply_invocations_attempt
|
||||
ON relay_cell_fence_apply_invocations(attempt_id, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_cell_drain_attempts (
|
||||
cell_id TEXT PRIMARY KEY,
|
||||
cell_incarnation TEXT NOT NULL,
|
||||
planned_grace_ms BIGINT NOT NULL,
|
||||
attempted_at BIGINT NOT NULL,
|
||||
retry_after BIGINT NOT NULL,
|
||||
recover_forward_attempted_at BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_cell_drain_attempt_states (
|
||||
attempt_id TEXT PRIMARY KEY,
|
||||
cell_id TEXT NOT NULL,
|
||||
@@ -605,17 +587,6 @@ CREATE TABLE IF NOT EXISTS relay_rate_windows (
|
||||
CREATE INDEX IF NOT EXISTS relay_rate_windows_started
|
||||
ON relay_rate_windows(window_started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_migration_leases (
|
||||
user_id TEXT NOT NULL,
|
||||
relay_host_id TEXT NOT NULL,
|
||||
source_cell_id TEXT NOT NULL,
|
||||
target_cell_id TEXT NOT NULL,
|
||||
assignment_epoch BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
completed_at BIGINT,
|
||||
PRIMARY KEY (user_id, relay_host_id, assignment_epoch)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_assignment_migrations (
|
||||
user_id TEXT NOT NULL,
|
||||
relay_host_id TEXT NOT NULL,
|
||||
@@ -741,7 +712,6 @@ const POSTGRES_TRANSACTION_PHASES = [
|
||||
['relay_region_rehome_', 'regional-rehome'],
|
||||
['relay_assignment_activity_leases', 'activity-lease'],
|
||||
['relay_assignment_migration', 'migration'],
|
||||
['relay_migration_leases', 'migration'],
|
||||
['relay_post_drain_migration_pins', 'migration'],
|
||||
['relay_cell_connection_runtime', 'cell-runtime'],
|
||||
['relay_cell_connection_snapshots', 'cell-runtime'],
|
||||
@@ -753,7 +723,6 @@ const POSTGRES_TRANSACTION_PHASES = [
|
||||
['relay_admission_selector', 'admission'],
|
||||
['relay_cell_admission', 'admission'],
|
||||
['relay_control_connection_reservations', 'connection'],
|
||||
['relay_confirmable_splices', 'connection'],
|
||||
['relay_connection_bases', 'connection'],
|
||||
['relay_direct_authorizations', 'connection'],
|
||||
['relay_confirm_results', 'connection'],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, afterEach, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import type { RelayCellConfig } from './config.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
openRelayDatabase,
|
||||
type RelayDatabase,
|
||||
type SqlRow
|
||||
} from './database.js'
|
||||
|
||||
// The sweep's host query walked every assignment by primary key to return nothing while stale
|
||||
// existing-only cells sat in the fleet. These pin that a fleet with nothing to evacuate never
|
||||
// reaches it, and that a cell the sweep can act on still does, on both dialects.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const schema = 'relay_dead_cell_precheck_test'
|
||||
const HOST_QUERY = 'FROM relay_assignments assignment'
|
||||
const CELLS: RelayCellConfig[] = [
|
||||
{ id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 },
|
||||
{ id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 }
|
||||
]
|
||||
|
||||
class RecordingDatabase implements RelayDatabase {
|
||||
readonly statements: string[] = []
|
||||
|
||||
constructor(private readonly delegate: RelayDatabase) {}
|
||||
|
||||
get dialect() {
|
||||
return this.delegate.dialect
|
||||
}
|
||||
|
||||
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
|
||||
this.statements.push(sql)
|
||||
return await this.delegate.query(sql, params)
|
||||
}
|
||||
|
||||
async queryLocked(...args: Parameters<RelayDatabase['queryLocked']>): Promise<SqlRow[]> {
|
||||
return await this.delegate.queryLocked(...args)
|
||||
}
|
||||
|
||||
async transaction<T>(operation: (transaction: RelayDatabase) => Promise<T>): Promise<T> {
|
||||
return await this.delegate.transaction(operation)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.delegate.close()
|
||||
}
|
||||
}
|
||||
|
||||
function scopedUrl(): string {
|
||||
const url = new URL(databaseUrl!)
|
||||
url.searchParams.set('options', `-c search_path=${schema}`)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function onAdmin(sql: string): Promise<void> {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query(sql)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
const dialects: [string, () => Promise<RelayDatabase>][] = [
|
||||
['sqlite', openInMemoryRelayDatabase],
|
||||
...(databaseUrl
|
||||
? [
|
||||
[
|
||||
'postgres',
|
||||
async () => {
|
||||
await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await onAdmin(`CREATE SCHEMA ${schema}`)
|
||||
return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
}
|
||||
] as [string, () => Promise<RelayDatabase>]
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
database = undefined
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
async function setup(now: () => number) {
|
||||
database = await open()
|
||||
const recording = new RecordingDatabase(database)
|
||||
const store = new RelayAssignmentStore(recording, now, {
|
||||
requireLiveCells: true,
|
||||
heartbeatTtlMs: 45_000
|
||||
})
|
||||
await store.reconcileCells(CELLS)
|
||||
return { store, recording }
|
||||
}
|
||||
|
||||
async function heartbeat(store: RelayAssignmentStore, cell: RelayCellConfig): Promise<void> {
|
||||
await store.recordCellHeartbeat({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
cellIncarnation: '11111111-1111-4111-8111-111111111111',
|
||||
startedAt: 50,
|
||||
ready: true,
|
||||
observedRequests: 0
|
||||
})
|
||||
}
|
||||
|
||||
it('skips the host query when the only dead cell is unfenced and existing-only', async () => {
|
||||
let now = 100
|
||||
const { store, recording } = await setup(() => now)
|
||||
for (const cell of CELLS) await heartbeat(store, cell)
|
||||
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
|
||||
await store.setCellEnabled('cell-b', false)
|
||||
expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' })
|
||||
await store.setCellEnabled('cell-b', true)
|
||||
await store.setCellEnabled('cell-a', false)
|
||||
now += 45_001
|
||||
await heartbeat(store, CELLS[1]!)
|
||||
recording.statements.length = 0
|
||||
|
||||
expect(await store.evacuateDeadCells()).toBe(0)
|
||||
expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(false)
|
||||
expect(
|
||||
await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [
|
||||
identity.userId
|
||||
])
|
||||
).toEqual([{ cell_id: 'cell-a' }])
|
||||
})
|
||||
|
||||
it('still evacuates hosts from a dead uncapped cell that admits', async () => {
|
||||
let now = 100
|
||||
const { store, recording } = await setup(() => now)
|
||||
for (const cell of CELLS) await heartbeat(store, cell)
|
||||
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
|
||||
await store.setCellEnabled('cell-b', false)
|
||||
expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' })
|
||||
await store.setCellEnabled('cell-b', true)
|
||||
now += 45_001
|
||||
await heartbeat(store, CELLS[1]!)
|
||||
recording.statements.length = 0
|
||||
|
||||
expect(await store.evacuateDeadCells()).toBe(1)
|
||||
expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(true)
|
||||
expect((await store.resolve(identity))?.cellId).toBe('cell-b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { openRelayDatabase } from './database.js'
|
||||
|
||||
// Three tables were created at every boot and never written. The schema stops creating them, and
|
||||
// a database that already has them must still boot, because they stay until a separate drop.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
const schema = 'relay_dead_tables_test'
|
||||
const DEAD_TABLES = [
|
||||
'relay_confirmable_splices',
|
||||
'relay_cell_drain_attempts',
|
||||
'relay_migration_leases'
|
||||
]
|
||||
|
||||
function scopedUrl(): string {
|
||||
const url = new URL(databaseUrl!)
|
||||
url.searchParams.set('options', `-c search_path=${schema}`)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function onScoped<T>(operation: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: scopedUrl() })
|
||||
await client.connect()
|
||||
try {
|
||||
return await operation(client)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSchema(): Promise<void> {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await client.query(`CREATE SCHEMA ${schema}`)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function existingDeadTables(): Promise<string[]> {
|
||||
return await onScoped(async (client) => {
|
||||
const rows = await client.query(
|
||||
`SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = $1 AND table_name = ANY($2) ORDER BY table_name`,
|
||||
[schema, DEAD_TABLES]
|
||||
)
|
||||
return rows.rows.map((row) => String(row.table_name))
|
||||
})
|
||||
}
|
||||
|
||||
describePostgres('removed relay tables against PostgreSQL', () => {
|
||||
afterAll(async () => {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await client.end()
|
||||
})
|
||||
|
||||
it('does not create the removed tables on a fresh database', async () => {
|
||||
await resetSchema()
|
||||
const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
await database.close()
|
||||
|
||||
expect(await existingDeadTables()).toEqual([])
|
||||
})
|
||||
|
||||
it('boots on a database an older image created, leaving those tables alone', async () => {
|
||||
await resetSchema()
|
||||
await onScoped(async (client) => {
|
||||
await client.query(`CREATE TABLE relay_confirmable_splices (basis_conn_id TEXT PRIMARY KEY)`)
|
||||
await client.query(`CREATE TABLE relay_cell_drain_attempts (cell_id TEXT PRIMARY KEY)`)
|
||||
await client.query(`CREATE TABLE relay_migration_leases (user_id TEXT NOT NULL)`)
|
||||
})
|
||||
|
||||
const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
await database.close()
|
||||
|
||||
expect(await existingDeadTables()).toEqual([...DEAD_TABLES].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { RelayDatabase, SqlRow } from './database.js'
|
||||
|
||||
export type HeapWindowReapBudget = {
|
||||
// Pages one DELETE may visit, so its row locks and WAL stay a few hundred rows.
|
||||
pagesPerStatement: number
|
||||
// Pages one tick may visit while it finds nothing to delete.
|
||||
maxPagesPerTick: number
|
||||
// Rows one tick deletes before it stops; this is what paces a backlog over days.
|
||||
maxRowsPerTick: number
|
||||
budgetMs: number
|
||||
}
|
||||
|
||||
// Why a TID range and not `WHERE <retention> LIMIT n`: these tables have no index on their
|
||||
// retention column, so the planner answers LIMIT with a sequential scan from page 0 (production
|
||||
// EXPLAIN, 2026-10-04). That scan gets longer every tick as the reaped head of the heap empties.
|
||||
// A TID range bounds each statement to its own pages whatever the table holds.
|
||||
export class HeapWindowReaper {
|
||||
private nextPage: number | undefined
|
||||
|
||||
constructor(
|
||||
private readonly table: string,
|
||||
private readonly predicate: string,
|
||||
private readonly budget: HeapWindowReapBudget,
|
||||
private readonly random: () => number = Math.random,
|
||||
private readonly clock: () => number = () => performance.now()
|
||||
) {}
|
||||
|
||||
async reap(database: RelayDatabase, params: unknown[]): Promise<number> {
|
||||
if (database.dialect !== 'postgres') {
|
||||
return changes(
|
||||
await database.query(
|
||||
`DELETE FROM ${this.table} WHERE rowid IN (
|
||||
SELECT rowid FROM ${this.table} WHERE ${this.predicate}
|
||||
LIMIT ${this.budget.maxRowsPerTick}
|
||||
)`,
|
||||
params
|
||||
)
|
||||
)
|
||||
}
|
||||
const pages = await heapPages(database, this.table)
|
||||
if (pages === 0) return 0
|
||||
// A random first page: every director runs this sweep, and walks that all start at page 0
|
||||
// after a rollout would read the same pages in lockstep.
|
||||
let page = this.nextPage ?? Math.floor(this.random() * pages)
|
||||
const startedAt = this.clock()
|
||||
let scanned = 0
|
||||
let deleted = 0
|
||||
while (
|
||||
scanned < this.budget.maxPagesPerTick &&
|
||||
deleted < this.budget.maxRowsPerTick &&
|
||||
this.clock() - startedAt < this.budget.budgetMs
|
||||
) {
|
||||
if (page >= pages) page = 0
|
||||
const end = Math.min(page + this.budget.pagesPerStatement, pages)
|
||||
// SKIP LOCKED: a row some request holds is left for a later pass rather than waited on.
|
||||
// `= ANY(ARRAY(...))`, not `IN (...)`: IN can plan as a hash join over a sequential scan of
|
||||
// the whole table; an array of TIDs is always a TID scan.
|
||||
deleted += changes(
|
||||
await database.query(
|
||||
`DELETE FROM ${this.table} WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM ${this.table}
|
||||
WHERE ctid >= CAST(? AS tid) AND ctid < CAST(? AS tid) AND ${this.predicate}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
))`,
|
||||
[`(${page},0)`, `(${end},0)`, ...params]
|
||||
)
|
||||
)
|
||||
scanned += end - page
|
||||
page = end
|
||||
}
|
||||
this.nextPage = page
|
||||
return deleted
|
||||
}
|
||||
}
|
||||
|
||||
async function heapPages(database: RelayDatabase, table: string): Promise<number> {
|
||||
const row = (
|
||||
await database.query(
|
||||
`SELECT pg_relation_size(CAST(? AS regclass)) / current_setting('block_size')::bigint
|
||||
AS pages`,
|
||||
[table]
|
||||
)
|
||||
)[0]
|
||||
return Number(row?.pages ?? 0)
|
||||
}
|
||||
|
||||
function changes(rows: SqlRow[]): number {
|
||||
return Number(rows[0]?.changes ?? 0)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
buildHostProofMacInput,
|
||||
HostChallengeSchema,
|
||||
HOST_CHALLENGE_PLAINTEXT_DOMAIN,
|
||||
RELAY_CLOSE_CODE
|
||||
} from '@orca-cloud/relay-contract'
|
||||
import nacl from 'tweetnacl'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type WebSocket from 'ws'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import { loadRelayConfig } from './config.js'
|
||||
import { RelayCredentialStore } from './credential-store.js'
|
||||
import type { RelayDatabase } from './database.js'
|
||||
import { HostSessionRegistry } from './host-session-registry.js'
|
||||
import type { RelayTokenClaims } from './relay-token-verifier.js'
|
||||
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
|
||||
|
||||
class ProofSocket extends EventEmitter {
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
readyState = this.OPEN
|
||||
readonly send = vi.fn<(frame: string) => void>()
|
||||
readonly close = vi.fn((code?: number, reason?: string) => {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close', code, Buffer.from(reason ?? ''))
|
||||
})
|
||||
|
||||
peerClose(): void {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close', 1000, Buffer.alloc(0))
|
||||
}
|
||||
|
||||
registrySocket(): WebSocket {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake implements the registry's send, state, close and EventEmitter surface; no actual networking is invoked.
|
||||
return this as unknown as WebSocket
|
||||
}
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const database: RelayDatabase = {
|
||||
query: vi.fn(async () => []),
|
||||
queryLocked: vi.fn(async () => []),
|
||||
transaction: (operation) => operation(database),
|
||||
close: async () => undefined
|
||||
}
|
||||
const config = loadRelayConfig({
|
||||
ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1',
|
||||
ORCA_RELAY_CELL_URL: 'http://127.0.0.1',
|
||||
ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test',
|
||||
ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks',
|
||||
ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only',
|
||||
ORCA_RELAY_ROLE: 'cell',
|
||||
ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin',
|
||||
ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test',
|
||||
ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600',
|
||||
ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60'
|
||||
})
|
||||
const assignments = new RelayAssignmentStore(database)
|
||||
const verify = vi.spyOn(assignments, 'verifyCellAssignment').mockResolvedValue(true)
|
||||
const activate = vi.spyOn(assignments, 'activateControl').mockResolvedValue('control:1')
|
||||
vi.spyOn(assignments, 'markMigrationTargetRegistered').mockResolvedValue(true)
|
||||
const recordAuth = vi.fn()
|
||||
const registry = new HostSessionRegistry(
|
||||
config,
|
||||
async () => null,
|
||||
new RelayCredentialStore(database),
|
||||
assignments,
|
||||
new ProcessQueuedByteBudget(),
|
||||
{
|
||||
recordAuth,
|
||||
recordForwardedBytes: vi.fn(),
|
||||
recordHttp: vi.fn(),
|
||||
recordReconnect: vi.fn(),
|
||||
recordSql: vi.fn()
|
||||
}
|
||||
)
|
||||
const keyPair = nacl.box.keyPair()
|
||||
const identity = {
|
||||
sub: 'user-proof',
|
||||
prof: 'profile-proof',
|
||||
relayHostId: createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16),
|
||||
purpose: 'host-control',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600
|
||||
} satisfies RelayTokenClaims
|
||||
const hello = JSON.stringify({
|
||||
type: 'host-hello',
|
||||
v: 1,
|
||||
relayHostId: identity.relayHostId,
|
||||
assignmentEpoch: 1,
|
||||
hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'),
|
||||
appVersion: 'test'
|
||||
})
|
||||
return { registry, verify, activate, recordAuth, database, identity, keyPair, hello }
|
||||
}
|
||||
|
||||
async function openProof(h: ReturnType<typeof fixture>, socket = new ProofSocket()) {
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from(h.hello), false)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.verify).toHaveBeenCalled()
|
||||
expect(socket.send).toHaveBeenCalledOnce()
|
||||
return socket
|
||||
}
|
||||
|
||||
function answerProof(socket: ProofSocket, keyPair: nacl.BoxKeyPair): void {
|
||||
const frame = socket.send.mock.calls[0]?.[0]
|
||||
if (frame === undefined) {
|
||||
throw new Error('missing challenge')
|
||||
}
|
||||
const parsed: unknown = JSON.parse(frame)
|
||||
if (parsed === null || typeof parsed !== 'object' || !('type' in parsed)) {
|
||||
throw new Error('invalid challenge frame')
|
||||
}
|
||||
const { type, ...fields } = parsed
|
||||
expect(type).toBe('host-challenge')
|
||||
const challenge = HostChallengeSchema.parse(fields)
|
||||
const plaintext = nacl.box.open(
|
||||
Buffer.from(challenge.ciphertextB64, 'base64'),
|
||||
Buffer.from(challenge.nonceB64, 'base64'),
|
||||
Buffer.from(challenge.relayEphemeralPublicKeyB64, 'base64'),
|
||||
keyPair.secretKey
|
||||
)
|
||||
if (plaintext === null) {
|
||||
throw new Error('challenge did not decrypt')
|
||||
}
|
||||
const domain = new TextEncoder().encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`)
|
||||
expect(plaintext.subarray(0, domain.length)).toEqual(domain)
|
||||
const transcriptLength = new DataView(
|
||||
plaintext.buffer,
|
||||
plaintext.byteOffset + domain.length,
|
||||
4
|
||||
).getUint32(0, false)
|
||||
const transcriptStart = domain.length + 4
|
||||
const transcript = plaintext.subarray(transcriptStart, transcriptStart + transcriptLength)
|
||||
const secret = plaintext.subarray(transcriptStart + transcriptLength)
|
||||
const proofB64 = createHmac('sha256', secret)
|
||||
.update(buildHostProofMacInput(transcript))
|
||||
.digest('base64')
|
||||
socket.emit(
|
||||
'message',
|
||||
Buffer.from(
|
||||
JSON.stringify({ type: 'host-challenge-ack', challengeId: challenge.challengeId, proofB64 })
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('host control proof cleanup', () => {
|
||||
it('allocates no hello stage for an already closed peer', () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
socket.peerClose()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the host hello timer and listeners when its peer closes early', () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(socket.listenerCount('message')).toBe(1)
|
||||
socket.peerClose()
|
||||
expect({
|
||||
timers: vi.getTimerCount(),
|
||||
message: socket.listenerCount('message'),
|
||||
close: socket.listenerCount('close')
|
||||
}).toEqual({ timers: 0, message: 0, close: 0 })
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
expect(h.verify).not.toHaveBeenCalled()
|
||||
expect(h.database.query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the exact hello deadline and refusal while releasing its message listener', () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
vi.advanceTimersByTime(1999)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
'host hello timeout'
|
||||
)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('releases the challenge timer and listeners when its peer closes before proof', async () => {
|
||||
const h = fixture()
|
||||
const socket = await openProof(h)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(socket.listenerCount('message')).toBe(1)
|
||||
socket.peerClose()
|
||||
expect({
|
||||
timers: vi.getTimerCount(),
|
||||
message: socket.listenerCount('message'),
|
||||
close: socket.listenerCount('close')
|
||||
}).toEqual({ timers: 0, message: 0, close: 0 })
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
expect(h.database.query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the exact proof deadline and refusal with no leftover listener', async () => {
|
||||
const h = fixture()
|
||||
const socket = await openProof(h)
|
||||
await vi.advanceTimersByTimeAsync(9999)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
'host proof timeout'
|
||||
)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
expect(h.recordAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does no challenge crypto, send, timer or registration after a closed peer finishes verification', async () => {
|
||||
const h = fixture()
|
||||
let finish!: (valid: boolean) => void
|
||||
h.verify.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const generateKey = vi.spyOn(nacl.box, 'keyPair')
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from(h.hello), false)
|
||||
expect(h.verify).toHaveBeenCalledOnce()
|
||||
socket.peerClose()
|
||||
finish(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(socket.send).not.toHaveBeenCalled()
|
||||
expect(generateKey).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
expect(h.database.query).not.toHaveBeenCalled()
|
||||
expect(
|
||||
h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([false, true])('preserves invalid first-frame refusal (binary=%s)', (binary) => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from('{}'), binary)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
binary ? 'host hello must be text' : 'invalid host hello'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves invalid proof authentication failure (binary=%s)',
|
||||
async (binary) => {
|
||||
const h = fixture()
|
||||
const socket = await openProof(h)
|
||||
socket.emit('message', Buffer.from('{}'), binary)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
'invalid host proof'
|
||||
)
|
||||
expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('allocates no proof wait when sending the challenge closes its peer', async () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
socket.send.mockImplementation(() => socket.peerClose())
|
||||
await openProof(h, socket)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the existing diagnostic and refusal when challenge send throws', async () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
socket.send.mockImplementation(() => {
|
||||
throw new Error('synthetic send failure')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
await openProof(h, socket)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
||||
'relay temporarily unavailable'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
'[orca-relay] host hello proof failed: synthetic send failure'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('contains assignment lookup rejection with its existing close and diagnostic', async () => {
|
||||
const h = fixture()
|
||||
h.verify.mockRejectedValueOnce(new Error('synthetic lookup failure'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from(h.hello), false)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
||||
'relay temporarily unavailable'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
'[orca-relay] host hello proof failed: synthetic lookup failure'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a newer same-host peer live when the old proof peer closes', async () => {
|
||||
const h = fixture()
|
||||
const oldPeer = await openProof(h)
|
||||
const replacement = await openProof(h)
|
||||
oldPeer.peerClose()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
answerProof(replacement, h.keyPair)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.activate).toHaveBeenCalledOnce()
|
||||
expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(true)
|
||||
expect(
|
||||
h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })?.socket
|
||||
).toBe(replacement)
|
||||
expect(replacement.send).toHaveBeenCalledTimes(2)
|
||||
expect(replacement.listenerCount('message')).toBe(1)
|
||||
expect(replacement.listenerCount('close')).toBe(2)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(oldPeer.close).not.toHaveBeenCalled()
|
||||
expect(replacement.close).not.toHaveBeenCalled()
|
||||
h.registry.drain(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -160,6 +160,30 @@ function send(socket: WebSocket, type: string, message: object): void {
|
||||
socket.send(JSON.stringify({ type, ...message }))
|
||||
}
|
||||
|
||||
function readControlFrame(
|
||||
socket: WebSocket,
|
||||
timeoutMs: number,
|
||||
timeoutReason: string,
|
||||
receive: (raw: RawData, isBinary: boolean) => void
|
||||
): void {
|
||||
if (socket.readyState !== socket.OPEN) return
|
||||
const timer = setTimeout(() => {
|
||||
finish()
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, timeoutReason)
|
||||
}, timeoutMs)
|
||||
function finish(): void {
|
||||
clearTimeout(timer)
|
||||
socket.off('message', onMessage)
|
||||
socket.off('close', finish)
|
||||
}
|
||||
function onMessage(raw: RawData, isBinary: boolean): void {
|
||||
finish()
|
||||
receive(raw, isBinary)
|
||||
}
|
||||
socket.once('message', onMessage)
|
||||
socket.once('close', finish)
|
||||
}
|
||||
|
||||
// Hosts abandon connects after 15s; waiting much longer than that behind a
|
||||
// stalled predecessor only accumulates doomed sockets.
|
||||
const ACTIVATION_QUEUE_WAIT_MS = 30_000
|
||||
@@ -794,12 +818,7 @@ export class HostSessionRegistry {
|
||||
socket.close(RELAY_CLOSE_CODE.DRAINING, 'relay draining')
|
||||
return
|
||||
}
|
||||
let firstFrameTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello timeout')
|
||||
}, 2_000)
|
||||
socket.once('message', (raw, isBinary) => {
|
||||
if (firstFrameTimer) clearTimeout(firstFrameTimer)
|
||||
firstFrameTimer = null
|
||||
readControlFrame(socket, 2_000, 'host hello timeout', (raw, isBinary) => {
|
||||
if (isBinary) {
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello must be text')
|
||||
return
|
||||
@@ -991,6 +1010,7 @@ export class HostSessionRegistry {
|
||||
socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch')
|
||||
return
|
||||
}
|
||||
if (socket.readyState !== socket.OPEN) return
|
||||
|
||||
const key = this.key(identity.sub, identity.relayHostId)
|
||||
const existing = this.sessions.get(key)
|
||||
@@ -1035,11 +1055,7 @@ export class HostSessionRegistry {
|
||||
ciphertextB64: Buffer.from(ciphertext).toString('base64'),
|
||||
expiresAt
|
||||
})
|
||||
const proofTimer = setTimeout(() => {
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host proof timeout')
|
||||
}, 10_000)
|
||||
socket.once('message', (raw, isBinary) => {
|
||||
clearTimeout(proofTimer)
|
||||
readControlFrame(socket, 10_000, 'host proof timeout', (raw, isBinary) => {
|
||||
const ack = isBinary
|
||||
? null
|
||||
: HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack'))
|
||||
|
||||
@@ -42,9 +42,6 @@ describePostgres('PostgreSQL drain-send locking', () => {
|
||||
`DELETE FROM relay_control_connection_reservations WHERE user_id = ?`,
|
||||
[identity.userId]
|
||||
)
|
||||
await database.query(`DELETE FROM relay_migration_leases WHERE user_id = ?`, [
|
||||
identity.userId
|
||||
])
|
||||
await database.query(`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, [
|
||||
identity.userId
|
||||
])
|
||||
|
||||
@@ -32,7 +32,7 @@ const CELL_TABLES = [
|
||||
// The target-row statement locks exactly these, held from it to COMMIT.
|
||||
const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission']
|
||||
|
||||
type Trip = { sql: string; lockable: Record<string, boolean> }
|
||||
type Trip = { sql: string; lockable: Record<string, boolean>; probeMs: number }
|
||||
|
||||
type DelayControl = StatementDelay & { beforeTrip: (sql: string) => Promise<void> }
|
||||
|
||||
@@ -159,11 +159,12 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => {
|
||||
bystander: context.bystander.id
|
||||
}
|
||||
control.beforeTrip = async (sql) => {
|
||||
const probeStartedAt = performance.now()
|
||||
const state: Record<string, boolean> = {}
|
||||
for (const [role, cellId] of Object.entries(probed)) {
|
||||
for (const table of CELL_TABLES) state[`${role}:${table}`] = await lockable(table, cellId)
|
||||
}
|
||||
trips.push({ sql, lockable: state })
|
||||
trips.push({ sql, lockable: state, probeMs: performance.now() - probeStartedAt })
|
||||
}
|
||||
consumeRelayCellInventoryHold(delayed)
|
||||
control.enabled = true
|
||||
@@ -188,13 +189,22 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => {
|
||||
])
|
||||
}
|
||||
const counts = consumeRelayCellInventoryHold(delayed)
|
||||
const commitProbeMs = trips.at(-1)!.probeMs
|
||||
console.info(
|
||||
JSON.stringify({ event: 'rehome_target_row_hold', trips: trips.length, ...counts })
|
||||
JSON.stringify({
|
||||
event: 'rehome_target_row_hold',
|
||||
trips: trips.length,
|
||||
commitProbeMs,
|
||||
...counts
|
||||
})
|
||||
)
|
||||
expect(counts.rehomeTargetRowHolds).toBe(1)
|
||||
expect(counts.cellInventoryHoldMaxSite).toBe('rehome-target-row')
|
||||
expect(counts.rehomeTargetRowHoldMsMax).toBeGreaterThanOrEqual(STATEMENT_DELAY_MS)
|
||||
expect(counts.rehomeTargetRowHoldMsMax).toBeLessThanOrEqual(2 * STATEMENT_DELAY_MS)
|
||||
// The COMMIT observer probes run under the lock, but are absent in production.
|
||||
expect(counts.rehomeTargetRowHoldMsMax - commitProbeMs).toBeLessThanOrEqual(
|
||||
2 * STATEMENT_DELAY_MS
|
||||
)
|
||||
expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore + 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RelayAssignmentStore,
|
||||
RELEASED_CONTROL_RESERVATION_RETENTION_MS
|
||||
} from './assignment-store.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
openRelayDatabase,
|
||||
type RelayDatabase
|
||||
} from './database.js'
|
||||
import { HeapWindowReaper } from './heap-window-reaper.js'
|
||||
|
||||
// Released reservations were never deleted: 13.7M rows and 9 GB in production, every one of them
|
||||
// still locked by each placement of its host. These pin what the prune may take and how it walks.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
const schema = 'relay_released_reservation_prune_test'
|
||||
const NOW = 100 * 24 * 60 * 60 * 1_000
|
||||
const STALE = NOW - RELEASED_CONTROL_RESERVATION_RETENTION_MS
|
||||
const PREDICATE = `state = 'released' AND released_at <= ?`
|
||||
|
||||
function scopedUrl(): string {
|
||||
const url = new URL(databaseUrl!)
|
||||
url.searchParams.set('options', `-c search_path=${schema}`)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function onAdmin(sql: string): Promise<void> {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query(sql)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function openPostgres(): Promise<RelayDatabase> {
|
||||
await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await onAdmin(`CREATE SCHEMA ${schema}`)
|
||||
return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
}
|
||||
|
||||
async function insertReservation(
|
||||
database: RelayDatabase,
|
||||
id: string,
|
||||
state: string,
|
||||
releasedAt: number | null
|
||||
): Promise<void> {
|
||||
await database.query(
|
||||
`INSERT INTO relay_control_connection_reservations
|
||||
(reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch,
|
||||
cell_id, state, created_at, timeout_at, released_at, updated_at)
|
||||
VALUES (?, ?, 'user-1', 'host000000000001', 1, 'cell-a', ?, 1, 1, ?, 1)`,
|
||||
[id, id, state, releasedAt]
|
||||
)
|
||||
}
|
||||
|
||||
async function remainingIds(database: RelayDatabase): Promise<string[]> {
|
||||
const rows = await database.query(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations ORDER BY reservation_id`
|
||||
)
|
||||
return rows.map((row) => String(row.reservation_id))
|
||||
}
|
||||
|
||||
const dialects: [string, () => Promise<RelayDatabase>][] = [
|
||||
['sqlite', openInMemoryRelayDatabase],
|
||||
...(databaseUrl ? [['postgres', openPostgres] as [string, () => Promise<RelayDatabase>]] : [])
|
||||
]
|
||||
|
||||
describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
database = undefined
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
it('deletes only released rows older than the retention window', async () => {
|
||||
database = await open()
|
||||
await insertReservation(database, 'a-old-released', 'released', STALE - 1)
|
||||
await insertReservation(database, 'b-edge-released', 'released', STALE)
|
||||
await insertReservation(database, 'c-fresh-released', 'released', STALE + 1)
|
||||
await insertReservation(database, 'd-reserved', 'reserved', null)
|
||||
await insertReservation(database, 'e-debt', 'late-arrival-debt', null)
|
||||
await insertReservation(database, 'f-claimed', 'claimed', null)
|
||||
// A row that once was released and came back is judged by its state, not its timestamp.
|
||||
await insertReservation(database, 'g-claimed-old-release', 'claimed', STALE - 1)
|
||||
const store = new RelayAssignmentStore(database, () => NOW)
|
||||
|
||||
expect(await store.pruneReleasedControlReservations()).toBe(2)
|
||||
expect(await remainingIds(database)).toEqual([
|
||||
'c-fresh-released',
|
||||
'd-reserved',
|
||||
'e-debt',
|
||||
'f-claimed',
|
||||
'g-claimed-old-release'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describePostgres('heap window reaper against PostgreSQL', () => {
|
||||
let database: RelayDatabase
|
||||
// 200 rows of this shape fill about two pages; 6,000 spread the table over ~60 pages.
|
||||
const ROWS = 6_000
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
async function seed(): Promise<number> {
|
||||
database = await openPostgres()
|
||||
// Every third row is still live, so each page holds rows the walk must keep.
|
||||
await database.query(
|
||||
`INSERT INTO relay_control_connection_reservations
|
||||
(reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch,
|
||||
cell_id, state, created_at, timeout_at, released_at, updated_at)
|
||||
SELECT 'r-' || lpad(n::text, 6, '0'), 'r-' || n, 'user-1', 'host000000000001', 1,
|
||||
'cell-a', CASE WHEN n % 3 = 0 THEN 'claimed' ELSE 'released' END,
|
||||
1, 1, CASE WHEN n % 3 = 0 THEN NULL ELSE 1 END, 1
|
||||
FROM generate_series(1, ?) AS n`,
|
||||
[ROWS]
|
||||
)
|
||||
const pages = (
|
||||
await database.query(
|
||||
`SELECT pg_relation_size('relay_control_connection_reservations') / 8192 AS pages`
|
||||
)
|
||||
)[0]!
|
||||
return Number(pages.pages)
|
||||
}
|
||||
|
||||
it('stops each tick at its row cap and drains the backlog over later ticks', async () => {
|
||||
const pages = await seed()
|
||||
expect(pages).toBeGreaterThan(20)
|
||||
const reaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
PREDICATE,
|
||||
{ pagesPerStatement: 2, maxPagesPerTick: 1_000, maxRowsPerTick: 300, budgetMs: 60_000 },
|
||||
() => 0.5
|
||||
)
|
||||
|
||||
const first = await reaper.reap(database, [NOW])
|
||||
// One statement past the cap at most: two pages of this shape hold well under 300 rows.
|
||||
expect(first).toBeGreaterThanOrEqual(300)
|
||||
expect(first).toBeLessThan(600)
|
||||
|
||||
let ticks = 1
|
||||
while ((await reaper.reap(database, [NOW])) > 0) ticks += 1
|
||||
expect(ticks).toBeGreaterThan(5)
|
||||
const left = await database.query(
|
||||
`SELECT state, COUNT(*) AS rows FROM relay_control_connection_reservations GROUP BY state`
|
||||
)
|
||||
expect(left).toEqual([{ state: 'claimed', rows: String(ROWS / 3) }])
|
||||
})
|
||||
|
||||
it('wraps from the end of the heap back to its first page', async () => {
|
||||
const pages = await seed()
|
||||
// Starts on the last page, so every other page is reached only after the wrap.
|
||||
const reaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
PREDICATE,
|
||||
{ pagesPerStatement: 4, maxPagesPerTick: pages + 4, maxRowsPerTick: 1_000_000, budgetMs: 60_000 },
|
||||
() => (pages - 1) / pages
|
||||
)
|
||||
|
||||
await reaper.reap(database, [NOW])
|
||||
|
||||
expect(
|
||||
await database.query(
|
||||
`SELECT COUNT(*) AS rows FROM relay_control_connection_reservations
|
||||
WHERE state = 'released'`
|
||||
)
|
||||
).toEqual([{ rows: '0' }])
|
||||
})
|
||||
|
||||
it('skips a row a request holds instead of waiting for it', async () => {
|
||||
await seed()
|
||||
const holder = new pg.Client({ connectionString: scopedUrl() })
|
||||
await holder.connect()
|
||||
try {
|
||||
await holder.query('BEGIN')
|
||||
await holder.query(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations
|
||||
WHERE reservation_id = 'r-000001' FOR UPDATE`
|
||||
)
|
||||
const reaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
PREDICATE,
|
||||
{ pagesPerStatement: 1_000, maxPagesPerTick: 1_000, maxRowsPerTick: 1_000_000, budgetMs: 60_000 },
|
||||
() => 0
|
||||
)
|
||||
|
||||
// The pool's lock_timeout would fail this statement if it waited on the held row.
|
||||
expect(await reaper.reap(database, [NOW])).toBe((ROWS * 2) / 3 - 1)
|
||||
expect(
|
||||
await database.query(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations
|
||||
WHERE state = 'released'`
|
||||
)
|
||||
).toEqual([{ reservation_id: 'r-000001' }])
|
||||
} finally {
|
||||
await holder.query('ROLLBACK')
|
||||
await holder.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('plans each statement as a TID range scan, never a sequential scan', async () => {
|
||||
await seed()
|
||||
await database.query('ANALYZE relay_control_connection_reservations')
|
||||
const client = new pg.Client({ connectionString: scopedUrl() })
|
||||
await client.connect()
|
||||
try {
|
||||
const plan = await client.query(
|
||||
`EXPLAIN DELETE FROM relay_control_connection_reservations WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM relay_control_connection_reservations
|
||||
WHERE ctid >= CAST($1 AS tid) AND ctid < CAST($2 AS tid) AND ${PREDICATE.replace('?', '$3')}
|
||||
FOR UPDATE SKIP LOCKED))`,
|
||||
['(0,0)', '(16,0)', NOW]
|
||||
)
|
||||
const text = plan.rows.map((row) => String(row['QUERY PLAN'])).join('\n')
|
||||
expect(text).toContain('Tid Range Scan')
|
||||
expect(text).not.toContain('Seq Scan')
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ const SHAPES = {
|
||||
domain: 'relay.onorca.dev',
|
||||
allCells: [
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30',
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34'
|
||||
],
|
||||
// The launch set was registered together; each later cell registers alone beside it.
|
||||
registrationWaves: [
|
||||
@@ -29,8 +29,10 @@ const SHAPES = {
|
||||
['production-gce-c30'],
|
||||
['production-gce-c31'],
|
||||
['production-gce-c32'],
|
||||
['production-gce-c33']
|
||||
['production-gce-c33'],
|
||||
['production-gce-c34']
|
||||
],
|
||||
// C34 is a migration-only spare: no promotion wave until a reviewed change adds one.
|
||||
promotionWaves: [
|
||||
['production-gce-c27'],
|
||||
['production-gce-c28', 'production-gce-c29'],
|
||||
|
||||
@@ -553,11 +553,13 @@ test('accepts only reviewed Asia admission waves', () => {
|
||||
['rollback', 'production-gce-c30'],
|
||||
['rollback', 'production-gce-c31'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'],
|
||||
...['production-gce-c32', 'production-gce-c33'].flatMap((cellId) => [
|
||||
'inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback'
|
||||
].map((mode) => [mode, cellId])),
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33']
|
||||
...['inspect', 'verify', 'register', 'registered', 'rollback']
|
||||
.map((mode) => [mode, 'production-gce-c34']),
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34']
|
||||
]
|
||||
for (const [mode, cellIds] of accepted) {
|
||||
assert.deepEqual(
|
||||
@@ -586,7 +588,12 @@ test('accepts only reviewed Asia admission waves', () => {
|
||||
['promote', 'production-gce-c27,production-gce-c30'],
|
||||
['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['promote', 'production-gce-c30,production-gce-c31'],
|
||||
// The C34 spare stays migration-only: no reviewed promotion wave names it.
|
||||
['promote', 'production-gce-c34'],
|
||||
['recover-promotion', 'production-gce-c34'],
|
||||
['register', 'production-gce-c33,production-gce-c34'],
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'],
|
||||
['rollback', 'production-gce-c27,production-gce-c30'],
|
||||
['rollback', 'production-gce-c30,production-gce-c31'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
@@ -648,6 +655,29 @@ test('registers C31 alone beside the general C27-C30', async () => {
|
||||
assert.deepEqual(subject.selector().membership.general, general)
|
||||
})
|
||||
|
||||
test('registers the C34 spare alone as migration-only in asia-east2', async () => {
|
||||
const general = [...launchCells, 'production-gce-c30', 'production-gce-c31']
|
||||
const subject = harness({
|
||||
generation: 17,
|
||||
membership: {
|
||||
existingOnly: [],
|
||||
migrationOnly: [],
|
||||
general: [...general, 'production-gce-c32', 'production-gce-c33']
|
||||
}
|
||||
})
|
||||
const result = await operateRelayAsiaAdmission({
|
||||
environment: 'production', mode: 'register', cells: ['production-gce-c34'],
|
||||
expectedGeneration: 17, imageDigest: digest, attemptId: 'asia_register_c34', token: 'not-logged'
|
||||
}, subject)
|
||||
const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells'))
|
||||
assert.deepEqual(request.body.cells, [{
|
||||
cellId: 'production-gce-c34', cellUrl: 'https://c34.relay.onorca.dev', region: 'asia-east2',
|
||||
capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60
|
||||
}])
|
||||
assert.deepEqual(result.states, { 'production-gce-c34': 'migration-only' })
|
||||
assert.equal(subject.selector().membership.general.includes('production-gce-c34'), false)
|
||||
})
|
||||
|
||||
const usRegions = { 'production-gce-c32': 'us-central1', 'production-gce-c33': 'us-central1' }
|
||||
|
||||
test('registers C32 and C33 one at a time in us-central1 at the Asia shape', async () => {
|
||||
|
||||
@@ -20,7 +20,8 @@ const SHAPES = {
|
||||
'production-gce-c30': 'asia-east2-a',
|
||||
'production-gce-c31': 'asia-east2-b',
|
||||
'production-gce-c32': 'us-central1-a',
|
||||
'production-gce-c33': 'us-central1-b'
|
||||
'production-gce-c33': 'us-central1-b',
|
||||
'production-gce-c34': 'asia-east2-c'
|
||||
},
|
||||
// The launch set, then each later additive cell; a plan targets one wave, never live cells.
|
||||
waves: [
|
||||
@@ -28,7 +29,8 @@ const SHAPES = {
|
||||
['production-gce-c30'],
|
||||
['production-gce-c31'],
|
||||
// Declared together, so they plan together: a lone C32 plan would hit C33's missing template.
|
||||
['production-gce-c32', 'production-gce-c33']
|
||||
['production-gce-c32', 'production-gce-c33'],
|
||||
['production-gce-c34']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const productionCells = () => Object.fromEntries([
|
||||
[30, 'asia-east2-a'],
|
||||
[31, 'asia-east2-b'],
|
||||
[32, 'us-central1-a'],
|
||||
[33, 'us-central1-b']
|
||||
[33, 'us-central1-b'],
|
||||
[34, 'asia-east2-c']
|
||||
].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, {
|
||||
hostname: `c${ordinal}`, region: zone.slice(0, -2), zone,
|
||||
machine_type: 'e2-standard-4', boot_disk_gb: 30,
|
||||
@@ -57,6 +58,14 @@ test('accepts the additive C31 wave in the next zone of the rotation', () => {
|
||||
assert.equal(result.relay_gce_cells['production-gce-c31'].zone, 'asia-east2-b')
|
||||
})
|
||||
|
||||
test('accepts the additive C34 spare wave in asia-east2-c at the Asia pool', () => {
|
||||
const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(),
|
||||
existingAdditionalRegions: additionalRegions, environment: 'production',
|
||||
cellIds: 'production-gce-c34', image })
|
||||
assert.equal(result.relay_gce_cells['production-gce-c34'].zone, 'asia-east2-c')
|
||||
assert.equal(result.relay_gce_cells['production-gce-c34'].database_pool_max, 16)
|
||||
})
|
||||
|
||||
test('accepts the additive US C32+C33 wave at the default pool only', () => {
|
||||
const cellIds = 'production-gce-c32,production-gce-c33'
|
||||
for (const [cellId, zone] of [
|
||||
@@ -103,7 +112,8 @@ test('matches every committed production Asia cell entry', () => {
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production-gce-c30',
|
||||
'production-gce-c31',
|
||||
'production-gce-c32,production-gce-c33'
|
||||
'production-gce-c32,production-gce-c33',
|
||||
'production-gce-c34'
|
||||
]) {
|
||||
const committedImage = committed[wave.split(',')[0]].image
|
||||
assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({
|
||||
@@ -116,8 +126,8 @@ test('matches every committed production Asia cell entry', () => {
|
||||
environment: 'production', cellIds: 'production-gce-c30',
|
||||
image
|
||||
}), /differs from the reviewed topology/)
|
||||
// The US cells launch on the newest digest, the one C31 launched on.
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33']) {
|
||||
// The US cells and the C34 spare launch on the newest cell digest, the one C31 launched on.
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) {
|
||||
assert.equal(committed[cellId].image, committed['production-gce-c31'].image, cellId)
|
||||
}
|
||||
})
|
||||
@@ -152,7 +162,8 @@ test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drif
|
||||
'production-gce-c30,production-gce-c31',
|
||||
'production-gce-c32',
|
||||
'production-gce-c33',
|
||||
'production-gce-c34'
|
||||
'production-gce-c33,production-gce-c34',
|
||||
'production-gce-c35'
|
||||
]) {
|
||||
assert.throws(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: productionCells(), existingAdditionalRegions: additionalRegions,
|
||||
|
||||
@@ -101,7 +101,9 @@ describe('production Relay capacity cell admission', () => {
|
||||
// Migration-only canaries: the US-only capacity rollout never touches them either.
|
||||
'production-gce-c17', 'production-gce-c18',
|
||||
// US cells at the Asia shape: the 1,000-cap capacity rollout never touches them.
|
||||
'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c32', 'production-gce-c33',
|
||||
// The migration-only Asia spare.
|
||||
'production-gce-c34'
|
||||
]) {
|
||||
const hostname = cellId.slice('production-gce-'.length)
|
||||
assert.deepEqual(parseProductionCapacityCellArguments([
|
||||
@@ -118,7 +120,7 @@ describe('production Relay capacity cell admission', () => {
|
||||
paceWindowMs: 0
|
||||
})
|
||||
}
|
||||
for (const cellId of ['production-gce-c12', 'production-gce-c34']) {
|
||||
for (const cellId of ['production-gce-c12', 'production-gce-c35']) {
|
||||
const hostname = cellId.slice('production-gce-'.length)
|
||||
assert.throws(() => parseProductionCapacityCellArguments([
|
||||
'--director-origin', 'https://relay.onorca.dev',
|
||||
|
||||
@@ -2,9 +2,9 @@ import { pathToFileURL } from 'node:url'
|
||||
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||
|
||||
// Every cell that carries the rehome identity: the eighteen US cells and the
|
||||
// five asia-east2 cells that drain mis-homed hosts back the other way.
|
||||
// six asia-east2 cells that drain mis-homed hosts back the other way.
|
||||
const PRODUCTION_CELL =
|
||||
/^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33)$/
|
||||
/^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34)$/
|
||||
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
||||
|
||||
export function parseRehomeTrustProbeArguments(argv, environment = process.env) {
|
||||
|
||||
@@ -116,7 +116,7 @@ test('fails when both trust-probe attempts return a transient 503', async () =>
|
||||
test('approves the asia-east2 and US 3,000 rehome sources and still rejects unlisted cells', () => {
|
||||
for (const cellId of [
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30',
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34'
|
||||
]) {
|
||||
const parsed = parseRehomeTrustProbeArguments(
|
||||
argv.map((value) => (value === 'production-gce-c7' ? cellId : value)),
|
||||
@@ -124,7 +124,7 @@ test('approves the asia-east2 and US 3,000 rehome sources and still rejects unli
|
||||
)
|
||||
assert.equal(parsed.cellId, cellId)
|
||||
}
|
||||
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c34']) {
|
||||
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c35']) {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRehomeTrustProbeArguments(
|
||||
@@ -183,7 +183,7 @@ test('approves exactly the committed production rehome source cells', () => {
|
||||
const sources = new Set(
|
||||
[...tfvars.slice(start, tfvars.indexOf(']', start)).matchAll(/"([^"]+)"/g)].map(([, cell]) => cell)
|
||||
)
|
||||
assert.ok(sources.has('production-gce-c33'))
|
||||
assert.ok(sources.has('production-gce-c34'))
|
||||
for (let ordinal = 1; ordinal <= 40; ordinal++) {
|
||||
const cellId = `production-gce-c${ordinal}`
|
||||
const approved = (() => {
|
||||
|
||||
@@ -50,7 +50,8 @@ test('accepts only the reviewed Asia topology waves', () => {
|
||||
'production:production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production:production-gce-c30',
|
||||
'production:production-gce-c31',
|
||||
'production:production-gce-c32,production-gce-c33'
|
||||
'production:production-gce-c32,production-gce-c33',
|
||||
'production:production-gce-c34'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from './relay-cloud-sql-connection-budget.mjs'
|
||||
|
||||
test('production shared consumers keep allowance and reserve below the ceiling', () => {
|
||||
// cells: 22 pools at 10 (220, C32/C33 included) + the five asia-east2 pools at 16 (80).
|
||||
// cells: 22 pools at 10 (220, C32/C33 included) + the six asia-east2 pools at 16 (96).
|
||||
const report = readRelayCloudSqlConnectionBudget()
|
||||
|
||||
assert.deepEqual(report.consumers, { cells: 300, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 5, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 385)
|
||||
assert.deepEqual(report.consumers, { cells: 316, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 6, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 401)
|
||||
assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30)
|
||||
assert.equal(report.rolloutOverlap.apiCandidate, 65)
|
||||
assert.equal(report.rolloutOverlap.authCandidate, 35)
|
||||
@@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling',
|
||||
assert.equal(report.maintenanceAdminAllowance, 5)
|
||||
assert.equal(report.explicitReserve, 10)
|
||||
assert.equal(report.usableCeiling, 490)
|
||||
assert.equal(report.operatingMaximum, 455)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 35)
|
||||
assert.equal(report.budgetedTotal, 465)
|
||||
assert.equal(report.unallocated, 35)
|
||||
assert.equal(report.operatingMaximum, 471)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 19)
|
||||
assert.equal(report.budgetedTotal, 481)
|
||||
assert.equal(report.unallocated, 19)
|
||||
assert.equal(report.withinBudget, true)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs'
|
||||
|
||||
// Migration-only by policy: zero hosts and no reservation, so a wave rolls one without
|
||||
// displacing anybody. It enters and must leave migration-only, never general.
|
||||
// C32 and C33 stay here until each one's canary promotes it; that follow-up moves it to general.
|
||||
// C34 is an Asia spare that stays migration-only by policy.
|
||||
export const SAME_CAP_MIGRATION_ONLY_CELLS = [
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c34'
|
||||
]
|
||||
|
||||
export const SAME_CAP_CELLS = [
|
||||
@@ -15,7 +15,7 @@ export const SAME_CAP_CELLS = [
|
||||
'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22',
|
||||
'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26',
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30',
|
||||
'production-gce-c31',
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33',
|
||||
...SAME_CAP_MIGRATION_ONLY_CELLS
|
||||
]
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ test('requires one canary or a bounded reviewed batch', () => {
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31`
|
||||
}).cells, ['production-gce-c31'])
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33']) {
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) {
|
||||
assert.deepEqual(validateSameCapWave({
|
||||
mode: 'canary-apply',
|
||||
cellIds: cellId,
|
||||
@@ -74,10 +74,10 @@ test('requires one canary or a bounded reviewed batch', () => {
|
||||
}
|
||||
assert.throws(() => validateSameCapWave({
|
||||
mode: 'canary-apply',
|
||||
cellIds: 'production-gce-c34',
|
||||
cellIds: 'production-gce-c35',
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c34`
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c35`
|
||||
}), /cells/)
|
||||
})
|
||||
|
||||
@@ -123,12 +123,16 @@ test('the wave workflow chains exactly ten serial cell jobs', () => {
|
||||
assert.doesNotMatch(dispatch, /\n cell_11:/)
|
||||
})
|
||||
|
||||
test('lists C32 and C33 as migration-only beside C17 and C18 until their canaries promote them', () => {
|
||||
assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, [
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33'
|
||||
])
|
||||
assert.equal(SAME_CAP_CELLS.includes('production-gce-c30'), true)
|
||||
assert.equal(SAME_CAP_CELLS.includes('production-gce-c31'), true)
|
||||
test('lists the C34 spare as migration-only beside C17 and C18, and C30-C33 as general', () => {
|
||||
assert.deepEqual(
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS,
|
||||
['production-gce-c17', 'production-gce-c18', 'production-gce-c34']
|
||||
)
|
||||
for (const cellId of [
|
||||
'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33'
|
||||
]) {
|
||||
assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId)
|
||||
}
|
||||
})
|
||||
|
||||
test('rolls the migration-only cells but never mixes the two classes in one wave', () => {
|
||||
@@ -194,15 +198,28 @@ test('rolls the migration-only cells but never mixes the two classes in one wave
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c31Mixed}`,
|
||||
canaryRunId: '42'
|
||||
}), /all general or all migration-only/)
|
||||
// Until its canary promotes it, a same-cap restore must hand a US 3,000 cell back isolated.
|
||||
assert.equal(entryAdmission('production-gce-c32'), 'migration-only')
|
||||
const usUnpromoted = 'production-gce-c26,production-gce-c32'
|
||||
assert.throws(() => validateSameCapWave({
|
||||
// C32 and C33 are general since their 2026-10-01 promotions: they roll beside US 1k cells,
|
||||
// isolate and restore (delta 2), and never share a wave with C17/C18.
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33']) {
|
||||
assert.equal(entryAdmission(cellId), 'general', cellId)
|
||||
assert.equal(selectorWaveDelta(cellId), 2, cellId)
|
||||
}
|
||||
const usPromoted = 'production-gce-c26,production-gce-c32,production-gce-c33'
|
||||
assert.deepEqual(validateSameCapWave({
|
||||
mode: 'batch-apply',
|
||||
cellIds: usUnpromoted,
|
||||
cellIds: usPromoted,
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usUnpromoted}`,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usPromoted}`,
|
||||
canaryRunId: '42'
|
||||
}).cells, ['production-gce-c26', 'production-gce-c32', 'production-gce-c33'])
|
||||
const usMixed = 'production-gce-c32,production-gce-c17'
|
||||
assert.throws(() => validateSameCapWave({
|
||||
mode: 'batch-apply',
|
||||
cellIds: usMixed,
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usMixed}`,
|
||||
canaryRunId: '42'
|
||||
}), /all general or all migration-only/)
|
||||
// A mixed wave has no single selector delta for its later cells to offset from.
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId)
|
||||
}
|
||||
assert.equal(resolveCellShape('production-gce-c12').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c34').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c35').status, 1)
|
||||
})
|
||||
|
||||
|
||||
@@ -275,11 +275,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell))
|
||||
// Only a declared rehome source may roll at a trusted protocol at all; the job refuses
|
||||
// the rest before it plans, and the next test covers them at protocol 0.
|
||||
// C32 and C33 are already rehome sources but stay migration-only until their canaries.
|
||||
const unpromotedSources = ['production-gce-c32', 'production-gce-c33']
|
||||
// The C34 spare is a rehome source that stays migration-only.
|
||||
assert.deepEqual(
|
||||
SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)),
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => !unpromotedSources.includes(cell))
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c34')
|
||||
)
|
||||
for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) {
|
||||
const { cap, pool } = cellShape(cellId)
|
||||
|
||||
@@ -24,14 +24,16 @@ const CELL_SHAPES = {
|
||||
'production-gce-c30': 'asia-east2-a',
|
||||
'production-gce-c31': 'asia-east2-b',
|
||||
'production-gce-c32': 'us-central1-a',
|
||||
'production-gce-c33': 'us-central1-b'
|
||||
'production-gce-c33': 'us-central1-b',
|
||||
'production-gce-c34': 'asia-east2-c'
|
||||
},
|
||||
waves: [
|
||||
['production-gce-c27', 'production-gce-c28', 'production-gce-c29'],
|
||||
['production-gce-c30'],
|
||||
['production-gce-c31'],
|
||||
// Declared together, so they plan together: a lone C32 plan would hit C33's missing template.
|
||||
['production-gce-c32', 'production-gce-c33']
|
||||
['production-gce-c32', 'production-gce-c33'],
|
||||
['production-gce-c34']
|
||||
]
|
||||
},
|
||||
staging: {
|
||||
|
||||
@@ -161,6 +161,18 @@ test('accepts the additive production C31 wave only in asia-east2-b', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts the additive production C34 spare wave only in asia-east2-c', () => {
|
||||
const c34Config = { ...productionConfig, cells: ['production-gce-c34'] }
|
||||
assert.deepEqual(
|
||||
validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34', 'asia-east2-c') }, c34Config),
|
||||
{ environment: 'production', cells: ['production-gce-c34'], changes: 4 }
|
||||
)
|
||||
assert.throws(
|
||||
() => validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34') }, c34Config),
|
||||
/fixed-one Asia MIG shape/
|
||||
)
|
||||
})
|
||||
|
||||
// A US cell joins the root region: no additional-region network, no region label or line, and
|
||||
// the default pool emits no line, exactly as the startup template renders a root-region cell.
|
||||
function usCellPlan(hostname, zone) {
|
||||
@@ -269,7 +281,8 @@ test('accepts only a reviewed Asia topology wave', () => {
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production-gce-c29,production-gce-c27,production-gce-c28',
|
||||
'production-gce-c30',
|
||||
'production-gce-c31'
|
||||
'production-gce-c31',
|
||||
'production-gce-c34'
|
||||
]) {
|
||||
assert.doesNotThrow(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)),
|
||||
@@ -286,7 +299,8 @@ test('accepts only a reviewed Asia topology wave', () => {
|
||||
'production-gce-c32',
|
||||
'production-gce-c33',
|
||||
'production-gce-c32,production-gce-c33,production-gce-c34',
|
||||
'production-gce-c34'
|
||||
'production-gce-c33,production-gce-c34',
|
||||
'production-gce-c35'
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)),
|
||||
|
||||
@@ -215,7 +215,19 @@ promote while a same-cap restore has just returned an empty general US cell: the
|
||||
would land there and the canary would roll the new cell back. Both cells are declared rehome
|
||||
sources and sit in the same-cap migration-only list until each one's canary promotes it, then move
|
||||
to the general list. The shadow gate's fleet pool list tracks the 16-connection Asia pools, so
|
||||
whether a US cell belongs there is decided at promotion, not assumed.
|
||||
whether a US cell belongs there is decided at promotion, not assumed. Both were promoted to general
|
||||
on 2026-10-01, so the same-cap job now rolls them as general cells. They stay out of the fleet pool
|
||||
list because their pool is the US default of 10.
|
||||
|
||||
C34 is an Asia spare at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It is
|
||||
its own topology wave and registers alone as migration-only, then the director is configured with
|
||||
`cell-ids` set to C34. It has no promotion wave: the Asia admission script and workflow refuse
|
||||
`promote` for it, and placement and regional rehome select only general cells. It is a
|
||||
migration-only landing zone that only an explicit evacuation or migration naming it can target.
|
||||
Do not name it in the multi-target `promote-general-cell` or `retire-migration-cell` modes, which
|
||||
accept any migration-only cell. It is a declared rehome source, sits in the same-cap migration-only
|
||||
list, and stays out of the fleet pool list. Promoting it later takes its own reviewed change adding a
|
||||
promotion wave and canary entry.
|
||||
Rollback returns
|
||||
Asia cells to migration-only; it does not destroy the network or use
|
||||
existing-only. The production topology dispatch remains unavailable until the
|
||||
|
||||
@@ -332,7 +332,7 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It
|
||||
rejects deletes, replacements, loss of an existing host route, US-resource
|
||||
changes, and unrelated drift. Do not add production C27-C29 until the
|
||||
compatible image has been published and each entry can pin its immutable
|
||||
digest. A later cell, such as C30 or C31, is its own reviewed wave. The shared URL map
|
||||
digest. A later cell, such as C30, C31 or the C34 spare, is its own reviewed wave. The shared URL map
|
||||
pulls every live cell into its plan, so the workflow plans each live cell at the
|
||||
image its state template already serves, and the validator rejects any change
|
||||
to a cell outside the wave. US C32 and C33 use the same workflow at the same
|
||||
|
||||
@@ -450,6 +450,21 @@ relay_gce_cells = {
|
||||
connection_hard_cap = 3000
|
||||
connection_unobserved_bound = 60
|
||||
}
|
||||
# Asia spare: registered migration-only as a drain landing zone; c completes the 2/2/2 zone spread.
|
||||
"production-gce-c34" = {
|
||||
hostname = "c34"
|
||||
region = "asia-east2"
|
||||
zone = "asia-east2-c"
|
||||
machine_type = "e2-standard-4"
|
||||
boot_disk_gb = 30
|
||||
boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21"
|
||||
capacity_requests = 6000
|
||||
database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters).
|
||||
image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269"
|
||||
initially_enabled = false
|
||||
connection_hard_cap = 3000
|
||||
connection_unobserved_bound = 60
|
||||
}
|
||||
}
|
||||
|
||||
relay_region_rehome_source_cell_ids = [
|
||||
@@ -476,7 +491,8 @@ relay_region_rehome_source_cell_ids = [
|
||||
"production-gce-c30",
|
||||
"production-gce-c31",
|
||||
"production-gce-c32",
|
||||
"production-gce-c33"
|
||||
"production-gce-c33",
|
||||
"production-gce-c34"
|
||||
]
|
||||
|
||||
# Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "orca-cloud",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.34.6",
|
||||
"engines": {
|
||||
"node": ">=24 <27",
|
||||
"pnpm": ">=10"
|
||||
@@ -26,7 +26,7 @@
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
Generated
+93
-410
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
minimumReleaseAge: 4320
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// UMD relative requires cannot survive a self-contained bundle.
|
||||
export const JSONC_PARSER_ESM_ALIAS = {
|
||||
'jsonc-parser': resolve(import.meta.dirname, '../../node_modules/jsonc-parser/lib/esm/main.js')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const parserRequire = createRequire(require.resolve('remark-parse'))
|
||||
const mathRequire = createRequire(require.resolve('rehype-katex'))
|
||||
|
||||
// Both preview paths use DOM-free parsing so their dependencies also run in workers.
|
||||
export const markdownParserAliases = {
|
||||
'decode-named-character-reference': parserRequire.resolve('decode-named-character-reference'),
|
||||
'hast-util-from-html-isomorphic': mathRequire.resolve('hast-util-from-html-isomorphic')
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type OutputChunk = Rollup.OutputChunk
|
||||
// The CLI loads these paths after electron-vite replaces out/main.
|
||||
export const CLI_MAIN_ENTRY_NAMES = [
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'gitlab/project-ref-parser',
|
||||
'orca-profiles/profile-index-store',
|
||||
'claude-accounts/keychain',
|
||||
...[
|
||||
@@ -55,9 +56,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'cursor-desktop-profile-worker-entry',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'session-scanner-worker-entry',
|
||||
'foreign-sqlite-reader-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry',
|
||||
'usage-scan-worker-entry',
|
||||
@@ -123,10 +122,15 @@ function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>,
|
||||
electronFreeChunkCode: Map<OutputChunk, string>,
|
||||
runtime: EntryRuntime = 'plain-Node process'
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
const code = chunk.code
|
||||
if (electronFreeChunkCode.get(chunk) === code) {
|
||||
continue
|
||||
}
|
||||
if (ELECTRON_REQUIRE_RE.test(code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
||||
@@ -134,6 +138,7 @@ function assertNoElectronRequire(
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
}
|
||||
electronFreeChunkCode.set(chunk, code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,17 +278,30 @@ export function createPlainNodeEntryGuardPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
const electronFreeChunkCode = new Map<OutputChunk, string>()
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
|
||||
assertNoElectronRequire(
|
||||
entryName,
|
||||
entry,
|
||||
byFileName,
|
||||
electronFreeChunkCode,
|
||||
'plain-Node process'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
|
||||
assertNoElectronRequire(
|
||||
entryName,
|
||||
entry,
|
||||
byFileName,
|
||||
electronFreeChunkCode,
|
||||
'worker thread'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,4)][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,4)][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'}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@12.0.0 --activate \
|
||||
&& corepack prepare pnpm@12.8.1 --activate \
|
||||
&& pnpm --version
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -164,9 +164,10 @@ const rpmElectronRuntimeDependencies = [
|
||||
]
|
||||
|
||||
// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build.
|
||||
// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with
|
||||
// Keep in sync with isOsOpenedDocumentName() in src/main/startup/os-opened-documents.ts and with
|
||||
// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows.
|
||||
const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx']
|
||||
const TABULAR_FILE_EXTENSIONS = ['csv', 'tsv']
|
||||
|
||||
// Why: the config must load on a host-only install without resolving unused Windows addons.
|
||||
// This is load-time tolerance only; beforePack enforces that the target's natives are installed.
|
||||
@@ -283,7 +284,7 @@ module.exports = {
|
||||
// before the GUI process starts, so those deps need the same treatment.
|
||||
// Why: out/package.json pins compiled output to CommonJS so parent
|
||||
// package.json files with type=module cannot change the packaged CLI loader.
|
||||
// Why: the OpenCode SQLite worker entry is also spawned by the scanner
|
||||
// Why: the foreign SQLite reader entry is also spawned by the scanner
|
||||
// service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into
|
||||
// app.asar. Left packed, that spawn fails closed and every OpenCode session
|
||||
// disappears from Agent Session History in packaged builds only. Worker
|
||||
@@ -302,6 +303,7 @@ module.exports = {
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/gitlab/project-ref-parser.js',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/orca-profiles/profile-index-store.js',
|
||||
@@ -310,8 +312,7 @@ module.exports = {
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/wsl-transcript-fs-process-entry.js',
|
||||
'out/main/cursor-desktop-profile-worker-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/foreign-sqlite-reader-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
@@ -510,16 +511,25 @@ module.exports = {
|
||||
include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh')
|
||||
},
|
||||
mac: {
|
||||
// Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming
|
||||
// Why rank Alternate: Orca joins Finder's "Open With" list without claiming
|
||||
// LSHandlerRank ownership, so whichever editor the user already prefers stays the default.
|
||||
// Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break.
|
||||
fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
|
||||
ext,
|
||||
name: 'Markdown Document',
|
||||
description: 'Markdown Document',
|
||||
role: 'Editor',
|
||||
rank: 'Alternate'
|
||||
})),
|
||||
fileAssociations: [
|
||||
...MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
|
||||
ext,
|
||||
name: 'Markdown Document',
|
||||
description: 'Markdown Document',
|
||||
role: 'Editor',
|
||||
rank: 'Alternate'
|
||||
})),
|
||||
...TABULAR_FILE_EXTENSIONS.map((ext) => ({
|
||||
ext,
|
||||
name: `${ext.toUpperCase()} Document`,
|
||||
description: `${ext.toUpperCase()} Document`,
|
||||
role: 'Editor',
|
||||
rank: 'Alternate'
|
||||
}))
|
||||
],
|
||||
icon: 'resources/build/icon.icns',
|
||||
entitlements: 'resources/build/entitlements.mac.plist',
|
||||
entitlementsInherit: 'resources/build/entitlements.mac.plist',
|
||||
@@ -612,7 +622,7 @@ module.exports = {
|
||||
// override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the
|
||||
// default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to
|
||||
// application/x-genesis-32x-rom, so claiming it here would need a glob override.
|
||||
mimeTypes: ['text/markdown'],
|
||||
mimeTypes: ['text/markdown', 'text/csv', 'text/tab-separated-values'],
|
||||
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
|
||||
// The Linux installer should not claim those system package/file names.
|
||||
executableName: 'orca-ide',
|
||||
|
||||
+1
-3
@@ -9,9 +9,7 @@
|
||||
"src/main/computer/sidecar-entry.ts",
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/rate-limits/cursor-desktop-profile-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-entry.ts",
|
||||
"src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
|
||||
@@ -95,9 +95,9 @@ not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is
|
||||
small (10 reviewed entries — one test fixture title, five non-English
|
||||
language-name search keywords, and four reviewed product-name search
|
||||
keywords): new candidates fail the check and must be localized or added with
|
||||
small (11 reviewed entries — six non-English language-name search keywords,
|
||||
four reviewed product-name search keywords, and one non-UI `label` placement
|
||||
prop): new candidates fail the check and must be localized or added with
|
||||
a reviewed reason in the same change.
|
||||
|
||||
The script scans `src/renderer/src` by default. That is the primary UI surface.
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
[
|
||||
{
|
||||
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Nightly #1",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
|
||||
"kind": "object-property:label",
|
||||
"text": "Hermes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Idioma",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "语言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "語言",
|
||||
"text": "Langue",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
@@ -51,24 +30,17 @@
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Idioma",
|
||||
"text": "語言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Langue",
|
||||
"text": "语言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
@@ -77,11 +49,11 @@
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
@@ -91,317 +63,16 @@
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:title",
|
||||
"text": "Default shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:description",
|
||||
"text": "Shell used for new terminal panes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "shell",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "terminal",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "fish",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "bash",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "nushell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "default",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:title",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:description",
|
||||
"text": "Choose what Orca opens for new local terminal panes.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:ariaLabel",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "System shell (",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "Custom shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:placeholder",
|
||||
"text": "fish, nu, or /bin/zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:aria-label",
|
||||
"text": "Custom shell executable",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Enter a shell name on PATH or an executable path. Orca starts it as a login shell.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": ". Switch to System shell or choose an executable on this host.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "arguments",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "args",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "login",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "wrapper",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "rcfile",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Advanced",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Shell arguments",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-expression",
|
||||
"text": "Starts the shell as a login shell with -l.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-expression",
|
||||
"text": "Enter one argument per line. Leave it empty to pass no arguments.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:ariaLabel",
|
||||
"text": "Shell argument mode",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "-l (default)",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "Custom args",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:placeholder",
|
||||
"text": "--rcfile /path/to/rcfile",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:aria-label",
|
||||
"text": "Shell arguments, one per line",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:description",
|
||||
"text": "Shell and arguments used for new local interactive terminal panes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "terminal",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "fish",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "bash",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "nushell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "arguments",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "args",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "login",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "wrapper",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "rcfile",
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
!include "${__FILEDIR__}\orca-process-check.nsh"
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Markdown "Open with Orca" (issue #10138)
|
||||
; Markdown and CSV/TSV "Open with Orca" (issues #10138, #23225)
|
||||
;
|
||||
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
|
||||
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
|
||||
@@ -21,29 +21,36 @@
|
||||
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
|
||||
; value here.
|
||||
;
|
||||
; MARKDOWN_PROGID must stay in sync with the extension list handled by
|
||||
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
|
||||
; Keep the extension list in sync with isOsOpenedDocumentName().
|
||||
; ---------------------------------------------------------------------------
|
||||
!define MARKDOWN_PROGID "Orca.Markdown"
|
||||
!define TABULAR_PROGID "Orca.Tabular"
|
||||
|
||||
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
|
||||
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
|
||||
!macro ORCA_REGISTER_DOCUMENT_OPEN_WITH EXT PROGID
|
||||
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
|
||||
!macroend
|
||||
|
||||
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
|
||||
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
|
||||
!macro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH EXT PROGID
|
||||
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}"
|
||||
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
|
||||
!macroend
|
||||
|
||||
!macro ORCA_REGISTER_DOCUMENT_PROGID PROGID NAME
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}" "" "${NAME}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\DefaultIcon" "" "$appExe,0"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open\command" "" '"$appExe" "%1"'
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
|
||||
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
|
||||
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
|
||||
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${MARKDOWN_PROGID}" "Markdown Document"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}"
|
||||
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}"
|
||||
; Why: Explorer caches the association list until told otherwise.
|
||||
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
|
||||
!macroend
|
||||
@@ -100,8 +107,11 @@
|
||||
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
|
||||
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
|
||||
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
|
||||
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${TABULAR_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}"
|
||||
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
|
||||
!macroend
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
"anti-slop/no-module-mocking": "off"
|
||||
}
|
||||
},
|
||||
// Test-only side effect for explicit RPC registries; real catalog tests never import it.
|
||||
{
|
||||
"files": ["**/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts"],
|
||||
"rules": {
|
||||
"anti-slop/no-module-mocking": "off"
|
||||
}
|
||||
},
|
||||
// The exemptions below are file-scoped rather than inline `oxlint-disable` comments
|
||||
// because the root lint scan does not load this plugin, so an inline directive naming
|
||||
// an anti-slop rule always reads back as an unused directive there.
|
||||
|
||||
@@ -25,8 +25,6 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'node-pty',
|
||||
'posthog-node',
|
||||
'proper-lockfile',
|
||||
// serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too.
|
||||
'serve-sim',
|
||||
'qrcode',
|
||||
'ssh2',
|
||||
'tweetnacl',
|
||||
@@ -34,6 +32,9 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
// Why macOS only: serve-sim drives the iOS Simulator, and its native addon is a Mach-O that
|
||||
// Windows signing rejects as a PE file.
|
||||
const DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['serve-sim']
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@vscode/windows-process-tree',
|
||||
'@orca/windows-registry'
|
||||
@@ -180,6 +181,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
|
||||
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
|
||||
const packageRoots = [
|
||||
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
||||
...(electronPlatformName === 'darwin' ? DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS : []),
|
||||
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
|
||||
]
|
||||
for (const packageName of packageRoots) {
|
||||
|
||||
+157
-29
@@ -639,7 +639,7 @@
|
||||
"protection": "partial",
|
||||
"owner": "agent-session-runtime",
|
||||
"layer": "runtime-unit",
|
||||
"surfaces": ["structured chat journal replay", "structured chat recovery"],
|
||||
"surfaces": ["structured chat journal replay", "structured chat damaged history"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
@@ -648,15 +648,17 @@
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts"
|
||||
],
|
||||
"invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.",
|
||||
"oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.",
|
||||
"invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, a newer schema winning over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.",
|
||||
"oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; name a future-schema row read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.",
|
||||
"commands": [
|
||||
// As the 2026-09-11 evidence run ran it; the recovery test it names went with the read-time repair.
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts"
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-damage.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts",
|
||||
"src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts"
|
||||
"src/main/native-chat/agent-session-journal/journal-damage.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -664,9 +666,18 @@
|
||||
"assertions": [
|
||||
"releases superseded revision bodies while reducing a long journal",
|
||||
"holds no read snapshot while reducing, so a checkpoint can pass mid-replay",
|
||||
"keeps the prefix but latches read-only for a future row beyond a gap",
|
||||
"keeps gap repair precedence when a later row is malformed",
|
||||
"rejects an unanchored prefix before a later gap"
|
||||
"reads past a gap to a future row, which names the newer row and no damage",
|
||||
"names the first damage when a later row is malformed too",
|
||||
"names a missing epoch row before a later gap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts",
|
||||
"assertions": [
|
||||
"fails to load and keeps every row: %s",
|
||||
"reads past damage to a newer build's row, which fails the load as a newer Orca's instead",
|
||||
"is refused when it is written, and the chat still loads with every other row",
|
||||
"is founded afresh on open, with nothing deleted, and takes writes"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -679,6 +690,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 7.71,
|
||||
"summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-10-02",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.56,
|
||||
"summary": "450 tests passed across 42 files, run as the vitest invocation the test script makes. Damage fails the load with every row kept; the retained-heap, newer-row-past-a-gap and cursor-cleanup assertions passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -948,7 +968,7 @@
|
||||
"invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.",
|
||||
"oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -963,6 +983,7 @@
|
||||
"src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx",
|
||||
"src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx",
|
||||
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx",
|
||||
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx",
|
||||
"src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"mobile/src/session/mobile-native-chat-image-attachment.test.ts",
|
||||
"mobile/src/session/use-mobile-native-chat-image-attachments.test.ts",
|
||||
@@ -1033,13 +1054,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
"date": "2026-10-04",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 22.1,
|
||||
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests."
|
||||
"durationSeconds": 42.3,
|
||||
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file."
|
||||
},
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
@@ -12107,10 +12128,10 @@
|
||||
"https://github.com/stablyai/orca/pull/12778"
|
||||
],
|
||||
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.",
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, serializes worker work, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.",
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, bounds scanner-service calls at sixteen, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty-startup-barrier-and-listing.test.ts",
|
||||
@@ -12118,7 +12139,7 @@
|
||||
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
|
||||
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"src/renderer/src/lib/ai-vault-tab-title-sync.test.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-client.test.ts",
|
||||
"src/main/ai-vault/session-scanner-service-client.test.ts",
|
||||
"src/main/ai-vault/session-title-file-reader.test.ts",
|
||||
"src/main/ai-vault/session-parse-cache-persistence.test.ts",
|
||||
"src/main/ipc/ai-vault.test.ts",
|
||||
@@ -12168,12 +12189,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ai-vault/session-scanner-worker-client.test.ts",
|
||||
"file": "src/main/ai-vault/session-scanner-service-client.test.ts",
|
||||
"assertions": [
|
||||
"full scans and exact-title reads share one serial FIFO worker",
|
||||
"active cancellation stays serialized and queued work remains bounded",
|
||||
"worker faults restart queued work and idle time preserves incremental parse state",
|
||||
"worker disposal rejects retained work and terminates the worker"
|
||||
"the service waits for ready and runs the cache and interactive lanes independently",
|
||||
"active and queued calls are bounded together at sixteen",
|
||||
"cancellation reaches active work and kills a service that ignores it",
|
||||
"service faults restart queued work under a restart circuit that a forced refresh reopens"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -12202,13 +12223,13 @@
|
||||
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"date": "2026-10-02",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.1,
|
||||
"summary": "The focused run passed 112 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, persistent serial worker lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback."
|
||||
"durationSeconds": 5.3,
|
||||
"summary": "The focused run passed 138 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, per-lane scanner-service lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -12221,11 +12242,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to one serial worker or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion."
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to the local scanner service or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active worker operation, 16 queued operations, four concurrent transcript parses inside the worker, a 4,096-title index, and no worktree-path-triggered refresh. The worker emits the aiVault.scan.worker span with duration and session count for full scans."
|
||||
"evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active scanner-service call per lane (cache, interactive), 16 active plus queued calls, four concurrent transcript parses inside the service, a 4,096-title index, and no worktree-path-triggered refresh. The service emits the aiVault.scan.service span with duration and session count for full scans."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Add deterministic call-count instrumentation.",
|
||||
@@ -16958,15 +16979,38 @@
|
||||
"invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.",
|
||||
"oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts"
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts",
|
||||
"assertions": [
|
||||
"repaints a pushed image exactly and keeps the history the TUI covers",
|
||||
"keeps the pane's history when the host screen has another grid",
|
||||
"keeps history unduplicated when a pushed image also carries history",
|
||||
"paints the whole image when the TUI started while hidden",
|
||||
"reads the pane buffer after a queued alt-screen entry parses",
|
||||
"repaints from the normal buffer once the host TUI has exited",
|
||||
"clears a raw byte replay in place on the alt screen",
|
||||
"paints a requested image from an exited TUI exactly over an alt screen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts",
|
||||
"assertions": [
|
||||
"keeps the covered history for a live TUI exactly as the host holds it",
|
||||
"repaints from the normal buffer once the host TUI has exited"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"assertions": [
|
||||
@@ -21411,6 +21455,90 @@
|
||||
"A tombstone that exhausts its retries stays on disk until the next startup, unchanged from before."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if the reused listing strands a displaced root, crosses the admission cap, rearms an exhausted retry through another root, hands one tombstone to removal twice, or touches a recreated live history path."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.consumed-side-effect-retention",
|
||||
"title": "Terminal side-effect queues release successfully applied and evicted effects",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-unit",
|
||||
"surfaces": ["terminal output side effects", "renderer memory census"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Provider-independent queue and mocked IPC output contracts run on macOS. Remote-runtime uses this processor but has no live session run. Native mobile uses another processor and is unaffected; host ownership, ACKs, wire, paths, folder/git identity, PTY lifecycle and output bytes are unchanged. Linux, Windows, WSL and live remote execution are gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/blob/main/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts"
|
||||
],
|
||||
"invariant": "Release consumed title/payload objects after successful apply or overflow carry, preserving callback order, 64-effect drains, the 512-effect pending cap, bell and payload carry, empty-tail coalescing, reentrant clear/flush/enqueue, thrown-apply behavior and output delivery.",
|
||||
"oracle": "Forced GC collects all 64 applied effects from a 100-effect bounded drain while the remaining 36 stay alive and deliver in order; it also collects the first evicted effect in a 513-effect burst while all 512 survivors remain alive. Clearing during apply immediately releases all 99 other pending effects, as the original queue did. Census reports 36 retained objects after the bounded drain. Explicit reentrant and error cases produce the same observations against the original queue.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts",
|
||||
"assertions": [
|
||||
"releases applied effects while preserving every pending effect and its delivery order",
|
||||
"releases an evicted effect before the compaction threshold",
|
||||
"releases every pending effect immediately when clear is called during apply"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts",
|
||||
"assertions": [
|
||||
"keeps empty-tail coalescing observable during the apply callback",
|
||||
"delivers the same effect requeued after clear without releasing its new slot",
|
||||
"preserves nested flush order and effects enqueued after the inner compaction",
|
||||
"preserves thrown apply errors and their existing empty-tail coalescing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.66,
|
||||
"summary": "46 tests across five files passed. Four reentrant/error cases also pass against an isolated original-queue copy; stock and candidate both release 99 pending effects when clear runs during apply."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "Focused renderer queue/output tests, including forced GC; p95 not established."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Local author and independent review validation; no CI soak. Reference-lifetime tests require the test runner's existing --expose-gc."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Original queue fails both WeakRef collection assertions: all 64 applied effects and the evicted effect remain reachable. Clearing only consumed slots passes while all pending effects stay live. Baseline and candidate both pass all four reentrant/error observations."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Retained objects fall from 100 to 36 after one bounded drain and from 513 to 512 after one overflow eviction before compaction. Release is constant work per consumed effect; no changed drain limit, timer, polling, batching, cache, transport call or subprocess. Existing compaction cadence remains; clear truncates then replaces its backing array to preserve immediate release and protect reentrant same-object requeue."
|
||||
},
|
||||
"knownGaps": [
|
||||
"No real renderer heap-byte or input-latency measurement; references and complete delivery are the deterministic oracle.",
|
||||
"No live Linux, Windows, WSL, SSH or paired-runtime session run; these use provider-independent queue code.",
|
||||
"Thrown apply callbacks keep their consumed reference until the original compaction boundary to preserve exception/coalescing behavior."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Collect CI soak with zero unexplained GC flakes and retain callback-order, overflow-carry and reentrancy assertions."
|
||||
],
|
||||
"demotionRule": "Keep experimental; investigate delivery, coalescing, error-path or pending-reference regressions without weakening the retention or fidelity oracle."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
+29
-27
@@ -8,10 +8,14 @@
|
||||
* Counting passes patch `Map`/`Set`/`Object.assign`/`Object.values`, which deoptimizes them, so
|
||||
* counts and timings are taken in separate passes and never from the same run.
|
||||
*
|
||||
* Run: ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts
|
||||
*
|
||||
* Scale mirrors the reporting user rather than the 100-worktree fixture in
|
||||
* docs/reference/renderer-agent-status-performance.md: 423 worktrees, 634 terminal tabs.
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { AgentStatusBatchUpdate } from '@/store/slices/agent-status'
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
TEST_REPO
|
||||
} from '@/store/slices/store-test-helpers'
|
||||
import { makePaneKey } from '../../src/shared/stable-pane-id'
|
||||
import { getDefaultSettings } from '../../src/shared/constants'
|
||||
import {
|
||||
createAgentStatusPaneRoutingIndex,
|
||||
resolvePaneKeyFromRoutingIndex
|
||||
@@ -39,25 +44,24 @@ const counters = { maps: 0, sets: 0, tabComparisons: 0 }
|
||||
|
||||
const NativeMap = globalThis.Map
|
||||
const NativeSet = globalThis.Set
|
||||
const nativeArrayIterator = Array.prototype[Symbol.iterator]
|
||||
|
||||
function withAllocationCounting<T>(run: () => T): T {
|
||||
class CountingMap<K, V> extends NativeMap<K, V> {
|
||||
constructor(entries?: readonly (readonly [K, V])[] | null) {
|
||||
constructor(entries?: Iterable<readonly [K, V]> | null) {
|
||||
super(entries)
|
||||
counters.maps += 1
|
||||
}
|
||||
}
|
||||
class CountingSet<V> extends NativeSet<V> {
|
||||
constructor(values?: readonly V[] | null) {
|
||||
constructor(values?: Iterable<V> | null) {
|
||||
super(values)
|
||||
counters.sets += 1
|
||||
}
|
||||
}
|
||||
counters.maps = 0
|
||||
counters.sets = 0
|
||||
globalThis.Map = CountingMap as unknown as MapConstructor
|
||||
globalThis.Set = CountingSet as unknown as SetConstructor
|
||||
globalThis.Map = CountingMap
|
||||
globalThis.Set = CountingSet
|
||||
try {
|
||||
return run()
|
||||
} finally {
|
||||
@@ -68,34 +72,33 @@ function withAllocationCounting<T>(run: () => T): T {
|
||||
|
||||
/** Tab list whose iteration is observable, so the nested-loop resolver's comparisons are countable. */
|
||||
class CountingTabList<T> extends Array<T> {
|
||||
[Symbol.iterator](): IterableIterator<T> {
|
||||
const inner = nativeArrayIterator.call(this) as IterableIterator<T>
|
||||
const wrapped: IterableIterator<T> = {
|
||||
next: () => {
|
||||
const result = inner.next()
|
||||
if (!result.done) {
|
||||
counters.tabComparisons += 1
|
||||
}
|
||||
return result
|
||||
},
|
||||
[Symbol.iterator]: () => wrapped
|
||||
[Symbol.iterator](): ArrayIterator<T> {
|
||||
const iterator = super[Symbol.iterator]()
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = (...args) => {
|
||||
const result = next(...args)
|
||||
if (!result.done) {
|
||||
counters.tabComparisons += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
return wrapped
|
||||
return iterator
|
||||
}
|
||||
}
|
||||
|
||||
function buildFixture(countTabIteration: boolean) {
|
||||
const store = createTestStore()
|
||||
const settings = store.getState().settings ?? getDefaultSettings(tmpdir())
|
||||
const tabsByWorktree: AppState['tabsByWorktree'] = {}
|
||||
const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {}
|
||||
const worktrees = []
|
||||
const worktrees: ReturnType<typeof makeWorktree>[] = []
|
||||
const paneKeys: string[] = []
|
||||
const owners: { tabId: string; worktreeId: string }[] = []
|
||||
for (let index = 0; index < WORKTREES; index += 1) {
|
||||
const worktreeId = `wt-${index}`
|
||||
worktrees.push(makeWorktree({ id: worktreeId, repoId: TEST_REPO.id }))
|
||||
const tabs = []
|
||||
const unified = []
|
||||
const tabs: ReturnType<typeof makeTab>[] = []
|
||||
const unified: ReturnType<typeof makeUnifiedTab>[] = []
|
||||
for (let tab = 0; tab < (index % 2 === 0 ? 1 : 2); tab += 1) {
|
||||
const tabId = `tab-${index}-${tab}`
|
||||
tabs.push(makeTab({ id: tabId, worktreeId, title: `Terminal ${index}-${tab}` }))
|
||||
@@ -110,9 +113,7 @@ function buildFixture(countTabIteration: boolean) {
|
||||
paneKeys.push(makePaneKey(tabId, LEAF_ID))
|
||||
owners.push({ tabId, worktreeId })
|
||||
}
|
||||
tabsByWorktree[worktreeId] = countTabIteration
|
||||
? (CountingTabList.from(tabs) as unknown as typeof tabs)
|
||||
: tabs
|
||||
tabsByWorktree[worktreeId] = countTabIteration ? CountingTabList.from(tabs) : tabs
|
||||
unifiedTabsByWorktree[worktreeId] = unified
|
||||
}
|
||||
store.setState({
|
||||
@@ -122,8 +123,8 @@ function buildFixture(countTabIteration: boolean) {
|
||||
unifiedTabsByWorktree,
|
||||
terminalLayoutsByTabId: {},
|
||||
setGeneratedTabTitlesFromAgentPrompts: () => {},
|
||||
settings: { ...store.getState().settings, tabAutoGenerateTitle: false }
|
||||
} as Partial<AppState>)
|
||||
settings: { ...settings, tabAutoGenerateTitle: false }
|
||||
})
|
||||
return { store, paneKeys, owners }
|
||||
}
|
||||
|
||||
@@ -141,7 +142,7 @@ function per1k(value: number): number {
|
||||
function runIndexedRouting(store: ReturnType<typeof createTestStore>, paneKeys: string[]): void {
|
||||
for (let event = 0; event < EVENTS; event += 1) {
|
||||
if (event % BATCH_SIZE === 0) {
|
||||
store.setState({ agentStatusEpoch: event } as Partial<AppState>)
|
||||
store.setState({ agentStatusEpoch: event })
|
||||
}
|
||||
const index = createAgentStatusPaneRoutingIndex(store.getState())
|
||||
resolvePaneKeyFromRoutingIndex(index, paneKeys[event % paneKeys.length])
|
||||
@@ -287,7 +288,8 @@ describe('agent-status hot path benchmark', () => {
|
||||
}
|
||||
|
||||
const outputPath =
|
||||
process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? '/tmp/agent-status-hot-path-benchmark.json'
|
||||
process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ??
|
||||
join(tmpdir(), 'agent-status-hot-path-benchmark.json')
|
||||
writeFileSync(
|
||||
outputPath,
|
||||
`${JSON.stringify({ worktrees: WORKTREES, events: EVENTS, report }, null, 2)}\n`
|
||||
@@ -363,15 +363,12 @@ export function mobileWebAppBuildOptions(routes) {
|
||||
*/
|
||||
export function entryStaticClosure(metafile, entryOutputPath) {
|
||||
const reached = new Set([entryOutputPath])
|
||||
const queue = [entryOutputPath]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()
|
||||
for (const current of reached) {
|
||||
for (const imported of metafile.outputs[current]?.imports ?? []) {
|
||||
if (imported.kind !== 'import-statement' || reached.has(imported.path)) {
|
||||
continue
|
||||
}
|
||||
reached.add(imported.path)
|
||||
queue.push(imported.path)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
|
||||
@@ -539,10 +539,10 @@ describe('the Phase C budget', () => {
|
||||
key
|
||||
).toBeGreaterThanOrEqual(measured)
|
||||
}
|
||||
// 1 to 9 per route, which is why four per route was a bound rather than a fit and why the
|
||||
// 1 to 10 per route, which is why four per route was a bound rather than a fit and why the
|
||||
// envelope cannot be a line through the measurement either.
|
||||
expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1)
|
||||
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(9)
|
||||
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(10)
|
||||
})
|
||||
|
||||
it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => {
|
||||
@@ -614,13 +614,13 @@ describe('the Phase C budget', () => {
|
||||
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
|
||||
// The shell hands back null for a manifest over its own ceiling, so a derived ceiling above
|
||||
// that ships a green build no device can open. At the 42 images the tree carries, the envelope
|
||||
// plus 42 plus the document crosses 256 at 32 routes, which Phase C reaches. The crossing came
|
||||
// plus 42 plus the document crosses 256 at 30 routes, which Phase C reaches. The crossing came
|
||||
// in from 50 with the envelope: it grants the worst swept route to each one past the sweep,
|
||||
// where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out.
|
||||
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
|
||||
expect(assertAssetCeilingFitsShell(31, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251)
|
||||
expect(() => assertAssetCeilingFitsShell(32, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
|
||||
/260 .*256/
|
||||
expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251)
|
||||
expect(() => assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
|
||||
/261 .*256/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,10 +23,12 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
|
||||
import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs'
|
||||
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
|
||||
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
|
||||
import {
|
||||
ORCAD_EMOJI_SHORTCODE_DATASET,
|
||||
ORCAD_FOREIGN_SQLITE_READER_ENTRY,
|
||||
ORCAD_NODE_PTY_DIR,
|
||||
ORCAD_NODE_PTY_JS_ARTIFACTS,
|
||||
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
|
||||
@@ -56,6 +58,10 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
|
||||
// orcad restart would SIGKILL every running terminal.
|
||||
const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon)
|
||||
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
|
||||
// Why beside orcad.js: the hook server's OpenCode binder and the OpenCode history scanner
|
||||
// start this worker from the module dir, since orcad has no Electron resources tree.
|
||||
const FOREIGN_SQLITE_READER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader)
|
||||
const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER_ENTRY)
|
||||
const OUT_FILE = join(OUT_DIR, 'orcad.js')
|
||||
const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET
|
||||
if (!BUILD_TARGET) {
|
||||
@@ -205,6 +211,7 @@ function buildForkedChild(entryPoint, outfile) {
|
||||
const childResults = await Promise.all([
|
||||
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
|
||||
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE),
|
||||
buildForkedChild(FOREIGN_SQLITE_READER_ENTRY, FOREIGN_SQLITE_READER_OUT_FILE),
|
||||
...['writer', 'backup'].map((role) =>
|
||||
buildForkedChild(
|
||||
join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]),
|
||||
@@ -340,6 +347,16 @@ try {
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
try {
|
||||
smokeForeignSqliteReaderWorker(OUT_DIR)
|
||||
if (nodeRuntimePath) {
|
||||
smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[build-orcad] foreign SQLite reader worker check failed:', error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
|
||||
// this string, so two different builds carrying one version would share a directory — and an
|
||||
// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* gracefully degraded.
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { JSONC_PARSER_ESM_ALIAS } from '../build-plugins/jsonc-parser-esm.ts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
@@ -60,7 +61,6 @@ const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
'agent-hooks',
|
||||
'managed-hook-runtime.ts'
|
||||
)
|
||||
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
|
||||
ROOT,
|
||||
@@ -102,6 +102,7 @@ const RELAY_VERSION = '0.1.0'
|
||||
async function buildRelayBundles(outDir) {
|
||||
await build({
|
||||
entryPoints: [RELAY_ENTRY],
|
||||
alias: JSONC_PARSER_ESM_ALIAS,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
@@ -186,7 +187,7 @@ async function buildRelayBundles(outDir) {
|
||||
outfile: join(outDir, 'managed-hook-runtime.js'),
|
||||
// Why: jsonc-parser's default UMD build keeps relative dynamic requires
|
||||
// that break after bundling; its ESM entry is equivalent and self-contained.
|
||||
alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY },
|
||||
alias: JSONC_PARSER_ESM_ALIAS,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
@@ -293,6 +294,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
await build({
|
||||
entryPoints: [wslHookEntry],
|
||||
alias: JSONC_PARSER_ESM_ALIAS,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,9 @@ const CASTING_DISABLE_PATTERN =
|
||||
/\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*typescript\/consistent-type-assertions/
|
||||
const ANTI_SLOP_DISABLE_PATTERN =
|
||||
/\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*\banti-slop\//
|
||||
const REACT_DOCTOR_DISABLE_PATTERN =
|
||||
/^\s*\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s+react-doctor\/[\w-]+(?:\s*,\s*react-doctor\/[\w-]+)*\s*(?:--(?:(?!\*\/).)*)?(?:\*\/)?\s*$/
|
||||
const EXPLICIT_DISABLE_RULE_PATTERN = /(?:-disable(?:-next-line|-line)?\s+|^)[\w-]+(?:\/[\w-]+)?/
|
||||
export const OXLINT_SCANS = [
|
||||
{
|
||||
// Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root
|
||||
@@ -41,7 +44,12 @@ export const OXLINT_SCANS = [
|
||||
},
|
||||
{
|
||||
label: 'React Doctor',
|
||||
args: ['--config', 'config/oxlint-react-doctor.json']
|
||||
args: [
|
||||
'--config',
|
||||
'config/oxlint-react-doctor.json',
|
||||
'--report-unused-disable-directives-severity',
|
||||
'warn'
|
||||
]
|
||||
},
|
||||
{
|
||||
// Why changed-lines only: the renderer carries ~4.7k pre-existing restyle/raw-color
|
||||
@@ -250,6 +258,16 @@ export function collectBaseLineBlocks(root, comparisonBase, files = null) {
|
||||
}
|
||||
|
||||
export function isMovedCode(highlightedLines, baseBlocks) {
|
||||
return createMovedCodeMatcher(baseBlocks)(highlightedLines)
|
||||
}
|
||||
|
||||
export function createMovedCodeMatcher(baseBlocks) {
|
||||
// Base-revision blocks stay fixed for the gate run; normalize each visited block once.
|
||||
const normalizedBlocks = new Map()
|
||||
return (highlightedLines) => matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks)
|
||||
}
|
||||
|
||||
function matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks) {
|
||||
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
if (needle.length === 0) {
|
||||
return false
|
||||
@@ -262,8 +280,12 @@ export function isMovedCode(highlightedLines, baseBlocks) {
|
||||
// and nearly all of it must be present. Genuinely new code shares neither the
|
||||
// anchor nor the ordering, so it stays reported.
|
||||
const MIN_COVERAGE = 0.9
|
||||
return baseBlocks.some((rawHaystack) => {
|
||||
const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
return baseBlocks.some((block) => {
|
||||
let haystack = normalizedBlocks.get(block)
|
||||
if (!haystack) {
|
||||
haystack = block.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
normalizedBlocks.set(block, haystack)
|
||||
}
|
||||
for (let start = 0; start < haystack.length; start += 1) {
|
||||
if (haystack[start] !== needle[0]) {
|
||||
continue
|
||||
@@ -301,7 +323,8 @@ export function diagnosticTouchesAddedLines(
|
||||
diagnostic,
|
||||
rangesByFile,
|
||||
root = process.cwd(),
|
||||
baseBlocks = []
|
||||
baseBlocks = [],
|
||||
movedCodeMatcher = isMovedCode
|
||||
) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
@@ -313,7 +336,7 @@ export function diagnosticTouchesAddedLines(
|
||||
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
|
||||
return false
|
||||
}
|
||||
return !isMovedCode(
|
||||
return !movedCodeMatcher(
|
||||
diagnosticHighlightedLines(root, diagnostic.filename, label.span),
|
||||
baseBlocks
|
||||
)
|
||||
@@ -348,16 +371,34 @@ export function isCastingDirectiveUnusedWarning(diagnostic, root) {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the anti-slop rules live in a JS plugin that only config/oxlint-anti-slop.json loads, so
|
||||
// the root scan never sees those rule names and reports every anti-slop suppression as unused.
|
||||
// `audit:anti-slop` is the scan that enforces them.
|
||||
export function isAntiSlopDirectiveUnusedWarning(diagnostic, root) {
|
||||
// Unloaded plugin directives are checked by their owning scan.
|
||||
export function isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scanLabel) {
|
||||
if (!/^Unused (?:oxlint|eslint)-disable/.test(diagnostic.message ?? '')) {
|
||||
return false
|
||||
}
|
||||
if (scanLabel === 'React Doctor') {
|
||||
const labels = diagnostic.labels ?? []
|
||||
return (
|
||||
labels.length > 0 &&
|
||||
labels.every(({ span }) => {
|
||||
if (span.offset === undefined || span.length === undefined) {
|
||||
return false
|
||||
}
|
||||
const file = path.isAbsolute(diagnostic.filename)
|
||||
? diagnostic.filename
|
||||
: path.join(root, diagnostic.filename)
|
||||
// Oxlint spans use UTF-8 byte offsets, including before non-ASCII comments.
|
||||
const directive = readFileSync(file)
|
||||
.subarray(span.offset, span.offset + span.length)
|
||||
.toString('utf8')
|
||||
const rules = directive.split('--')[0]
|
||||
return EXPLICIT_DISABLE_RULE_PATTERN.test(rules) && !/\breact-doctor\//.test(rules)
|
||||
})
|
||||
)
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) =>
|
||||
diagnosticHighlightedLines(root, diagnostic.filename, label.span).some((line) =>
|
||||
ANTI_SLOP_DISABLE_PATTERN.test(line)
|
||||
diagnosticHighlightedLines(root, diagnostic.filename, label.span).some(
|
||||
(line) => ANTI_SLOP_DISABLE_PATTERN.test(line) || REACT_DOCTOR_DISABLE_PATTERN.test(line)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -429,6 +470,7 @@ export function main(
|
||||
}
|
||||
|
||||
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
|
||||
const movedCodeMatcher = createMovedCodeMatcher(baseBlocks)
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
@@ -436,8 +478,8 @@ export function main(
|
||||
(diagnostic) =>
|
||||
!isSuppressedDiagnostic(diagnostic, root) &&
|
||||
!isCastingDirectiveUnusedWarning(diagnostic, root) &&
|
||||
!isAntiSlopDirectiveUnusedWarning(diagnostic, root) &&
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
!isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scan.label) &&
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks, movedCodeMatcher)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runProcessSync } from '../../src/shared/child-process/run-process'
|
||||
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
isAntiSlopDirectiveUnusedWarning,
|
||||
isUnloadedPluginDirectiveUnusedWarning,
|
||||
isMovedCode,
|
||||
isRootCodeQualityPath,
|
||||
overlapsAddedLines,
|
||||
@@ -124,7 +126,7 @@ describe('moved-code exemption', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-slop directive unused warning', () => {
|
||||
describe('unloaded plugin directive unused warning', () => {
|
||||
const root = path.resolve(import.meta.dirname, '..', '..')
|
||||
// Assembled so no line here is itself a directive the gate would scan.
|
||||
const directive = (rule) => `/* oxlint-disable ${rule} -- reason */`
|
||||
@@ -146,21 +148,149 @@ describe('anti-slop directive unused warning', () => {
|
||||
|
||||
it('exempts a suppression the root scan cannot resolve', () => {
|
||||
withFixture(directive('anti-slop/no-module-mocking'), (diagnostic) => {
|
||||
expect(isAntiSlopDirectiveUnusedWarning(diagnostic, root)).toBe(true)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('still reports an unused directive for a rule the root scan does load', () => {
|
||||
withFixture(directive('unicorn/no-array-reduce'), (diagnostic) => {
|
||||
expect(isAntiSlopDirectiveUnusedWarning(diagnostic, root)).toBe(false)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores diagnostics that are not unused-directive warnings', () => {
|
||||
withFixture(directive('anti-slop/no-module-mocking'), (diagnostic) => {
|
||||
expect(
|
||||
isAntiSlopDirectiveUnusedWarning({ ...diagnostic, message: 'Unexpected any.' }, root)
|
||||
isUnloadedPluginDirectiveUnusedWarning(
|
||||
{ ...diagnostic, message: 'Unexpected any.' },
|
||||
root,
|
||||
'code quality'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function scanFixture(label, file) {
|
||||
const scan = OXLINT_SCANS.find((candidate) => candidate.label === label)
|
||||
if (!scan) {
|
||||
throw new Error(`Missing ${label} scan`)
|
||||
}
|
||||
const { command, prefixArgs } = resolveOxlintInvocation(root)
|
||||
const result = runProcessSync({
|
||||
program: command,
|
||||
args: [...prefixArgs, ...scan.args, '--format', 'json', file],
|
||||
cwd: root,
|
||||
timeoutMs: 30_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024
|
||||
})
|
||||
return JSON.parse(result.stdout).diagnostics
|
||||
}
|
||||
|
||||
it('accepts a used Doctor directive only through its loaded scan', () => {
|
||||
const source = [
|
||||
"import { useEffect, useState } from 'react'",
|
||||
directive('react-doctor/no-derived-state-effect'),
|
||||
'export function Title({ title }: { title: string }) {',
|
||||
" const [value, setValue] = useState('')",
|
||||
' useEffect(() => { setValue(title) }, [title])',
|
||||
' return value',
|
||||
'}'
|
||||
].join('\n')
|
||||
withFixture(source, ({ filename }) => {
|
||||
const normal = scanFixture('code quality', filename)
|
||||
const unused = normal.find((diagnostic) => diagnostic.message.startsWith('Unused '))
|
||||
expect(unused).toBeDefined()
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(unused, root, 'code quality')).toBe(true)
|
||||
expect(scanFixture('React Doctor', filename)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an unused Doctor directive failing in its loaded scan', () => {
|
||||
withFixture(directive('react-doctor/no-derived-state-effect'), ({ filename }) => {
|
||||
const diagnostics = scanFixture('React Doctor', filename)
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0].message).toMatch(/^Unused /)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hide unused native rules in a mixed directive', () => {
|
||||
withFixture(
|
||||
directive('react-doctor/no-derived-state-effect, unicorn/no-array-reduce'),
|
||||
(diagnostic) => {
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('recognizes a standalone directive containing only Doctor rules', () => {
|
||||
withFixture(
|
||||
directive(
|
||||
'react-doctor/no-derived-state-effect, react-doctor/no-adjust-state-on-prop-change'
|
||||
),
|
||||
(diagnostic) => {
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(true)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps adjacent native directive warnings visible', () => {
|
||||
const doctor = directive('react-doctor/no-derived-state-effect')
|
||||
const native = directive('unicorn/no-array-reduce')
|
||||
for (const source of [`${doctor} ${native}`, `${native} ${doctor}`]) {
|
||||
withFixture(source, ({ filename }) => {
|
||||
const diagnostic = scanFixture('code quality', filename).find((candidate) =>
|
||||
candidate.labels.some((label) => label.span.offset === source.indexOf(native))
|
||||
)
|
||||
expect(diagnostic).toBeDefined()
|
||||
expect(diagnostic.message).toMatch(/^Unused /)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves used native directives to the scan that loads them', () => {
|
||||
withFixture(
|
||||
[
|
||||
'export const banner = "λ"',
|
||||
directive('typescript/no-explicit-any'),
|
||||
'export const answer: any = 42'
|
||||
].join('\n'),
|
||||
({ filename }) => {
|
||||
expect(scanFixture('code quality', filename)).toEqual([])
|
||||
const diagnostics = scanFixture('React Doctor', filename)
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not exempt unused Doctor rules together with unloaded native rules', () => {
|
||||
withFixture(
|
||||
directive('react-doctor/no-derived-state-effect, typescript/no-explicit-any'),
|
||||
({ filename }) => {
|
||||
const diagnostics = scanFixture('React Doctor', filename)
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps blanket unused directives visible in the Doctor scan', () => {
|
||||
for (const source of [directive(''), '// oxlint-disable-next-line -- reason']) {
|
||||
withFixture(source, ({ filename }) => {
|
||||
const diagnostics = scanFixture('React Doctor', filename)
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('committed pin', () => {
|
||||
|
||||
it('runs in the static analysis job', () => {
|
||||
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '')
|
||||
const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '')
|
||||
expect(commands).toContain('pnpm run check:node-runtime-pin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ const result = spawnSync(
|
||||
[
|
||||
...prefixArgs,
|
||||
'dlx',
|
||||
'react-doctor@0.9.1',
|
||||
'react-doctor@0.9.14',
|
||||
'.',
|
||||
'--yes',
|
||||
'--scope',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user