mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into move-mobile-search-button-to-bottom-left-floating
This commit is contained in:
@@ -12,3 +12,13 @@
|
||||
/src/cli/bundled-skill-guides.ts text eol=lf
|
||||
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
|
||||
/resources/plugins/** text eol=lf
|
||||
# pnpm hashes every patch byte-for-byte, so a CRLF checkout breaks the install.
|
||||
/config/patches/*.patch -text
|
||||
# The xterm bundle hunks also make a diff nobody can read; review the hand-written
|
||||
# source patch under xterm-src/ instead. The sibling patches stay diffable.
|
||||
/config/patches/@xterm__xterm@*.patch -diff
|
||||
/config/patches/xterm-src/*.patch text eol=lf
|
||||
# Generated wrapper fixtures: collapse them in the PR diff so they stop drowning
|
||||
# the reviewable change, and pin LF because they are compared byte-for-byte.
|
||||
# Not -diff: the shell diff is the review surface when a wrapper does change.
|
||||
/src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf
|
||||
|
||||
@@ -52,21 +52,19 @@ If your change affects UI or interaction behavior, verify it on the platforms it
|
||||
|
||||
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
|
||||
|
||||
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
|
||||
CI enforces this for `src/preload/` and `src/shared/`.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Each pull request should:
|
||||
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
|
||||
|
||||
- explain the user-visible change
|
||||
- stay focused on a single topic when possible
|
||||
- include screenshots or screen recordings for new UI or behavior changes
|
||||
- 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
|
||||
- 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
|
||||
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
If there is no visual change, say that explicitly in the PR description.
|
||||
- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
## Release Process
|
||||
|
||||
@@ -114,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
|
||||
description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
@@ -10,17 +10,37 @@ inputs:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
persist-native-cache:
|
||||
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
|
||||
required: false
|
||||
default: 'true'
|
||||
cache-electron-package:
|
||||
description: Cache the Electron package archive and export ELECTRON_CACHE for following steps.
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
node-version:
|
||||
description: Resolved Node.js version used for the install.
|
||||
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
|
||||
native-cache-scope:
|
||||
description: Operating-system image scope used by the native module cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.scope }}
|
||||
native-cache-hit:
|
||||
description: Whether the compiled native module cache was restored.
|
||||
value: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# setup-node needs pnpm on PATH to locate and restore its store.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
id: default-node
|
||||
if: inputs.node-version == ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -28,6 +48,7 @@ runs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup requested Node.js
|
||||
id: requested-node
|
||||
if: inputs.node-version != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -49,7 +70,7 @@ runs:
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: inputs.native-runtime != 'none'
|
||||
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
@@ -63,14 +84,87 @@ runs:
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
# Why --frozen-lockfile: re-resolving pulls ~62 MB of registry packuments per job
|
||||
# (measured) to recompute what the lockfile already pins, and the `git diff` guard
|
||||
# below fails the run whenever that recomputation would have changed anything. The
|
||||
# guard stays so a stale lockfile still fails by name rather than by resolver error.
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install \
|
||||
--no-frozen-lockfile \
|
||||
--prefer-frozen-lockfile=false \
|
||||
--ignore-scripts
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
|
||||
fi
|
||||
|
||||
- name: Resolve Electron package cache
|
||||
id: electron-package-cache
|
||||
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$RUNNER_OS" in
|
||||
Linux) cache_root="$HOME/.cache/electron" ;;
|
||||
macOS) cache_root="$HOME/Library/Caches/electron" ;;
|
||||
Windows) cache_root="${LOCALAPPDATA:-$HOME/AppData/Local}/electron/Cache" ;;
|
||||
*)
|
||||
echo "::error::Unsupported runner OS for Electron cache: $RUNNER_OS"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf 'cache-root=%s\n' "$cache_root" >> "$GITHUB_OUTPUT"
|
||||
printf 'ELECTRON_CACHE=%s\n' "$cache_root" >> "$GITHUB_ENV"
|
||||
printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Electron package archive
|
||||
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.electron-package-cache.outputs.cache-root }}
|
||||
key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
|
||||
# Node version, and the patch whose contents the build has to match.
|
||||
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
|
||||
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
|
||||
# Native addons built on a newer Linux image can require glibc symbols
|
||||
# missing from an older runner/container. ImageOS distinguishes hosted
|
||||
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
|
||||
- name: Resolve native cache scope
|
||||
id: native-cache-scope
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
scope="${ID:-linux}-${VERSION_ID:-unknown}"
|
||||
fi
|
||||
echo "scope=$scope" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore compiled native modules
|
||||
id: native-cache-restore
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
id: native-cache-restore-only
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
## Summary
|
||||
## ELI5
|
||||
|
||||
Describe the user-visible change.
|
||||
<!-- Simple high-level explanation -->
|
||||
|
||||
## Screenshots
|
||||
## What Changed
|
||||
|
||||
- Add screenshots or a screen recording for any new or changed UI behavior.
|
||||
- If there is no visual change, say `No visual change`.
|
||||
<!-- Describe the change clearly and keep scope tight. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- What problem does this solve, and why is this approach right? -->
|
||||
|
||||
## Linked Issue
|
||||
|
||||
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Visual Proof
|
||||
|
||||
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
|
||||
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
|
||||
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `pnpm lint`
|
||||
- [ ] `pnpm typecheck`
|
||||
- [ ] `pnpm test`
|
||||
- [ ] `pnpm build`
|
||||
- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed
|
||||
<!-- How did you verify this? Steps a reviewer can follow. Which platforms did you actually test (macOS / Linux / Windows / SSH)? -->
|
||||
|
||||
## AI Review Report
|
||||
- [ ] I manually tested these changes locally
|
||||
- [ ] Automated tests added/updated, or explained why not below
|
||||
|
||||
Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result.
|
||||
Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR.
|
||||
## AI Disclosure
|
||||
|
||||
## Security Audit
|
||||
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
|
||||
<!-- Which AI model if anyone was used, please state the details -->
|
||||
|
||||
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
|
||||
## Review
|
||||
|
||||
## Agent skill upstream boundary
|
||||
|
||||
- [ ] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.
|
||||
|
||||
## Notes
|
||||
|
||||
Call out any platform-specific behavior, risks, or follow-up work.
|
||||
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR is small and focused
|
||||
- [ ] I explained what changed and why (including ELI5)
|
||||
- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
|
||||
- [ ] Self-reviewed for correctness, security, and performance
|
||||
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
|
||||
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { mergeLocBlock, renderLocBlock, sumChangedFiles } from './pr-test-loc-table.mjs'
|
||||
|
||||
export const PR_FILES_PAGE_LIMIT = 3000
|
||||
|
||||
export function nextLink(linkHeader) {
|
||||
if (linkHeader == null || linkHeader.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const part of linkHeader.split(',')) {
|
||||
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/)
|
||||
if (match != null) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'User-Agent': 'orca-pr-test-loc',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPullFiles({ owner, repo, pullNumber, token, fetchImpl = fetch }) {
|
||||
const files = []
|
||||
let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`
|
||||
|
||||
while (url != null) {
|
||||
const response = await fetchImpl(url, { headers: githubHeaders(token) })
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to list PR #${pullNumber} files: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
const page = await response.json()
|
||||
if (!Array.isArray(page)) {
|
||||
throw new Error(`Unexpected PR files payload for #${pullNumber}`)
|
||||
}
|
||||
files.push(...page)
|
||||
if (files.length >= PR_FILES_PAGE_LIMIT) {
|
||||
console.log(
|
||||
`PR #${pullNumber} file list hit GitHub's ${PR_FILES_PAGE_LIMIT}-file cap; totals may be short.`
|
||||
)
|
||||
return files.slice(0, PR_FILES_PAGE_LIMIT)
|
||||
}
|
||||
url = nextLink(response.headers.get('link'))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function writeGithubOutput(totals) {
|
||||
const block = `${renderLocBlock(totals)}\n`
|
||||
const outputPath = process.env.GITHUB_OUTPUT
|
||||
if (outputPath != null) {
|
||||
appendFileSync(outputPath, `summary<<ORCA_PR_LOC_EOF\n${block}ORCA_PR_LOC_EOF\n`)
|
||||
}
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath != null) {
|
||||
appendFileSync(summaryPath, `${block}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePullRequest({
|
||||
owner,
|
||||
repo,
|
||||
pullNumber,
|
||||
token,
|
||||
totals,
|
||||
fetchImpl = fetch,
|
||||
sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}) {
|
||||
const headers = githubHeaders(token)
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
|
||||
const response = await fetchImpl(url, { headers })
|
||||
if (response.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read PR #${pullNumber}: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const pull = await response.json()
|
||||
const nextBody = mergeLocBlock(pull.body, totals)
|
||||
if (nextBody === (pull.body ?? '')) {
|
||||
console.log(`PR #${pullNumber} LoC header already current.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const updateRequest = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ body: nextBody })
|
||||
}
|
||||
let update
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
update = await fetchImpl(url, updateRequest)
|
||||
if (update.ok || ![500, 502, 503, 504].includes(update.status) || attempt === 2) {
|
||||
break
|
||||
}
|
||||
await sleepImpl(1000 * 2 ** attempt)
|
||||
}
|
||||
if (update.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
}
|
||||
if (!update.ok) {
|
||||
throw new Error(`Failed to update PR #${pullNumber}: ${update.status} ${update.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`Updated LoC header on PR #${pullNumber}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveRepository() {
|
||||
const repository = process.env.GITHUB_REPOSITORY
|
||||
if (repository == null || !repository.includes('/')) {
|
||||
return undefined
|
||||
}
|
||||
const slash = repository.indexOf('/')
|
||||
return { owner: repository.slice(0, slash), repo: repository.slice(slash + 1) }
|
||||
}
|
||||
|
||||
function resolveToken() {
|
||||
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.error(
|
||||
`Usage: ${process.argv[1]} --from-pr <number> | --update-pr <number> [--files-json <file>] [--merge-body <file>]`
|
||||
)
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
let filesJsonPath
|
||||
let mergeBodyPath
|
||||
let fromPrNumber
|
||||
let updatePrNumber
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--files-json') {
|
||||
filesJsonPath = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--merge-body') {
|
||||
mergeBodyPath = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--from-pr') {
|
||||
fromPrNumber = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--update-pr') {
|
||||
updatePrNumber = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
const pullNumber = updatePrNumber ?? fromPrNumber
|
||||
if (
|
||||
(argv.includes('--files-json') && filesJsonPath == null) ||
|
||||
(argv.includes('--merge-body') && mergeBodyPath == null) ||
|
||||
(argv.includes('--from-pr') && fromPrNumber == null) ||
|
||||
(argv.includes('--update-pr') && updatePrNumber == null) ||
|
||||
(filesJsonPath == null && pullNumber == null)
|
||||
) {
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
let files
|
||||
if (filesJsonPath != null) {
|
||||
files = JSON.parse(readFileSync(filesJsonPath, 'utf8'))
|
||||
} else {
|
||||
const repository = resolveRepository()
|
||||
const token = resolveToken()
|
||||
if (repository == null || token == null) {
|
||||
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required to read a pull request.')
|
||||
return 2
|
||||
}
|
||||
files = await listPullFiles({
|
||||
...repository,
|
||||
pullNumber: Number(pullNumber),
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
const totals = sumChangedFiles(files)
|
||||
writeGithubOutput(totals)
|
||||
|
||||
if (mergeBodyPath != null) {
|
||||
process.stdout.write(mergeLocBlock(readFileSync(mergeBodyPath, 'utf8'), totals))
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(renderLocBlock(totals))
|
||||
|
||||
if (updatePrNumber == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const repository = resolveRepository()
|
||||
const token = resolveToken()
|
||||
if (repository == null || token == null) {
|
||||
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required with --update-pr.')
|
||||
return 2
|
||||
}
|
||||
|
||||
return updatePullRequest({
|
||||
...repository,
|
||||
pullNumber: Number(updatePrNumber),
|
||||
token,
|
||||
totals
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const LOC_BLOCK_START = '<!-- orca-pr-loc -->'
|
||||
export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
|
||||
export const LOC_HANDS_OFF_COMMENT =
|
||||
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
|
||||
|
||||
// Why the __-wrapped names: they are unambiguous test-data markers, so a dir
|
||||
// cannot be one by accident. Bare `fixtures/` is deliberately absent — it reads
|
||||
// as a plausible prod module name, and this classifier bills every PR.
|
||||
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i
|
||||
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
|
||||
|
||||
export function isTestPath(path) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized)
|
||||
}
|
||||
|
||||
export function emptyLocTotals() {
|
||||
return {
|
||||
test: { files: 0, added: 0, deleted: 0 },
|
||||
nonTest: { files: 0, added: 0, deleted: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export function sumChangedFiles(files) {
|
||||
const totals = emptyLocTotals()
|
||||
for (const file of files) {
|
||||
const path = file.filename
|
||||
if (path == null) {
|
||||
continue
|
||||
}
|
||||
const bucket = isTestPath(path) ? totals.test : totals.nonTest
|
||||
bucket.files += 1
|
||||
bucket.added += Number(file.additions ?? 0)
|
||||
bucket.deleted += Number(file.deletions ?? 0)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
const COLOR_ADDED = '#1a7f37'
|
||||
const COLOR_DELETED = '#cf222e'
|
||||
|
||||
function diffCell(count) {
|
||||
if (count === 0) {
|
||||
return '0'
|
||||
}
|
||||
if (count > 0) {
|
||||
return `$\\color{${COLOR_ADDED}}{\\Huge{\\mathbf{+}}}$\u200b${count}`
|
||||
}
|
||||
return `$\\color{${COLOR_DELETED}}{\\Huge{\\mathbf{−}}}$\u200b${Math.abs(count)}`
|
||||
}
|
||||
|
||||
function locTableRow(label, bucket) {
|
||||
const added = bucket.added ?? 0
|
||||
const deleted = bucket.deleted ?? 0
|
||||
return `| ${label} | ${bucket.files ?? 0} | ${diffCell(added)} | ${diffCell(-deleted)} | ${diffCell(added - deleted)} |`
|
||||
}
|
||||
|
||||
export function formatLocTable({ test, nonTest }) {
|
||||
return [
|
||||
'| | Files | Added | Deleted | Net |',
|
||||
'| :--- | ---: | ---: | ---: | ---: |',
|
||||
locTableRow('Test', test),
|
||||
locTableRow('Prod', nonTest)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderLocBlock(totals) {
|
||||
return [
|
||||
LOC_BLOCK_START,
|
||||
LOC_HANDS_OFF_COMMENT,
|
||||
'',
|
||||
formatLocTable(totals),
|
||||
'',
|
||||
LOC_BLOCK_END
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function mergeLocBlock(body, totals) {
|
||||
const block = renderLocBlock(totals)
|
||||
const current = body ?? ''
|
||||
const start = current.indexOf(LOC_BLOCK_START)
|
||||
const end = current.indexOf(LOC_BLOCK_END)
|
||||
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
const rest = current.slice(end + LOC_BLOCK_END.length).replace(/^\r?\n/, '')
|
||||
if (rest.trim().length === 0) {
|
||||
return `${current.slice(0, start)}${block}\n`
|
||||
}
|
||||
return `${current.slice(0, start)}${block}\n\n${rest.replace(/^\r?\n+/, '')}`
|
||||
}
|
||||
|
||||
if (current.trim().length === 0) {
|
||||
return `${block}\n`
|
||||
}
|
||||
|
||||
return `${block}\n\n${current.replace(/^\r?\n+/, '')}`
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
name: Adhoc macOS Dev Build
|
||||
name: Adhoc macOS + Windows Dev Build
|
||||
|
||||
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
|
||||
# actually run an experimental feature for a few days, instead of reasoning about
|
||||
# it from a diff. Hourly covers main; this covers everything that is not main yet.
|
||||
# Why: lets anyone cut installable macOS and Windows builds of a trusted repo ref
|
||||
# so the team can run an experimental feature instead of reasoning from a diff.
|
||||
# Hourly covers current main; this covers experiments and on-demand validation.
|
||||
#
|
||||
# Deliberately narrow scope, same trade as hourly:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# Deliberately narrow scope:
|
||||
# - macOS and Windows desktop installers. Linux keeps using RC/stable.
|
||||
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
|
||||
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
|
||||
# app's TCC grants on identifier + team rather than on its cdhash, so those
|
||||
# grants survive an update; an unnotarized build reads as a new client and
|
||||
# silently loses file access under Documents/Desktop/Downloads.
|
||||
# - macOS is signed and notarized so TCC grants survive updates.
|
||||
# - Windows is unsigned; the published release notes explain the one-time
|
||||
# SmartScreen/manual-install requirement.
|
||||
#
|
||||
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
|
||||
# orca-hourly. Separate from orca because the main repo's releases atom feed
|
||||
@@ -92,6 +91,11 @@ jobs:
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
|
||||
# policy to main so only main's copy of this file can read them.
|
||||
environment: adhoc-mac-build
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.adhoc.outputs.version }}
|
||||
head_sha: ${{ steps.adhoc.outputs.head_sha }}
|
||||
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -167,9 +171,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -290,14 +294,20 @@ jobs:
|
||||
--repo "$ADHOC_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`.
|
||||
--notes "Adhoc macOS and Windows dev build of \`$REF\` at commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
|
||||
|
||||
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
|
||||
ran. Signed and notarized like a release, so it installs through Orca's
|
||||
in-app updater and opens without a Gatekeeper prompt — but the branch may
|
||||
never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days."
|
||||
ran. The macOS build is signed and notarized like a release, so it installs
|
||||
through Orca's in-app updater and opens without a Gatekeeper prompt — but the
|
||||
branch may never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days.
|
||||
|
||||
**Windows builds are unsigned.** They install and update normally once you
|
||||
are on one, but a signed Stable or RC build cannot install one through the
|
||||
in-app updater — download \`orca-windows-setup.exe\` below and run it once
|
||||
(SmartScreen will warn about an unknown publisher). Every later switch,
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish adhoc macOS artifacts
|
||||
@@ -416,3 +426,27 @@ jobs:
|
||||
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
|
||||
# Why this runs after the mac leg rather than beside it: the tag and version
|
||||
# are computed inside that job, so until it has run nothing else can name the
|
||||
# release to upload into. The cost is small enough not to matter — the Windows
|
||||
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
|
||||
# ~9.5 min mac run, which keeps a adhoc run far inside its interval.
|
||||
#
|
||||
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
|
||||
# file itself came from, which for an ordinary dispatch is main. Someone who
|
||||
# deliberately points the Actions "Use workflow from" picker at a branch
|
||||
# already gets that branch's copy of this entire file, so this follows the same
|
||||
# rule instead of inventing a second one.
|
||||
build-adhoc-win:
|
||||
needs: build-adhoc-mac
|
||||
# Only once the mac release is actually live: there is no release to upload
|
||||
# into otherwise, and the Windows workflow refuses to create one.
|
||||
if: needs.build-adhoc-mac.outputs.published == 'true'
|
||||
uses: ./.github/workflows/dev-channel-win-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
channel: adhoc
|
||||
tag: ${{ needs.build-adhoc-mac.outputs.tag }}
|
||||
ref: ${{ needs.build-adhoc-mac.outputs.head_sha }}
|
||||
version: ${{ needs.build-adhoc-mac.outputs.version }}
|
||||
|
||||
@@ -29,6 +29,9 @@ on:
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/providers/local-pty-provider.ts'
|
||||
- 'src/main/providers/windows-shell-args.ts'
|
||||
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
@@ -43,8 +46,6 @@ on:
|
||||
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
|
||||
- 'src/shared/computer-use-*.ts'
|
||||
- 'tests/e2e/computer-linux.e2e.ts'
|
||||
- 'tests/e2e/computer-mac.e2e.ts'
|
||||
- 'tests/e2e/computer-mac-safari.e2e.ts'
|
||||
- 'tests/e2e/computer-windows.e2e.ts'
|
||||
- 'tests/e2e/computer-windows-store.e2e.ts'
|
||||
- 'tests/e2e/helpers/computer-cli-driver.ts'
|
||||
@@ -54,6 +55,10 @@ on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: computer-e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
native-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -68,30 +73,17 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
|
||||
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
|
||||
# permission on Linux runners; node-pty's install fallback then fails
|
||||
# before this smoke job can exercise the native package.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
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"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
|
||||
# is no root config) and falls back to the 5s default timeout with no
|
||||
# Windows worker cap, so the real csc.exe launcher-compile tests time out
|
||||
# on hosted Windows. Use the shared config so this job matches pnpm test.
|
||||
- run: >-
|
||||
pnpm vitest run --config config/vitest.config.ts
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
@@ -106,6 +98,7 @@ jobs:
|
||||
src/main/computer/macos-native-provider-socket.test.ts
|
||||
src/main/computer/macos-computer-use-permissions.test.ts
|
||||
src/main/computer/macos-computer-use-permission-status.test.ts
|
||||
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
|
||||
src/main/computer/desktop-script-provider-client.test.ts
|
||||
src/main/computer/desktop-script-provider-cache.test.ts
|
||||
src/main/computer/desktop-script-provider-actions.test.ts
|
||||
@@ -129,7 +122,7 @@ jobs:
|
||||
src/shared/remote-runtime-client.test.ts
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm run build:electron-vite:parallel
|
||||
# Why: boot the BUILT daemon-entry under plain Node the way production
|
||||
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
|
||||
# (leaked electron require) while every other check passed; this fails
|
||||
@@ -146,8 +139,9 @@ jobs:
|
||||
- name: Windows daemon workspace-close repro
|
||||
if: runner.os == 'Windows'
|
||||
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
|
||||
# Hosted macOS runners cannot receive persistent Accessibility/Screen Recording
|
||||
# grants. Keep the real native build/owner-loss checks on every trigger instead.
|
||||
mac-native-owner-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -155,13 +149,9 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
@@ -172,28 +162,6 @@ jobs:
|
||||
- name: Swift tests and signed universal helper verification
|
||||
run: pnpm verify:computer-native
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: macos-15
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:computer-macos
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
|
||||
|
||||
linux:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -202,23 +170,15 @@ jobs:
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
persist-credentials: false
|
||||
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
|
||||
# Why: keep scheduled Linux e2e on the same native install path as PR
|
||||
# smoke and pr.yml's verify job.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
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"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm run build:electron-vite:parallel
|
||||
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
|
||||
windows:
|
||||
@@ -228,14 +188,12 @@ jobs:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm run build:electron-vite:parallel
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
|
||||
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -5,13 +5,15 @@ name: Daily macOS Dev Build
|
||||
# hourly.
|
||||
#
|
||||
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
|
||||
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
|
||||
# 18:15 UTC is late morning Pacific year-round (10:15am PST / 11:15am PDT). Minute
|
||||
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
|
||||
#
|
||||
# Deliberately narrow scope:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
|
||||
# and release-cut remain the gates that matter.
|
||||
# - No tests or lint on the build job. After a live publish we fire-and-forget
|
||||
# the full E2E workflow at the cut SHA (same detached dispatch as
|
||||
# release-cut). A red suite must not fail or delay the daily.
|
||||
# - PR CI and release-cut remain the gates that matter.
|
||||
# - Signed AND notarized, exactly like a release. The notary round trip is the
|
||||
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
|
||||
# + team rather than on its cdhash, so those grants survive an update. Without
|
||||
@@ -45,8 +47,8 @@ name: Daily macOS Dev Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
|
||||
- cron: '15 14 * * *'
|
||||
# Once a day, late morning Pacific. Single cron — no DST twin, no clock gate.
|
||||
- cron: '15 18 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
@@ -70,6 +72,11 @@ env:
|
||||
jobs:
|
||||
build-daily-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.daily.outputs.version }}
|
||||
head_sha: ${{ steps.freshness.outputs.head_sha }}
|
||||
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -138,9 +145,9 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
@@ -269,13 +276,19 @@ jobs:
|
||||
# exactly. One source, written once, read twice.
|
||||
notes_file="$RUNNER_TEMP/daily-release-notes.md"
|
||||
cat >"$notes_file" <<EOF
|
||||
Automated daily macOS dev build from commit \`$short_sha\`.
|
||||
Automated daily macOS and Windows dev build from commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
|
||||
|
||||
**Unvetted.** No tests ran. Signed and notarized like a release, so it
|
||||
installs through Orca's in-app updater and opens from a manual download
|
||||
without a Gatekeeper prompt — but nothing here has been reviewed.
|
||||
**Unvetted.** No tests ran. The macOS build is signed and notarized like a
|
||||
release, so it installs through Orca's in-app updater and opens from a manual
|
||||
download without a Gatekeeper prompt — but nothing here has been reviewed.
|
||||
|
||||
**Windows builds are unsigned.** They install and update normally once you
|
||||
are on one, but a signed Stable or RC build cannot install one through the
|
||||
in-app updater — download \`orca-windows-setup.exe\` below and run it once
|
||||
(SmartScreen will warn about an unknown publisher). Every later switch,
|
||||
including back to Stable, works in-app from there.
|
||||
EOF
|
||||
# Why create it up front: electron-builder then uploads into a known tag
|
||||
# rather than inferring one from package.json.
|
||||
@@ -469,3 +482,58 @@ jobs:
|
||||
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
|
||||
# Why detached dispatch, not a reusable workflow in this graph: the full suite
|
||||
# is currently red on main. Inlining it here would fail the daily after the
|
||||
# signed build already published. Same pattern as release-cut's post-release-e2e.
|
||||
#
|
||||
# Why --ref main plus the SHA input: daily tags live on orca-daily, not this
|
||||
# repo, so there is no cut tag for `gh workflow run --ref`. The workflow file
|
||||
# comes from main; checkout uses the exact commit this daily built.
|
||||
post-daily-e2e:
|
||||
needs: build-daily-mac
|
||||
if: ${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch cut-scoped E2E
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SHA: ${{ needs.build-daily-mac.outputs.head_sha }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if gh workflow run e2e.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
--raw-field "ref=$SHA"; then
|
||||
echo "Dispatched post-daily E2E for $SHA."
|
||||
exit 0
|
||||
fi
|
||||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||||
done
|
||||
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
|
||||
|
||||
# Why this runs after the mac leg rather than beside it: the tag and version
|
||||
# are computed inside that job, so until it has run nothing else can name the
|
||||
# release to upload into. The cost is small enough not to matter — the Windows
|
||||
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
|
||||
# ~9.5 min mac run, which keeps a daily run far inside its interval.
|
||||
#
|
||||
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
|
||||
# file itself came from, which for an ordinary dispatch is main. Someone who
|
||||
# deliberately points the Actions "Use workflow from" picker at a branch
|
||||
# already gets that branch's copy of this entire file, so this follows the same
|
||||
# rule instead of inventing a second one.
|
||||
build-daily-win:
|
||||
needs: build-daily-mac
|
||||
# Only once the mac release is actually live: there is no release to upload
|
||||
# into otherwise, and the Windows workflow refuses to create one.
|
||||
if: needs.build-daily-mac.outputs.published == 'true'
|
||||
uses: ./.github/workflows/dev-channel-win-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
channel: daily
|
||||
tag: ${{ needs.build-daily-mac.outputs.tag }}
|
||||
ref: ${{ needs.build-daily-mac.outputs.head_sha }}
|
||||
version: ${{ needs.build-daily-mac.outputs.version }}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
name: Dev Channel Windows Build
|
||||
|
||||
# Why its own file rather than steps inlined into hourly/daily/adhoc: one copy of
|
||||
# the Windows leg instead of three, and it stays dispatchable on its own so a
|
||||
# Windows artifact can be rebuilt for an existing tag without paying for the mac
|
||||
# leg's packaging and notarization again.
|
||||
#
|
||||
# Each mac workflow calls this as a `needs:`-gated job once its release is live,
|
||||
# passing the tag it created. Both legs land in that one release, so a tag
|
||||
# carries every platform it managed to build. Measured cost of the Windows leg is
|
||||
# ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a mac run of ~9.5
|
||||
# min, so running it after the mac job keeps an hourly well inside its cron.
|
||||
#
|
||||
# Why unsigned: Windows release installers are signed by SignPath *after*
|
||||
# packaging, and release-cut budgets 1h + 4h for those approval waits. That does
|
||||
# not fit an hourly cadence and it does not fit "dispatch an adhoc build and go
|
||||
# get coffee". So dev-channel Windows builds ship unsigned, which has one real
|
||||
# consequence, handled in `src/shared/release-channel.ts`:
|
||||
#
|
||||
# electron-updater Authenticode-verifies every installer it downloads against
|
||||
# the publisherName baked into the *installed* app's app-update.yml. Stable and
|
||||
# RC carry 'SignPath Foundation', so they reject an unsigned dev installer and
|
||||
# no future build can fix the copies already installed. Dev builds omit the
|
||||
# name (config/electron-builder.config.cjs), so verification is skipped there.
|
||||
#
|
||||
# Net effect: the way *into* a dev channel on Windows is a one-time manual
|
||||
# installer run. Every way out — to another dev build, or back to Stable — works
|
||||
# through the in-app updater. The picker offers a download for exactly that jump.
|
||||
#
|
||||
# Called as a job by each mac workflow once its release is live, and separately
|
||||
# dispatchable by hand to rebuild a Windows artifact for an existing tag without
|
||||
# re-running the mac leg's twenty minutes of packaging and notarization:
|
||||
#
|
||||
# gh workflow run dev-channel-win-build.yml --ref main \
|
||||
# -f channel=adhoc -f tag=v1.4.178-adhoc.20260819010203 \
|
||||
# -f ref=<sha> -f version=1.4.178-adhoc.20260819010203
|
||||
|
||||
on:
|
||||
# Why the inputs are duplicated: workflow_call does not accept `type: choice`,
|
||||
# and workflow_dispatch wants it so the Actions UI offers a menu instead of a
|
||||
# free-text box. The channel allowlist below is what actually enforces the set,
|
||||
# since a workflow_call caller can pass any string.
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: Dev channel whose release this build uploads into
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: Existing release tag in the channel repo
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA to build — must be the exact commit the mac leg built
|
||||
required: true
|
||||
type: string
|
||||
version:
|
||||
description: Version to package, without the leading v
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Dev channel whose release this build uploads into
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- hourly
|
||||
- daily
|
||||
- adhoc
|
||||
tag:
|
||||
description: Existing release tag in the channel repo (e.g. v1.4.178-adhoc.20260819010203)
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA to build — must be the exact commit the mac leg built
|
||||
required: true
|
||||
type: string
|
||||
version:
|
||||
description: Version to package, without the leading v
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Keyed on the tag: re-dispatching the same tag must not race two uploads into
|
||||
# one release, but two different channels (or two adhoc branches) are the
|
||||
# ordinary case and must not wait on each other.
|
||||
group: dev-channel-win-build-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-win:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
# Why the same environment as the mac workflows: this needs the App token
|
||||
# that can write to the dev-channel repos, and it should be reachable from
|
||||
# exactly the same place those secrets already live.
|
||||
environment: adhoc-mac-build
|
||||
# Why windows-2022 and not windows-latest: windows-latest moved to the
|
||||
# Windows 2025 / VS 2026 image before node-gyp could detect VS 18, breaking
|
||||
# native dependency install. release-cut pins the same image.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
steps:
|
||||
# Why vet before checkout: everything after this runs the checked-out code
|
||||
# with a token that can write to a release repo. The mac leg already vetted
|
||||
# the ref it resolved, but this workflow is dispatchable on its own, so it
|
||||
# re-derives the same guarantee rather than trusting its caller.
|
||||
- name: Vet the requested inputs
|
||||
id: vetted
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_SHA: ${{ inputs.ref }}
|
||||
REPO_URL: https://github.com/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# workflow_call takes channel as a free-text string, so the set is
|
||||
# enforced here rather than by the input type.
|
||||
case "$CHANNEL" in
|
||||
hourly|daily|adhoc) ;;
|
||||
*)
|
||||
echo "::error::Unknown dev channel '$CHANNEL'; expected hourly, daily, or adhoc."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::ref must be a full 40-character commit SHA, got '$REQUESTED_SHA'. The dispatching workflow passes the commit it resolved."
|
||||
exit 1
|
||||
fi
|
||||
# The tag must name the version being packaged, or the artifacts would
|
||||
# land in a release describing a different build.
|
||||
if [[ "$TAG" != "v$VERSION" ]]; then
|
||||
echo "::error::tag '$TAG' does not match version '$VERSION'."
|
||||
exit 1
|
||||
fi
|
||||
# And the version must carry the channel's own prerelease identifier,
|
||||
# so an hourly artifact can never be uploaded into an adhoc release.
|
||||
if [[ "$VERSION" != *"-$CHANNEL."* ]]; then
|
||||
echo "::error::version '$VERSION' is not a $CHANNEL version."
|
||||
exit 1
|
||||
fi
|
||||
# Reachability is the trust test: GitHub serves PR-only commits by SHA,
|
||||
# so resolving the object is not proof a branch or tag of this repo
|
||||
# reaches it. Bare + tree:0 keeps this to the commit graph.
|
||||
scratch="$RUNNER_TEMP/vet-requested-ref"
|
||||
git init -q --bare "$scratch"
|
||||
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
if ! git -C "$scratch" rev-parse --verify --quiet "$REQUESTED_SHA^{commit}" >/dev/null; then
|
||||
echo "::error::Commit $REQUESTED_SHA is not in stablyai/orca."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$REQUESTED_SHA" refs/heads refs/tags | head -1)" ]]; then
|
||||
echo "::error::Commit $REQUESTED_SHA is not reachable from any branch or tag of stablyai/orca; refusing to build it."
|
||||
exit 1
|
||||
fi
|
||||
echo "Vetted $CHANNEL $TAG at $REQUESTED_SHA"
|
||||
|
||||
- name: Checkout the built commit
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
# This job only reads stablyai/orca and never pushes; every write goes
|
||||
# to the dev-channel repo through a minted App token passed by env
|
||||
# (zizmor: artipacked).
|
||||
persist-credentials: false
|
||||
|
||||
# Why a guard and not just a build: the workflow file comes from the
|
||||
# dispatch ref, but the packaging config comes from the *built* commit. A
|
||||
# branch cut before Windows dev builds landed has a config that ignores
|
||||
# ORCA_WIN_*, which would resolve publish.repo to the main repo. Say that
|
||||
# in one sentence here rather than failing deep inside electron-builder.
|
||||
- name: Resolve the dev-channel packaging identity
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
upper="$(printf '%s' "$CHANNEL" | tr '[:lower:]' '[:upper:]')"
|
||||
echo "ORCA_WIN_${upper}=1" >>"$GITHUB_ENV"
|
||||
echo "ORCA_${upper}_BUILD_VERSION=${VERSION}" >>"$GITHUB_ENV"
|
||||
if [[ ! -f config/scripts/verify-dev-channel-packaging.mjs ]]; then
|
||||
echo "::error::$TAG was built from a commit with no config/scripts/verify-dev-channel-packaging.mjs; that commit predates Windows dev builds, so it cannot produce one."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Caches the Electron binary and electron-builder's tool downloads (nsis,
|
||||
# winCodeSign). Same key shape as release-cut's Windows leg.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-win-
|
||||
|
||||
# Why retried: pnpm install triggers electron's postinstall, which pulls the
|
||||
# Electron binary from GitHub release assets, and that CDN returns transient
|
||||
# 504s often enough to lose a build to it.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why the packaging check runs before the 20-minute build: it only needs
|
||||
# node_modules, and a stale config should cost seconds rather than a build.
|
||||
- name: Verify dev-channel packaging identity
|
||||
shell: bash
|
||||
run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32
|
||||
|
||||
# Why here and not in build:relay: only a Windows runner can compile it, and
|
||||
# arm64 cross-compiles from this same x64 agent. Runs before the 20-minute
|
||||
# build so a runner image missing the MSVC ARM64 cross toolset fails in
|
||||
# seconds with MSB8020 naming the component, rather than deep into packaging.
|
||||
- name: Build Windows process-table addon for the relay
|
||||
shell: bash
|
||||
run: |
|
||||
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64
|
||||
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
|
||||
|
||||
- name: Build app
|
||||
shell: bash
|
||||
run: pnpm build:release
|
||||
env:
|
||||
# Fail the build rather than ship a relay that silently falls back to
|
||||
# the PowerShell scan on every Windows SSH host.
|
||||
ORCA_REQUIRE_RELAY_NATIVE_ADDONS: 'x64,arm64'
|
||||
# Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only
|
||||
# 'stable' or 'rc', so leaving it unset keeps dev builds silent — which
|
||||
# is correct for unvetted artifacts. Same as the mac dev channels.
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
|
||||
# Why the token is minted here and not at the top: installation tokens live
|
||||
# one hour and nothing before this point writes anything.
|
||||
- name: Mint dev channel repo token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-${{ inputs.channel }}
|
||||
|
||||
# Why: electron-builder's publisher creates a release when it cannot find
|
||||
# the tag ("publish: always"). If the mac leg failed and discarded its draft
|
||||
# while this was building, that would mint a fresh, untitled, Windows-only
|
||||
# release. Check first and fail instead.
|
||||
- name: Confirm the target release still exists
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json tagName >/dev/null 2>&1; then
|
||||
echo "::error::Release $TAG no longer exists in stablyai/orca-$CHANNEL; the mac leg most likely failed and discarded it. Not creating a Windows-only release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish Windows artifacts
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
# 30 is the pack + upload budget; there is no notary queue on this leg.
|
||||
timeout_minutes: 30
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
ORCA_BUILD_COMMIT: ${{ inputs.ref }}
|
||||
# Why: electron-publish refuses to upload into a release published more
|
||||
# than two hours ago (gitHubPublisher.getOrCreateRelease). The mac leg
|
||||
# publishes the draft live as soon as *it* finishes, so a slow notary
|
||||
# queue plus a slow Windows build can cross that line and silently drop
|
||||
# every Windows asset. This is the documented escape hatch.
|
||||
EP_GH_IGNORE_TIME: 'true'
|
||||
|
||||
# Why: the updater resolves a tag, then fetches latest.yml from it. A
|
||||
# release carrying the installer but not the manifest is one the picker
|
||||
# offers and the update 404s on, so assert both.
|
||||
- name: Verify Windows update manifest published
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json assets --jq '.assets[].name')"
|
||||
echo "Assets on $TAG:"
|
||||
echo "$assets"
|
||||
for required in latest.yml orca-windows-setup.exe; do
|
||||
if ! grep -qx "$required" <<<"$assets"; then
|
||||
echo "::error::$TAG is missing $required; Windows could not install this build."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Windows artifacts verified on $TAG."
|
||||
@@ -0,0 +1,275 @@
|
||||
name: Docs site
|
||||
|
||||
on:
|
||||
# Stable desktop releases are the production publication boundary. The
|
||||
# release gate below excludes mobile and prerelease tags from this trigger.
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/site/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Stable desktop release tag to redeploy (vX.Y.Z)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Serialize production runs so a delayed older release cannot overwrite a
|
||||
# newer deployment. PR checks may still run concurrently by pull request.
|
||||
group: ${{ github.event_name == 'pull_request' && format('docs-pr-{0}', github.event.pull_request.number) || 'docs-production' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
# The Vercel project is rooted at `.`; this package directory is the
|
||||
# complete upload and build context.
|
||||
working-directory: docs/site
|
||||
|
||||
jobs:
|
||||
# This job deliberately has no deployment credentials and runs for every PR,
|
||||
# including forks.
|
||||
check:
|
||||
name: Build and test
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
# The repository root pins pnpm 12 while this isolated app pins 10;
|
||||
# setup-node's cache probe runs at the root and would auto-switch.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install site dependencies
|
||||
run: pnpm --ignore-workspace install --frozen-lockfile
|
||||
|
||||
- name: Run package tests
|
||||
run: pnpm --ignore-workspace test
|
||||
|
||||
- name: Lint site
|
||||
run: pnpm --ignore-workspace lint
|
||||
|
||||
- name: Typecheck site
|
||||
run: pnpm --ignore-workspace exec tsc --noEmit --incremental false
|
||||
|
||||
- name: Build site
|
||||
run: pnpm --ignore-workspace build
|
||||
|
||||
release_gate:
|
||||
name: Authorize release
|
||||
if: >-
|
||||
github.repository == 'stablyai/orca' &&
|
||||
(github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
deploy: ${{ steps.validate.outputs.deploy }}
|
||||
commit_sha: ${{ steps.validate.outputs.commit_sha }}
|
||||
steps:
|
||||
- name: Validate stable desktop tag
|
||||
id: validate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
RELEASE_DRAFT: ${{ github.event.release.draft }}
|
||||
RELEASE_AUTHOR: ${{ github.event.release.author.login }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
working-directory: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="$INPUT_TAG"
|
||||
[[ "$EVENT_NAME" == "release" ]] && tag="$RELEASE_TAG"
|
||||
|
||||
# Match the stable desktop format used by release-policy.yml. This
|
||||
# intentionally rejects mobile-* and all -rc.* tags.
|
||||
stable_tag=false
|
||||
[[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] && stable_tag=true
|
||||
|
||||
if [[ "$stable_tag" != "true" ]]; then
|
||||
echo "Release $tag is not a stable desktop release; skipping docs deployment."
|
||||
echo "deploy=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||
authorized_ref=true
|
||||
authorized_author=false
|
||||
[[ "$RELEASE_AUTHOR" == "github-actions[bot]" ]] && authorized_author=true
|
||||
release_state_ok=false
|
||||
[[ "$RELEASE_PRERELEASE" == "false" && "$RELEASE_DRAFT" == "false" ]] && release_state_ok=true
|
||||
else
|
||||
authorized_ref=false
|
||||
[[ "$WORKFLOW_REF" == "refs/heads/$DEFAULT_BRANCH" ]] && authorized_ref=true
|
||||
|
||||
release_json=''
|
||||
for attempt in 1 2 3; do
|
||||
if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" 2>/dev/null)"; then
|
||||
break
|
||||
fi
|
||||
if [[ "$attempt" -eq 3 ]]; then
|
||||
echo "::error::Release metadata for $tag was not available after 3 attempts."
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * 5))"
|
||||
done
|
||||
authorized_author=false
|
||||
[[ "$(jq -r '.author.login' <<<"$release_json")" == "github-actions[bot]" ]] && authorized_author=true
|
||||
release_state_ok=false
|
||||
if [[ "$(jq -r '.tag_name' <<<"$release_json")" == "$tag" &&
|
||||
"$(jq -r '.prerelease' <<<"$release_json")" == "false" &&
|
||||
"$(jq -r '.draft' <<<"$release_json")" == "false" ]]; then
|
||||
release_state_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve the tag to an immutable commit before handing it to the
|
||||
# deployment job. This prevents a force-moved tag from changing the
|
||||
# source between authorization and checkout.
|
||||
tag_ref_json=''
|
||||
for attempt in 1 2 3; do
|
||||
if tag_ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" 2>/dev/null)"; then
|
||||
break
|
||||
fi
|
||||
if [[ "$attempt" -eq 3 ]]; then
|
||||
echo "::error::Git ref for $tag was not available after 3 attempts."
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * 5))"
|
||||
done
|
||||
tag_object_sha="$(jq -er '.object.sha' <<<"$tag_ref_json")"
|
||||
tag_object_type="$(jq -er '.object.type' <<<"$tag_ref_json")"
|
||||
commit_sha="$tag_object_sha"
|
||||
if [[ "$tag_object_type" == 'tag' ]]; then
|
||||
commit_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object_sha" | jq -er '.object.sha')"
|
||||
fi
|
||||
if [[ ! "$commit_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Could not resolve $tag to a commit SHA."
|
||||
exit 1
|
||||
fi
|
||||
echo "commit_sha=$commit_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Stable tags cut before the docs package was merged are valid Orca
|
||||
# releases, but cannot produce a docs deployment. Skip them before
|
||||
# requesting the protected production environment.
|
||||
docs_source_available=false
|
||||
for attempt in 1 2 3; do
|
||||
if gh api "repos/$GITHUB_REPOSITORY/contents/docs/site/package.json?ref=$commit_sha" >/dev/null 2>&1; then
|
||||
docs_source_available=true
|
||||
break
|
||||
fi
|
||||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||||
done
|
||||
if [[ "$docs_source_available" != "true" ]]; then
|
||||
echo "Release $tag does not contain docs/site; skipping docs deployment."
|
||||
echo "deploy=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
deploy=false
|
||||
if [[ "$authorized_ref" == "true" && "$authorized_author" == "true" && "$release_state_ok" == "true" ]]; then
|
||||
deploy=true
|
||||
else
|
||||
echo "Release $tag is not an authorized stable desktop release; skipping docs deployment."
|
||||
fi
|
||||
echo "deploy=$deploy" >> "$GITHUB_OUTPUT"
|
||||
|
||||
production:
|
||||
name: Production
|
||||
if: >-
|
||||
(github.event_name == 'release' || github.event_name == 'workflow_dispatch') &&
|
||||
needs.release_gate.result == 'success' &&
|
||||
needs.release_gate.outputs.deploy == 'true'
|
||||
needs: release_gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment:
|
||||
name: docs-production
|
||||
url: https://www.onorca.dev/docs
|
||||
env:
|
||||
VERCEL_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- name: Checkout released tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.release_gate.outputs.commit_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install site dependencies
|
||||
run: pnpm --ignore-workspace install --frozen-lockfile
|
||||
|
||||
- name: Verify Vercel credentials
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "${VERCEL_TOKEN:-}" || { echo '::error::VERCEL_TOKEN is not configured for docs-production.'; exit 1; }
|
||||
test -n "${VERCEL_ORG_ID:-}" || { echo '::error::VERCEL_ORG_ID is not configured for docs-production.'; exit 1; }
|
||||
test -n "${VERCEL_PROJECT_ID:-}" || { echo '::error::VERCEL_PROJECT_ID is not configured for docs-production.'; exit 1; }
|
||||
|
||||
- name: Pull Vercel production settings
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: pnpm --ignore-workspace exec vercel pull --yes --non-interactive --environment=production
|
||||
|
||||
- name: Run package tests
|
||||
run: pnpm --ignore-workspace test
|
||||
|
||||
- name: Lint site
|
||||
run: pnpm --ignore-workspace lint
|
||||
|
||||
- name: Typecheck site
|
||||
run: pnpm --ignore-workspace exec tsc --noEmit --incremental false
|
||||
|
||||
- name: Build production site
|
||||
run: pnpm --ignore-workspace exec vercel build --prod --non-interactive
|
||||
|
||||
- name: Deploy production site
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: pnpm --ignore-workspace exec vercel deploy --prebuilt --prod --yes --non-interactive
|
||||
+173
-139
@@ -17,6 +17,10 @@ on:
|
||||
description: JSON array of changed specs; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
ssh_source_changed:
|
||||
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -40,81 +44,43 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
|
||||
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
|
||||
# errors.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
# Why: the build's plain-Node daemon smoke load resolves node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: node
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
- name: Build Electron app for E2E
|
||||
- name: Build E2E outputs
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
npx electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
status=0
|
||||
pnpm run build:relay &
|
||||
relay_pid=$!
|
||||
npx electron-vite build --mode e2e || status=1
|
||||
pnpm run build:web-from-renderer || status=1
|
||||
wait "$relay_pid" || status=1
|
||||
exit "$status"
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
# Why: build-relay.mjs writes each relay's marker as `out/relay/<platform>/.version`,
|
||||
# and upload-artifact drops dotfiles by default — consumers then fail SSH specs with
|
||||
# "local relay build is missing its version marker".
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
e2e:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
# Build Electron-native dependencies once per workflow. Consumer shards restore
|
||||
# this immutable cache instead of compiling the same ABI concurrently.
|
||||
prepare-native-cache:
|
||||
name: prepare Electron native cache
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/10'
|
||||
shard_name: 1-of-10
|
||||
- shard: '2/10'
|
||||
shard_name: 2-of-10
|
||||
- shard: '3/10'
|
||||
shard_name: 3-of-10
|
||||
- shard: '4/10'
|
||||
shard_name: 4-of-10
|
||||
- shard: '5/10'
|
||||
shard_name: 5-of-10
|
||||
- shard: '6/10'
|
||||
shard_name: 6-of-10
|
||||
- shard: '7/10'
|
||||
shard_name: 7-of-10
|
||||
- shard: '8/10'
|
||||
shard_name: 8-of-10
|
||||
- shard: '9/10'
|
||||
shard_name: 9-of-10
|
||||
- shard: '10/10'
|
||||
shard_name: 10-of-10
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -122,42 +88,69 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: pnpm install rebuilds native modules, and those postinstall
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
# virtual framebuffer so Chromium can initialize without a real display.
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
native-runtime: electron
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
e2e:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: [build, prepare-native-cache]
|
||||
if: inputs.test_files == ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Fourteen scheduled runs averaged 24.6 minutes per shard; shards 4
|
||||
# and 9 repeatedly hit the 30-minute cap. A 12-way trial still left
|
||||
# one 30-minute shard, so 14 gives the suite enough failure headroom.
|
||||
- shard: '1/14'
|
||||
shard_name: 1-of-14
|
||||
- shard: '2/14'
|
||||
shard_name: 2-of-14
|
||||
- shard: '3/14'
|
||||
shard_name: 3-of-14
|
||||
- shard: '4/14'
|
||||
shard_name: 4-of-14
|
||||
- shard: '5/14'
|
||||
shard_name: 5-of-14
|
||||
- shard: '6/14'
|
||||
shard_name: 6-of-14
|
||||
- shard: '7/14'
|
||||
shard_name: 7-of-14
|
||||
- shard: '8/14'
|
||||
shard_name: 8-of-14
|
||||
- shard: '9/14'
|
||||
shard_name: 9-of-14
|
||||
- shard: '10/14'
|
||||
shard_name: 10-of-14
|
||||
- shard: '11/14'
|
||||
shard_name: 11-of-14
|
||||
- shard: '12/14'
|
||||
shard_name: 12-of-14
|
||||
- shard: '13/14'
|
||||
shard_name: 13-of-14
|
||||
- shard: '14/14'
|
||||
shard_name: 14-of-14
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
run_install: false
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
# release.yml so the invariant "this workaround is Linux-only" is
|
||||
# consistent across all three workflows, even though this job
|
||||
# currently pins runs-on: ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
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"
|
||||
# Native cache misses need the compiler, Electron needs Xvfb, and paired
|
||||
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -174,7 +167,7 @@ jobs:
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
|
||||
# Why: Playwright retains traces/screenshots only on failure. Uploading
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
@@ -190,10 +183,12 @@ jobs:
|
||||
|
||||
changed-e2e:
|
||||
name: changed e2e specs
|
||||
needs: build
|
||||
needs: [build, prepare-native-cache]
|
||||
if: inputs.test_files != ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
|
||||
# pay a container image build plus ~22 serial SSH tests on top of the changed specs.
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -202,25 +197,15 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
# Why ripgrep: Quick Open's bounded host-side search requires rg instead of an
|
||||
# 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.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -232,9 +217,32 @@ jobs:
|
||||
env:
|
||||
TEST_FILES_JSON: ${{ inputs.test_files }}
|
||||
run: |
|
||||
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
# Why the native IME spec is dropped: it test.skip()s itself without
|
||||
# ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus
|
||||
# session. Running it here reported a green skip as coverage.
|
||||
mapfile -t TEST_FILES < <(jq -r '.[] | select(
|
||||
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
|
||||
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
|
||||
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts"
|
||||
)' <<<"$TEST_FILES_JSON")
|
||||
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
|
||||
echo "Changed specs are all owned by dedicated lanes."
|
||||
exit 0
|
||||
fi
|
||||
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay")
|
||||
# Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so
|
||||
# naming only one trigger silently skipped every other Docker-SSH spec in this lane.
|
||||
# The first clause stays because that spec needs Docker without referencing the variable.
|
||||
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \
|
||||
|| grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then
|
||||
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
|
||||
fi
|
||||
E2E_PROJECT_ARGS=()
|
||||
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
|
||||
E2E_PROJECT_ARGS+=(--project=electron-headful)
|
||||
fi
|
||||
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
@@ -247,12 +255,21 @@ jobs:
|
||||
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
needs: [build, prepare-native-cache]
|
||||
# effect of one route listing a startup-readiness spec — pruning that spec would have
|
||||
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
|
||||
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
|
||||
# lane, so editing one must still run it here.
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
inputs.ssh_source_changed == 'true' ||
|
||||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
|
||||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
timeout-minutes: 35
|
||||
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
|
||||
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
|
||||
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -261,28 +278,11 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
|
||||
# stays consistent across workflows even while this job is ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -295,18 +295,52 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why always(): these specs gate the C1 terminal parking/retention budget
|
||||
# over a real SSH host and run in no other lane, so a watcher-isolation
|
||||
# failure above must not silently skip them.
|
||||
- name: Run Docker SSH terminal parking + retention E2E
|
||||
# Why: Playwright empties test-results/ when it starts, so each step here used to
|
||||
# destroy the previous step's traces. Only the last lane's failure was ever
|
||||
# diagnosable from the artifact; set each lane aside before the next one runs.
|
||||
- name: Keep watcher-isolation traces
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d test-results ]; then
|
||||
mkdir -p e2e-traces
|
||||
mv test-results "e2e-traces/watcher-isolation"
|
||||
fi
|
||||
|
||||
# Why always(): this lane gates SSH parking/retention plus startup-exec
|
||||
# readiness across live SSH, headed paired, and headless serve topologies.
|
||||
- name: Run Docker SSH terminal parking + startup readiness E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
- name: Keep terminal-parking traces
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d test-results ]; then
|
||||
mkdir -p e2e-traces
|
||||
mv test-results "e2e-traces/terminal-parking"
|
||||
fi
|
||||
|
||||
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
|
||||
# spec below skipped itself while the shard still reported green. Running them on this one
|
||||
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
|
||||
# legible as an SSH-named failure.
|
||||
- name: Run remaining Docker SSH E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
|
||||
- name: Keep remaining-ssh-docker traces
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d test-results ]; then
|
||||
mkdir -p e2e-traces
|
||||
mv test-results "e2e-traces/remaining-ssh-docker"
|
||||
fi
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-ssh-docker-watcher-isolation
|
||||
path: test-results/
|
||||
path: e2e-traces/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -23,10 +23,9 @@ jobs:
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows golden E2E is temporarily disabled on CI while its
|
||||
# flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
# Match the release gate: Windows runs a focused golden subset.
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -44,9 +43,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# Why: Linux golden E2E uses the same native install path as PR/release CI,
|
||||
# which needs pnpm to bypass its non-executable bundled gyp_main.py.
|
||||
@@ -62,17 +61,49 @@ jobs:
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
# Why: this workflow can check out an older ref than the YAML that
|
||||
# invoked it. Skip goldens the checked-out tree does not define.
|
||||
- name: Run golden E2E tests on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
fi
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
fi
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
|
||||
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
}
|
||||
pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
|
||||
@@ -62,6 +62,11 @@ env:
|
||||
jobs:
|
||||
build-hourly-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.hourly.outputs.version }}
|
||||
head_sha: ${{ steps.freshness.outputs.head_sha }}
|
||||
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -130,9 +135,9 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
@@ -270,13 +275,19 @@ jobs:
|
||||
--repo "$HOURLY_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
|
||||
--notes "Automated hourly macOS and Windows dev build from commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
|
||||
|
||||
**Unvetted.** No tests ran. Signed and notarized like a release, so it
|
||||
installs through Orca's in-app updater and opens from a manual download
|
||||
without a Gatekeeper prompt — but nothing here has been reviewed."
|
||||
**Unvetted.** No tests ran. The macOS build is signed and notarized like a
|
||||
release, so it installs through Orca's in-app updater and opens from a manual
|
||||
download without a Gatekeeper prompt — but nothing here has been reviewed.
|
||||
|
||||
**Windows builds are unsigned.** They install and update normally once you
|
||||
are on one, but a signed Stable or RC build cannot install one through the
|
||||
in-app updater — download \`orca-windows-setup.exe\` below and run it once
|
||||
(SmartScreen will warn about an unknown publisher). Every later switch,
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
@@ -430,3 +441,27 @@ jobs:
|
||||
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
|
||||
# Why this runs after the mac leg rather than beside it: the tag and version
|
||||
# are computed inside that job, so until it has run nothing else can name the
|
||||
# release to upload into. The cost is small enough not to matter — the Windows
|
||||
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
|
||||
# ~9.5 min mac run, which keeps a hourly run far inside its interval.
|
||||
#
|
||||
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
|
||||
# file itself came from, which for an ordinary dispatch is main. Someone who
|
||||
# deliberately points the Actions "Use workflow from" picker at a branch
|
||||
# already gets that branch's copy of this entire file, so this follows the same
|
||||
# rule instead of inventing a second one.
|
||||
build-hourly-win:
|
||||
needs: build-hourly-mac
|
||||
# Only once the mac release is actually live: there is no release to upload
|
||||
# into otherwise, and the Windows workflow refuses to create one.
|
||||
if: needs.build-hourly-mac.outputs.published == 'true'
|
||||
uses: ./.github/workflows/dev-channel-win-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
channel: hourly
|
||||
tag: ${{ needs.build-hourly-mac.outputs.tag }}
|
||||
ref: ${{ needs.build-hourly-mac.outputs.head_sha }}
|
||||
version: ${{ needs.build-hourly-mac.outputs.version }}
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# Why: mirrors pr.yml so native module rebuilds do not use pnpm's
|
||||
# non-executable bundled gyp_main.py on Linux runners.
|
||||
@@ -43,12 +43,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
|
||||
# runners while creating the root node_modules. Use the normal resolver
|
||||
# path, then verify package metadata stayed unchanged.
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
pnpm install --frozen-lockfile
|
||||
git diff --exit-code package.json pnpm-lock.yaml pnpm-workspace.yaml
|
||||
|
||||
- name: Start Weston
|
||||
run: |
|
||||
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -32,6 +32,14 @@ jobs:
|
||||
runs-on: macos-26
|
||||
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
|
||||
timeout-minutes: 90
|
||||
|
||||
env:
|
||||
# Why: this job and ios-distribute resolve gems ~25 minutes apart, so both
|
||||
# must install the committed Gemfile.lock exactly. Frozen turns a lockfile
|
||||
# drift into a setup failure instead of two different fastlane versions in
|
||||
# one release.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
|
||||
outputs:
|
||||
release_version: ${{ steps.release_metadata.outputs.version }}
|
||||
build_number: ${{ steps.release_metadata.outputs.build_number }}
|
||||
@@ -56,9 +64,9 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -149,6 +157,10 @@ jobs:
|
||||
# margin without allowing App Store Connect polling to hang for hours.
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
# Same fastlane as ios-build, or fail before touching App Store Connect.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
@@ -12,11 +12,21 @@ on:
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
# Why: this job holds the only checks that load the Fastfile, so edits to
|
||||
# it or to the release workflow it guards must re-run them.
|
||||
- '.github/workflows/mobile.yml'
|
||||
- '.github/workflows/mobile-ios-release.yml'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
|
||||
# from the Gemfile, which is how the release jobs could land on different
|
||||
# fastlane versions in the first place. Fail here instead.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
@@ -30,15 +40,20 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup Ruby
|
||||
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
|
||||
# proves the pinned fastlane the release workflow depends on still
|
||||
# resolves — before a release run finds out.
|
||||
- name: Setup Ruby and fastlane
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: mobile
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# Why: the mobile typecheck imports shared types from ../src/shared, and
|
||||
# some of those files import runtime deps (tweetnacl, ws) resolved from
|
||||
@@ -64,6 +79,20 @@ jobs:
|
||||
- name: Test iOS release version resolution
|
||||
run: ruby fastlane/ios_release_version_test.rb
|
||||
|
||||
- name: Test TestFlight lane arguments
|
||||
run: ruby fastlane/fastfile_testflight_arguments_test.rb
|
||||
|
||||
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
|
||||
# require, or an undefined constant only surfaces mid-release — the
|
||||
# ios-distribute job failed every run for six days that way. `lanes` just
|
||||
# loads and lists, so it needs no App Store Connect credentials and makes
|
||||
# no network calls to Apple.
|
||||
- name: Smoke-check the Fastfile
|
||||
env:
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_OPT_OUT_USAGE: '1'
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Node next compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
|
||||
- cron: '0 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: node-next-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# A cold cache would otherwise make all eight Node 26 shards compile the same native addons.
|
||||
test_native_cache:
|
||||
name: prepare test native cache node 26
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: '26'
|
||||
|
||||
test:
|
||||
needs: [test_native_cache]
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["26"]'
|
||||
@@ -0,0 +1,42 @@
|
||||
name: PR test LoC
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: pr-test-loc-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
loc:
|
||||
name: test vs non-test LoC
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
# Why no checkout: the Files API already has per-file additions/deletions.
|
||||
# Why the default branch and never pull/<n>/head: this job holds a write-scoped
|
||||
# GITHUB_TOKEN, so it may only execute reviewed code. A PR that edits these
|
||||
# scripts takes effect once merged.
|
||||
- name: Count test vs non-test LoC
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
TRUSTED_REF: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=${TRUSTED_REF}" \
|
||||
--jq .content | base64 --decode > "$RUNNER_TEMP/${script}"
|
||||
done
|
||||
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "$PR_NUMBER"
|
||||
+559
-70
@@ -16,18 +16,75 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Why: a README/docs-only PR used to start the full matrix (test shards,
|
||||
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
|
||||
# filters on `on.pull_request` would drop the `verify` check entirely; this
|
||||
# detector keeps verify as the required aggregate and skips the expensive jobs.
|
||||
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
|
||||
# inputs are unchanged; empty diffs fail closed and run everything.
|
||||
code_paths:
|
||||
name: detect code-relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }}
|
||||
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
|
||||
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
|
||||
test: ${{ steps.filter.outputs.test }}
|
||||
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
|
||||
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
|
||||
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.filter.outputs.package_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Classify changed paths
|
||||
id: filter
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why --no-renames: name-only rename detection can report only the destination.
|
||||
# A code file moved under docs/ must still expose its code-side deletion.
|
||||
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
|
||||
echo "Changed paths:"
|
||||
printf '%s\n' "$CHANGED"
|
||||
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
@@ -50,9 +107,42 @@ jobs:
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
- name: Check VM runtime rollback compatibility
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
|
||||
src/shared/ephemeral-vm-runtime-store.ts \
|
||||
src/shared/ephemeral-vm-runtime-feature-store.ts \
|
||||
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
|
||||
src/shared/ephemeral-vm-runtimes.ts \
|
||||
src/shared/ephemeral-vm-recipes.ts \
|
||||
src/shared/orca-yaml-hook-types.ts \
|
||||
src/main/ephemeral-vm-runtime-service.ts \
|
||||
src/main/ephemeral-vm-runtime-provisioning-persistence.ts \
|
||||
src/main/ephemeral-vm-failed-start-cleanup.ts; then
|
||||
echo "VM runtime persistence is unchanged."
|
||||
exit 0
|
||||
fi
|
||||
node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs \
|
||||
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
|
||||
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Enforce ts-nocheck ratchet
|
||||
run: pnpm run check:ts-nocheck-ratchet
|
||||
|
||||
- name: Enforce runtime Electron-import ratchet
|
||||
run: pnpm run check:runtime-electron-ratchet
|
||||
|
||||
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
|
||||
# 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
|
||||
run: pnpm run smoke:orcad-terminal
|
||||
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
@@ -74,14 +164,14 @@ jobs:
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See docs/preload-typecheck-hole.md.
|
||||
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
|
||||
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
|
||||
echo "See docs/preload-typecheck-hole.md."
|
||||
echo "See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts."
|
||||
echo "Found:"
|
||||
echo "$matches"
|
||||
exit 1
|
||||
@@ -101,7 +191,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reject new root-level files and folders
|
||||
@@ -111,6 +205,8 @@ jobs:
|
||||
run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -121,10 +217,24 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- run: pnpm typecheck
|
||||
# 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
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: config/*.tsbuildinfo
|
||||
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.event.pull_request.base.sha }}
|
||||
restore-keys: |
|
||||
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
|
||||
|
||||
- run: pnpm run typecheck
|
||||
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.git_compatibility == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -135,19 +245,34 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
|
||||
# same binary on every PR for minutes of runner time. The key carries the version
|
||||
# because that is the only input; the sha256 assertion below still guards the
|
||||
# tarball on the miss path that actually builds.
|
||||
- name: Cache baseline Git build
|
||||
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
|
||||
|
||||
- name: Verify Git binary compatibility matrix
|
||||
run: |
|
||||
pids=()
|
||||
(
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$RUNNER_TEMP/git-2.25.5"
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
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
|
||||
source="$HOME/.cache/orca-git-compat/git-2.25.5"
|
||||
if [ ! -x "$source/git" ]; then
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
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
|
||||
# Why: the linked binaries are what the next run needs; the objects that
|
||||
# produced them are most of the tree and would bloat the cache entry.
|
||||
find "$source" -name '*.o' -delete
|
||||
fi
|
||||
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
@@ -173,9 +298,96 @@ jobs:
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
# Why this job: Orca's session index-heal depends on a Codex behavior — a
|
||||
# `thread/read` of an unindexed rollout performs a read-repair that inserts the
|
||||
# `threads` row. Every unit test drives a stub app-server and asserts only that the
|
||||
# call did not error, so if Codex dropped the repair they would all stay green while
|
||||
# the subsystem went inert. This runs the pinned real binary and fails when the
|
||||
# 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'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CODEX_CLI_VERSION: '0.150.1'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Install pinned Codex CLI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli" \
|
||||
"@openai/codex@$CODEX_CLI_VERSION"
|
||||
|
||||
- name: Verify Codex index-heal contract
|
||||
env:
|
||||
# Why REQUIRED: without a binary the suite skips, and a job that skips
|
||||
# reports success. This turns a failed or missing install into a red test
|
||||
# instead of a green no-op.
|
||||
ORCA_CODEX_CONTRACT_REQUIRED: '1'
|
||||
ORCA_CODEX_CONTRACT_VERSION: ${{ env.CODEX_CLI_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ORCA_CODEX_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli/node_modules/.bin/codex" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/codex/codex-index-heal-binary-contract.test.ts
|
||||
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the check rebuilds every package in the manifest from a pinned upstream
|
||||
# commit — @xterm/xterm and the two addons, each built twice (once unmodified to
|
||||
# prove the toolchain still reproduces the published bundles, once patched). Caching
|
||||
# the npm metadata and the shallow clone keeps the repeated cost to the builds
|
||||
# themselves; the key is the manifest, so a commit, package or toolchain bump
|
||||
# invalidates it.
|
||||
- name: Restore upstream xterm build inputs
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
${{ runner.temp }}/xterm-patch-build/upstream/.git
|
||||
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
|
||||
|
||||
- name: Verify xterm patches match the pinned upstream build
|
||||
env:
|
||||
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
|
||||
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.shell_contracts == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
|
||||
# is generous; it exists so a wedge fails the job instead of holding the whole run
|
||||
# open for the 6h GitHub default — which also blocks `gh run rerun --failed`.
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Why: the suites below gate their live fish tests on the binary, which is
|
||||
# right on a developer machine and wrong here — this job is a required check
|
||||
# and its fish lane is the only end-to-end guard for #9993, so a skip would
|
||||
# report green with nothing exercised. Turns those skips into failures.
|
||||
ORCA_REQUIRE_FISH: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -186,8 +398,72 @@ jobs:
|
||||
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
|
||||
# present, so without this the fish barrier is only covered by config-shape
|
||||
# assertions and never actually exercised.
|
||||
# Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and
|
||||
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
|
||||
# 3.7, so the PPA is what makes that lane real.
|
||||
- name: Install zsh and fish
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh fish
|
||||
run: |
|
||||
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
|
||||
# ships can lack a Release file for this suite, and a failed add-apt-repository
|
||||
# still leaves its list entry behind — either makes `apt-get update` exit
|
||||
# non-zero and would red this required check over something unrelated to the
|
||||
# PR. Every fish outcome is judged by the version gate below instead, so only
|
||||
# the zsh install (which has no such gate) stays fatal here.
|
||||
# Why retry only here: adding the PPA is the network-flaky step, and the
|
||||
# version gate below is fatal, so a transient Launchpad blip would
|
||||
# otherwise red a required check on PRs unrelated to shells.
|
||||
# Why -n: add-apt-repository refreshes every configured repo on its own. With
|
||||
# an update on each side of it this step refreshed them three times over, and
|
||||
# the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the
|
||||
# only thing the repo list gains here, and the single update below fetches it.
|
||||
# Why bound acquisition: measured on a *passing* run, this step spent 40s
|
||||
# fetching 11.4 MB of index and then 2m17s fetching 8.9 MB of packages at
|
||||
# 65 kB/s — it is dominated by download throughput, not by work. apt applies
|
||||
# no wall-clock bound to a stalled mirror, so a slow Launchpad or archive
|
||||
# host wedges the step for tens of minutes. This job is a required check, so
|
||||
# a wedge holds the entire run open and blocks `gh run rerun --failed`.
|
||||
# Bounded timeouts plus retries turn an unbounded hang into a fast, legible
|
||||
# failure. Set in apt.conf.d rather than on each command line so the two
|
||||
# invocations below stay exactly as pr-workflow-parallelism.test.mjs parses
|
||||
# them. Retries are 1, not 3: a first attempt at these bounds already multiplied
|
||||
# 30s x 3 retries across every index file into a ~15 minute stall on a dead
|
||||
# mirror, which is worse than failing once and moving on.
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-shell-contracts >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
for attempt in 1 2 3; do
|
||||
sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break
|
||||
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
|
||||
sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true
|
||||
sleep 5
|
||||
done
|
||||
# Why a wall-clock bound on each command: apt's Acquire timeouts are per-connection,
|
||||
# so a dead mirror costs timeout x retries x every index file. Measured: the archive
|
||||
# mirror stalled with zero bytes and the step burned 14m26s before the job bound
|
||||
# killed it. `timeout` is the only thing that bounds the command as a whole.
|
||||
# The update is already tolerant by design (see above), so bounding it just caps
|
||||
# what a dead mirror can cost before the install runs against whatever index exists.
|
||||
timeout 120 sudo apt-get update || true
|
||||
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
|
||||
# first install command in this step to prove the lane really installs them.
|
||||
timeout 300 sudo apt-get install -y zsh fish
|
||||
|
||||
# Separate from the install so the failure names the contract, not an apt error.
|
||||
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
|
||||
# instead of after a full dependency install.
|
||||
- name: Require fish 4+
|
||||
run: |
|
||||
version="$(fish --version 2>/dev/null || true)"
|
||||
major="${version##*version }"
|
||||
major="${major%%.*}"
|
||||
case "$major" in '' | *[!0-9]*) major=0 ;; esac
|
||||
echo "${version:-<fish not installed>}"
|
||||
if [ "$major" -lt 4 ]; then
|
||||
echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -195,23 +471,33 @@ jobs:
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
|
||||
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/codex-shell-launch-preflight.test.ts \
|
||||
src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/shell-startup-feature-channel.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 \
|
||||
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
# Cache-key input changes would otherwise make every shard compile the same
|
||||
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
|
||||
test_native_cache:
|
||||
name: prepare test native cache node 24
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.native_cache_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -222,25 +508,60 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
test:
|
||||
needs: [code_paths, test_native_cache]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
|
||||
- name: Test shard
|
||||
# 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'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Why no native-runtime: the provider drives the prebuilt agent-browser binary
|
||||
# shipped in node_modules and never touches node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an
|
||||
# AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable
|
||||
# sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an
|
||||
# unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long.
|
||||
- name: Resolve Chrome for the browser provider
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
|
||||
if [ -z "$chrome" ]; then
|
||||
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
|
||||
exit 1
|
||||
fi
|
||||
"$chrome" --version
|
||||
echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Test external Chromium browser provider
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
src/main/orcad/external-chromium-browser-process.integration.test.ts
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.cross-version-wire == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -250,7 +571,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
@@ -259,11 +584,19 @@ jobs:
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
- name: Old/new client and server compatibility journeys
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
tests/e2e/cross-version-wire/release-checkout.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -287,6 +620,8 @@ jobs:
|
||||
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -298,9 +633,7 @@ jobs:
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
path: ~/.cache/electron-builder
|
||||
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-linux-
|
||||
@@ -309,6 +642,19 @@ jobs:
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
# Why --no-file-parallelism: every file here launches a full Electron stack twice, and each
|
||||
# probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other
|
||||
# past those deadlines; serial, every probe owns the runner.
|
||||
- name: Test Linux Electron lifecycle boundary
|
||||
run: >-
|
||||
xvfb-run --auto-servernum pnpm exec vitest run --config config/vitest.config.ts
|
||||
--no-file-parallelism
|
||||
src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts
|
||||
src/main/browser/browser-route-tcp-egress.electron.test.ts
|
||||
src/main/browser/browser-route-webrtc-egress.electron.test.ts
|
||||
src/main/browser/browser-route-h3-egress.electron.test.ts
|
||||
src/main/browser/browser-route-dns-prefetch.electron.test.ts
|
||||
|
||||
- name: Build package inputs
|
||||
run: |
|
||||
status=0
|
||||
@@ -335,7 +681,10 @@ jobs:
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
|
||||
|
||||
- name: Verify headless serve signal shutdown
|
||||
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
@@ -345,6 +694,8 @@ jobs:
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -354,17 +705,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
@@ -375,11 +715,76 @@ jobs:
|
||||
restore-keys: |
|
||||
electron-builder-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Why persist-native-cache false: this job later rebuilds the same path for
|
||||
# Electron. A post-job save would store the Electron ABI under the Node key.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: deps
|
||||
with:
|
||||
native-runtime: node
|
||||
persist-native-cache: 'false'
|
||||
|
||||
- name: Save compiled Node native modules
|
||||
if: steps.deps.outputs.native-cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/rebuild-native-deps.test.mjs
|
||||
src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts
|
||||
src/main/browser/browser-route-tcp-egress.electron.test.ts
|
||||
src/main/browser/browser-route-webrtc-egress.electron.test.ts
|
||||
src/main/browser/browser-route-h3-egress.electron.test.ts
|
||||
src/main/browser/browser-route-dns-prefetch.electron.test.ts
|
||||
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
|
||||
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
|
||||
src/shared/child-process/windows-command-line.win32.test.ts
|
||||
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
|
||||
src/main/windows/windows-pty-job.win32.test.ts
|
||||
src/main/windows/windows-host-job.win32.test.ts
|
||||
src/main/wsl/wsl-runner.test.ts
|
||||
src/main/wsl/wsl-guest-environment.test.ts
|
||||
src/main/wsl/wsl-invocation-boundary.test.ts
|
||||
src/main/wsl/wsl-executable-path.win32.test.ts
|
||||
src/main/wsl/wsl-w1-w3-contract.test.ts
|
||||
src/shared/source-scan/source-tree-scan.test.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/cursor/hook-service.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
|
||||
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
src/main/ipc/pty-codex-account-attribution.test.ts
|
||||
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
|
||||
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
# job already packages and smoke-tests an AppImage built that way.
|
||||
- name: Cache Windows CLI launcher
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: native/windows-cli-launcher/.build
|
||||
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
env:
|
||||
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
|
||||
run: pnpm run build:release:parallel
|
||||
|
||||
- name: Restore compiled Electron native modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
@@ -389,6 +794,9 @@ jobs:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
- name: Smoke packaged Windows PTY native capability
|
||||
run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
@@ -396,19 +804,26 @@ jobs:
|
||||
# release runs retain full-suite coverage.
|
||||
e2e-paths:
|
||||
name: detect changed e2e specs
|
||||
needs: [code_paths]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft != true
|
||||
if: github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'
|
||||
# Why: detector only needs to read the checkout; do not inherit repo defaults.
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
|
||||
native_ime_source_changed: ${{ steps.filter.outputs.native_ime_source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter changed E2E specs
|
||||
@@ -418,9 +833,20 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
# Source routes are executable contracts so a test can prove exact
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
|
||||
# spec name surviving in a route's list. Same routes, so the two cannot drift.
|
||||
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
|
||||
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "SSH source changed: $SSH_SOURCE_CHANGED"
|
||||
# Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must
|
||||
# trigger on IME source rather than on a spec name in some route's list.
|
||||
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
|
||||
echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
@@ -439,16 +865,37 @@ jobs:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
|
||||
|
||||
# Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability
|
||||
# is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It
|
||||
# reports a red X on the PR without blocking, exactly like `e2e` above. Deliberately no
|
||||
# continue-on-error: that renders the check green and hides the signal it exists to give. To
|
||||
# make it blocking, add it to verify.needs, add TERMINAL_IME_NATIVE to the env below, and
|
||||
# require `success || skipped` outside the strict loop — see the note on `e2e`.
|
||||
terminal_ime_native:
|
||||
name: real IME
|
||||
needs: e2e-paths
|
||||
if: needs.e2e-paths.outputs.native_ime_source_changed == 'true'
|
||||
# Why: the reusable workflow only checks out, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/terminal-ime-e2e.yml
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- code_paths
|
||||
- static_analysis
|
||||
- root_directory_guard
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- codex_index_heal_contract
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -465,27 +912,69 @@ jobs:
|
||||
# checked outside the loop or it would excuse the jobs above.
|
||||
- name: Require successful checks
|
||||
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 }}
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
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 }}
|
||||
CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN: ${{ needs.code_paths.outputs.codex_index_heal_contract }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
|
||||
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
|
||||
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
|
||||
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
|
||||
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
|
||||
run: |
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
if [ "$CODE_PATHS" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHOULD_RUN" != "true" ]; then
|
||||
echo "Docs-only change; expensive PR checks skipped."
|
||||
fi
|
||||
failed=0
|
||||
check_job() {
|
||||
local name="$1" result="$2" should="$3"
|
||||
if [ "$should" = "true" ]; then
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "$name: expected success, got $result"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
if [ "$result" != "skipped" ]; then
|
||||
echo "$name: expected skipped, got $result"
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
# 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 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"
|
||||
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
|
||||
check_job test "$TEST" "$TEST_SHOULD_RUN"
|
||||
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
|
||||
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
|
||||
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
|
||||
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
|
||||
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
|
||||
exit "$failed"
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
|
||||
@@ -53,7 +53,7 @@ on:
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-cut
|
||||
@@ -68,6 +68,8 @@ jobs:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
|
||||
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
|
||||
@@ -783,7 +785,8 @@ jobs:
|
||||
{
|
||||
echo "## Release E2E Signal"
|
||||
echo ""
|
||||
echo "- Terminal rendering golden is release-blocking."
|
||||
echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows."
|
||||
echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script."
|
||||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||||
echo ""
|
||||
@@ -817,7 +820,7 @@ jobs:
|
||||
terminal-rendering-golden:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: terminal rendering golden ${{ matrix.platform }}
|
||||
name: golden e2e ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
@@ -830,10 +833,10 @@ jobs:
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows terminal rendering golden is temporarily disabled on
|
||||
# CI while its flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
# Windows terminal rendering remains flaky; keep its blocking signal
|
||||
# scoped to the fresh-profile startup regression from #14130.
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -841,6 +844,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore golden test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
@@ -851,9 +864,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# Why: Linux terminal golden E2E uses the same native install path as
|
||||
# release CI, which needs pnpm to bypass its non-executable gyp_main.py.
|
||||
@@ -866,26 +879,166 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for terminal rendering golden
|
||||
- name: Build Electron app for platform golden
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
# Why: this job is defined on the dispatch ref (usually main) but checks
|
||||
# out the release tag. Cherry-pick / hotfix tags can predate a golden
|
||||
# script that main already calls; --if-present keeps those cuts green
|
||||
# instead of failing with ERR_PNPM_NO_SCRIPT.
|
||||
- name: Run terminal rendering golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run source-control golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run terminal rendering golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run agent TUI golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
|
||||
- name: Run agent TUI golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
|
||||
- name: Run source-control golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run fresh-startup golden on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces
|
||||
name: golden-e2e-${{ matrix.platform }}-playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
skill-sharing-release-gate:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: skill sharing release gate ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Run skill package, transaction, and compatibility suites
|
||||
env:
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||||
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
|
||||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||||
|
||||
- name: Archive bounded skill-sharing results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skill-sharing-release-${{ matrix.platform }}
|
||||
path: skill-sharing-release-results.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
skill-sharing-linux-floor-release-gate:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: skill sharing release gate linux-glibc-2.31
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
container: ubuntu:20.04
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Trust the checked-out workspace in the job container
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Run skill package, transaction, and compatibility suites
|
||||
env:
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||||
|
||||
- name: Archive bounded skill-sharing results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skill-sharing-release-linux-glibc-2.31
|
||||
path: skill-sharing-release-results.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
# Why: these broader terminal rendering repros are useful release evidence,
|
||||
# but they include heavier app-like flows and must not block publishing.
|
||||
terminal-rendering-release-evidence:
|
||||
@@ -926,9 +1079,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# Why: keep the non-blocking evidence lane on the same Linux native
|
||||
# install path as the blocking golden and release build jobs.
|
||||
@@ -969,10 +1122,33 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Why: artifact jobs submit Windows binaries to SignPath. Keep every
|
||||
# quota-consuming build behind all blocking release gates so a late test
|
||||
# failure cannot create signing requests that can never be published.
|
||||
release-preflight:
|
||||
needs:
|
||||
- cut
|
||||
- terminal-rendering-golden
|
||||
- skill-sharing-release-gate
|
||||
- skill-sharing-linux-floor-release-gate
|
||||
if: >-
|
||||
always() &&
|
||||
needs.cut.outputs.should_release == 'true' &&
|
||||
needs.terminal-rendering-golden.result == 'success' &&
|
||||
needs.skill-sharing-release-gate.result == 'success' &&
|
||||
needs.skill-sharing-linux-floor-release-gate.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Confirm blocking release gates passed
|
||||
run: echo "All blocking release gates passed; artifact builds may start."
|
||||
|
||||
build:
|
||||
needs:
|
||||
- cut
|
||||
- create-release
|
||||
- release-preflight
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -989,12 +1165,14 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
|
||||
unpacked_dir: dist/linux-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux-arm64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
|
||||
unpacked_dir: dist/linux-arm64-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
@@ -1015,12 +1193,22 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
# GitHub reruns also resume jobs skipped behind a failed gate. Never
|
||||
# recreate Windows signing requests on a rerun; reuse the assets from the
|
||||
# original attempt and require a fresh dispatch if they are missing.
|
||||
- name: Skip Windows artifact rebuild on rerun
|
||||
if: matrix.platform == 'win' && github.run_attempt != 1
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Windows artifact/signing steps are disabled on reruns (attempt $GITHUB_RUN_ATTEMPT)."
|
||||
echo "Existing signed release assets must be reused; dispatch a fresh release only when a rebuild is required." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
|
||||
# ref, so cutting from an older/off-main ref whose tree predates a composite
|
||||
# action would fail the step with "Can't find 'action.yml'". Restore the
|
||||
# actions directory from the commit this workflow file itself came from.
|
||||
- name: Restore composite actions from the workflow ref
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
@@ -1038,9 +1226,9 @@ jobs:
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -1161,6 +1349,47 @@ jobs:
|
||||
# Kill only its child and require both PTY and watch recovery before packaging.
|
||||
node config/scripts/relay-watcher-fault-harness.mjs
|
||||
|
||||
# Why: main ships minified with sourcemap:'hidden' and packaging drops
|
||||
# out/**/*.map from app.asar, so a crash trace from a released build is
|
||||
# otherwise undecodable. The main bundle is platform-independent, so one
|
||||
# leg publishes the maps for the whole release.
|
||||
- name: Bundle main-process source maps
|
||||
id: bundle-main-sourcemaps
|
||||
if: matrix.platform == 'linux-x64'
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
|
||||
# Older cut tags predate the hidden-source-map build setting. They
|
||||
# are valid legacy releases, but have no map bundle to publish.
|
||||
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
|
||||
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
|
||||
exit 1
|
||||
fi
|
||||
echo "has_maps=false" >>"$GITHUB_OUTPUT"
|
||||
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
|
||||
exit 0
|
||||
fi
|
||||
echo "has_maps=true" >>"$GITHUB_OUTPUT"
|
||||
# Why: every entry in electron-builder's `files` is a negation, so
|
||||
# app-builder prepends `**/*` and packs anything left in the workspace
|
||||
# root into app.asar. Stage the bundle outside the checkout instead.
|
||||
find out/main -name '*.js.map' -print | sort | zip -q -X "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip" -@
|
||||
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
|
||||
|
||||
- name: Publish main-process source maps
|
||||
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: gh release upload "${{ needs.cut.outputs.tag }}" "${{ runner.temp }}/orca-sourcemaps-${{ needs.cut.outputs.tag }}.zip" --clobber --repo "${{ github.repository }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish release artifacts (Linux)
|
||||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||||
uses: nick-fields/retry@v4
|
||||
@@ -1172,10 +1401,21 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load packaged node-pty on the Linux floor
|
||||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: >-
|
||||
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
|
||||
--app-dir ${{ matrix.unpacked_dir }}
|
||||
|
||||
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
|
||||
# upload only after the production-signed installer has been returned.
|
||||
- name: Build Windows release artifacts
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
@@ -1186,7 +1426,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify Windows node-pty ConPTY runtime
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: pwsh
|
||||
run: |
|
||||
$runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
|
||||
@@ -1203,7 +1443,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
uses: ./.github/actions/install-signpath-module
|
||||
|
||||
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
||||
@@ -1220,7 +1460,7 @@ jobs:
|
||||
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
|
||||
- name: Stage unsigned inner PE files for signing
|
||||
id: stage-inner
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -1259,7 +1499,7 @@ jobs:
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1269,7 +1509,7 @@ jobs:
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.upload-unsigned-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
@@ -1283,7 +1523,7 @@ jobs:
|
||||
|
||||
- name: Notify Slack that inner-binary signing is waiting for approval
|
||||
id: notify-inner-signing
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -1351,7 +1591,7 @@ jobs:
|
||||
# falls through to today's unsigned-inner flow rather than blocking.
|
||||
- name: Download signed inner binaries from SignPath
|
||||
id: download-signed-inner
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -1374,7 +1614,7 @@ jobs:
|
||||
# shipping a mix of signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
id: restore-signed-inner
|
||||
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -1415,7 +1655,7 @@ jobs:
|
||||
# unsigned again, which the evidence gate will flag.
|
||||
- name: Replace cached elevate.exe with the signed copy
|
||||
id: sign-elevate-cache
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -1442,7 +1682,7 @@ jobs:
|
||||
|
||||
- name: Rebuild NSIS installer from signed unpacked app
|
||||
id: rebuild-nsis-signed
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -1459,7 +1699,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Roll back to original installer after failed rebuild
|
||||
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.rebuild-nsis-signed.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
|
||||
@@ -1469,7 +1709,7 @@ jobs:
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
id: upload-unsigned-windows-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1481,7 +1721,7 @@ jobs:
|
||||
# so the release job waits while the signing request is approved in UI.
|
||||
- name: Submit Windows installer signing request
|
||||
id: submit-signing-request
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
@@ -1493,7 +1733,7 @@ jobs:
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Notify Slack that Windows signing is waiting for approval
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: pwsh
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
@@ -1557,7 +1797,7 @@ jobs:
|
||||
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
||||
|
||||
- name: Download signed Windows installer from SignPath
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
@@ -1575,7 +1815,7 @@ jobs:
|
||||
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
|
||||
|
||||
- name: Stage signed Windows release assets
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
|
||||
@@ -1612,7 +1852,7 @@ jobs:
|
||||
Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml'
|
||||
|
||||
- name: Verify signed Windows installer
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
|
||||
@@ -1630,7 +1870,7 @@ jobs:
|
||||
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
|
||||
# to 'true' so unsigned inner binaries block the release.
|
||||
- name: Verify Windows inner binary signatures
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
|
||||
@@ -1762,7 +2002,7 @@ jobs:
|
||||
if ($policyFailure) { throw $policyFailure }
|
||||
|
||||
- name: Upload Windows inner signing evidence
|
||||
if: always() && matrix.platform == 'win'
|
||||
if: always() && matrix.platform == 'win' && github.run_attempt == 1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
|
||||
@@ -1773,7 +2013,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish signed Windows release artifacts
|
||||
if: matrix.platform == 'win'
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
@@ -1828,6 +2068,7 @@ jobs:
|
||||
needs:
|
||||
- cut
|
||||
- create-release
|
||||
- release-preflight
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
# Why: SignPath requires every job in this signing workflow to be
|
||||
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
|
||||
@@ -1859,6 +2100,8 @@ jobs:
|
||||
- cut
|
||||
- build
|
||||
- build-mac
|
||||
- skill-sharing-linux-floor-release-gate
|
||||
- skill-sharing-release-gate
|
||||
- terminal-rendering-golden
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -1950,6 +2193,42 @@ jobs:
|
||||
done
|
||||
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
|
||||
|
||||
docs-production-dispatch:
|
||||
needs:
|
||||
- cut
|
||||
- publish-release
|
||||
# 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 != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch docs deployment for stable desktop tags
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "Skipping docs deployment for non-stable tag $TAG."
|
||||
exit 0
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
if gh workflow run docs.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "$DEFAULT_BRANCH" \
|
||||
--field "tag=$TAG"; then
|
||||
echo "Dispatched docs deployment for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||||
done
|
||||
echo "::error::Failed to dispatch docs deployment for $TAG after 3 attempts."
|
||||
exit 1
|
||||
|
||||
homebrew-bump-published-rc-draft:
|
||||
needs:
|
||||
- cut
|
||||
|
||||
@@ -38,9 +38,9 @@ jobs:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
name: Skill update round trip
|
||||
|
||||
# Why the same paths on push as pull_request: GitHub evaluates each event's
|
||||
# filters independently. The PR already skipped README-only changes, but the
|
||||
# unfiltered `push` to main still started the 13-job matrix. Cancelling that
|
||||
# run marks the default-branch tip failed, so the repo shows a red X.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
paths: &skill-roundtrip-paths
|
||||
- 'skills/**'
|
||||
- 'resources/skills/**'
|
||||
- 'config/scripts/verify-skill-update-roundtrip.mjs'
|
||||
- 'config/scripts/skill-update-roundtrip-workflow.test.mjs'
|
||||
- 'src/main/skills/skill-freshness-eligibility.ts'
|
||||
- 'src/shared/skill-freshness.ts'
|
||||
- '.github/workflows/skill-update-roundtrip.yml'
|
||||
# Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge
|
||||
# queue today; if one is added, gate the matrix on a path job first.
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths: *skill-roundtrip-paths
|
||||
|
||||
jobs:
|
||||
roundtrip:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
# Why workflow_call and not pull_request: pr.yml owns the path filter that decides when an IME
|
||||
# change is worth a real ibus session. A pull_request trigger here would run it on every PR.
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
@@ -41,9 +44,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
run: |
|
||||
@@ -65,6 +68,10 @@ jobs:
|
||||
--workers=1
|
||||
|
||||
- name: Run native IBus Hangul exact-byte tests
|
||||
# Why not the default success(): the synthetic step above runs first, so its failure used
|
||||
# to skip this one entirely — the real-IME half reported nothing on exactly the PRs that
|
||||
# broke IME code. The two signals are independent and both belong in the log.
|
||||
if: '!cancelled()'
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
@@ -73,9 +73,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Unit tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_versions:
|
||||
description: JSON array of Node.js major versions to test.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
cache-electron-package: 'true'
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
# Why: setup-node can restore pnpm's content-addressed store only after
|
||||
# the pnpm binary exists, avoiding repeat dependency downloads per run.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -77,7 +77,6 @@ jobs:
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'config/build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
|
||||
@@ -85,9 +85,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
# Why: the harness only needs its runtime deps (the Playwright Electron
|
||||
# driver); it drives already-built installer artifacts, so no app build.
|
||||
|
||||
@@ -58,9 +58,9 @@ jobs:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -32,9 +32,9 @@ jobs:
|
||||
|
||||
# Why: pnpm must exist before setup-node resolves its dependency cache.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
+29
-1
@@ -17,6 +17,9 @@ src/**/*.d.ts
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
# Why: the trailing-slash form matches directories only, so a node_modules SYMLINK (how agent
|
||||
# worktrees share an install) slipped past a bulk `git add` and got committed — twice.
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
@@ -89,20 +92,36 @@ design-docs/
|
||||
# and the tracked reference docs linked from AGENTS.md / README.md).
|
||||
docs/**
|
||||
!docs/
|
||||
# The deployable docs app is source, not local engineering notes.
|
||||
!docs/site/
|
||||
!docs/site/**
|
||||
!docs/assets/
|
||||
!docs/assets/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/ime-regression-checklist.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
!docs/reference/wsl-runner-verification.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/renderer-agent-status-performance.md
|
||||
!docs/reference/ssh-execution-boundary.md
|
||||
!docs/reference/ssh-host-key-verification.md
|
||||
!docs/reference/ssh-reconnect-source-recovery.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
!docs/reference/worktree-scan-fingerprint.md
|
||||
!docs/reference/wsl-command-execution.md
|
||||
!docs/reference/wsl-probe-failure-semantics.md
|
||||
!docs/reference/xterm-patch-regeneration.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -125,6 +144,10 @@ playwright-report/
|
||||
/.agents/skills/
|
||||
/skills-lock.json
|
||||
|
||||
# Generated Clawpatch state includes machine-local paths and review records.
|
||||
/.clawpatch/
|
||||
/mobile/.clawpatch/
|
||||
|
||||
# Agent hook runtime endpoints (machine-local secrets)
|
||||
/agent-hooks/
|
||||
validation-screenshots/
|
||||
@@ -140,3 +163,8 @@ tests/tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
# Old release trees the cross-version wire harness extracts on demand
|
||||
tests/e2e/.cross-version-checkouts/
|
||||
|
||||
# Bundler's install path for the mobile release toolchain (mobile/Gemfile.lock
|
||||
# IS committed). Also keeps oxfmt/oxlint, which honor this file, from walking
|
||||
# vendored gems.
|
||||
/mobile/vendor/
|
||||
|
||||
@@ -40,6 +40,18 @@
|
||||
"react/no-string-refs": "error",
|
||||
"react/no-unescaped-entities": "error",
|
||||
"react/require-render-return": "error",
|
||||
"react/error-boundaries": "error",
|
||||
"react/globals": "error",
|
||||
"react/immutability": "off",
|
||||
"react/incompatible-library": "off",
|
||||
"react/preserve-manual-memoization": "off",
|
||||
"react/purity": "error",
|
||||
"react/refs": "off",
|
||||
"react/set-state-in-effect": "off",
|
||||
"react/set-state-in-render": "error",
|
||||
"react/static-components": "error",
|
||||
"react/use-memo": "error",
|
||||
"react/void-use-memo": "error",
|
||||
"react/jsx-curly-brace-presence": [
|
||||
"error",
|
||||
{ "props": "never", "children": "never", "propElementValues": "always" }
|
||||
@@ -56,11 +68,13 @@
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"typescript/array-type": "error",
|
||||
"typescript/consistent-generic-constructors": "error",
|
||||
"typescript/consistent-indexed-object-style": "error",
|
||||
"typescript/consistent-type-assertions": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/no-explicit-any": ["error", { "ignoreRestArgs": true }],
|
||||
"typescript/no-confusing-non-null-assertion": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "error",
|
||||
"typescript/no-unnecessary-template-expression": "error",
|
||||
@@ -68,12 +82,16 @@
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-optional-chain": "error",
|
||||
"typescript/prefer-ts-expect-error": "error",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
],
|
||||
"curly": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-else-return": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-object-has-own": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -90,11 +108,16 @@
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
"unicorn/consistent-existence-index-check": "error",
|
||||
"unicorn/empty-brace-spaces": "error",
|
||||
"unicorn/error-message": "error",
|
||||
"unicorn/no-array-fill-with-reference-type": "warn",
|
||||
"unicorn/no-array-reverse": "error",
|
||||
"unicorn/no-instanceof-builtins": "error",
|
||||
"unicorn/no-typeof-undefined": "error",
|
||||
"unicorn/no-unnecessary-array-splice-count": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-zero-fractions": "error",
|
||||
"unicorn/prefer-array-find": "error",
|
||||
"unicorn/prefer-array-flat-map": "warn",
|
||||
"unicorn/prefer-array-index-of": "error",
|
||||
@@ -121,6 +144,7 @@
|
||||
{
|
||||
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
|
||||
"rules": {
|
||||
"react/globals": "off",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "off",
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Electron UI Validation
|
||||
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
## Concise/Brief Non-obviosu comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Reuse Before Reimplementing
|
||||
|
||||
Before writing new logic at any scale — a function, component, IPC channel, state store, or whole subsystem/flow — check whether an existing implementation already does the job (or nearly does). Extend or generalize it instead of building a parallel version; only write from scratch when nothing fits. Keep the check proportionate: a quick search for trivial code, a real one before building anything substantial.
|
||||
|
||||
## Concise/Brief Non-obvious Comments ONLY
|
||||
|
||||
- DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
- BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -17,7 +27,14 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
# Verifying Changes
|
||||
|
||||
- **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`)
|
||||
- **Test**: `pnpm test [path/to/file.test.ts]`
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
@@ -30,11 +47,14 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
|
||||
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
|
||||
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
|
||||
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
|
||||
|
||||
## SSH Use Case
|
||||
|
||||
All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms.
|
||||
|
||||
## Folder Workspace Use Case
|
||||
|
||||
@@ -56,6 +76,12 @@ When adding or changing a Git command:
|
||||
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
|
||||
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
|
||||
|
||||
## Git Scan Safety
|
||||
|
||||
- Never enumerate every ref and then run `git ls-tree -r` or `git show` once per ref. That ref × tree fan-out can retain gigabytes of output before a downstream `sort -u` or search can make progress.
|
||||
- Prefer `rg` over the checked-out files for source searches. For history or refs, use a named ref, an explicit namespace/path, `--max-count`, and a bounded output; do not use an unqualified `--all` scan as a first diagnostic.
|
||||
- Keep repository-wide commands targeted to the current repository and worktree. If an unbounded scan is genuinely required, measure the ref count first, explain the cost, and get confirmation before running it.
|
||||
|
||||
## Git Provider Compatibility
|
||||
|
||||
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.
|
||||
|
||||
@@ -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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,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) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** Scan to join the Orca community WeChat group 7.
|
||||
- **WeChat:** Scan to join the Orca community WeChat group 7. If it is full, use group 8.
|
||||
|
||||
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
type NormalizedInputOptions = Rollup.NormalizedInputOptions
|
||||
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
||||
type OutputBundle = Rollup.OutputBundle
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
@@ -16,14 +17,14 @@ type OutputChunk = Rollup.OutputChunk
|
||||
// graph still resolves.
|
||||
|
||||
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
|
||||
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
|
||||
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
|
||||
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
'wsl-transcript-fs-process-entry',
|
||||
'agent-hooks/managed-agent-hook-controls'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
@@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
export const GUARDED_ENTRY_NAMES = [
|
||||
...PLAIN_NODE_ENTRY_NAMES,
|
||||
...WORKER_THREAD_ENTRY_NAMES
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
|
||||
|
||||
// Why: writeBundle skips any name missing from the bundle, so a renamed or
|
||||
// removed rollup input would silently drop that entry from the guard and let the
|
||||
// regression back in. Pin the lists to the input keys at build start instead.
|
||||
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
|
||||
if (typeof input === 'string' || Array.isArray(input)) {
|
||||
return
|
||||
}
|
||||
const inputNames = new Set(Object.keys(input))
|
||||
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
|
||||
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
|
||||
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
|
||||
`the current entry names — a stale name silently stops guarding that entry.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function collectReachableChunks(
|
||||
entry: OutputChunk,
|
||||
@@ -89,41 +115,125 @@ function assertNoElectronRequire(
|
||||
}
|
||||
}
|
||||
|
||||
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
|
||||
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
|
||||
|
||||
export type SmokeTimings = {
|
||||
timeoutMs: number
|
||||
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
|
||||
// needs an uncatchable follow-up to stay a deadline.
|
||||
killGraceMs: number
|
||||
}
|
||||
|
||||
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
|
||||
|
||||
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
|
||||
// can hold the pipes open long after the child is gone.
|
||||
const SMOKE_STDERR_DRAIN_MS = 250
|
||||
|
||||
type SmokeResult = {
|
||||
status: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stderr: string
|
||||
error?: Error
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
|
||||
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
|
||||
// build forever. Escalate to SIGKILL instead.
|
||||
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let stderr = ''
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
let forceKillTimer: NodeJS.Timeout | undefined
|
||||
let drainTimer: NodeJS.Timeout | undefined
|
||||
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
const deadlineTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGTERM')
|
||||
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
|
||||
}, timings.timeoutMs)
|
||||
|
||||
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(deadlineTimer)
|
||||
clearTimeout(forceKillTimer)
|
||||
clearTimeout(drainTimer)
|
||||
resolve({ status, signal, stderr, error, timedOut })
|
||||
}
|
||||
|
||||
child.on('error', (error: Error) => finish(null, null, error))
|
||||
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
|
||||
// cannot outlast the process itself.
|
||||
child.on('close', (status, signal) => finish(status, signal))
|
||||
child.on('exit', (status, signal) => {
|
||||
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
||||
// unresolved requires). require("electron") does not throw in a dev tree with
|
||||
// node_modules present, so the static scan above — not this smoke — is the
|
||||
// electron regression guard; this only catches gross load failures.
|
||||
function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
|
||||
const entryPath = join(outputDir, 'daemon-entry.js')
|
||||
const result = spawnSync(process.execPath, [entryPath], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
const result = await runDaemonEntry(entryPath, timings)
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
||||
`${result.error.message}`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr ?? ''
|
||||
// Almost always means the daemon stopped rejecting an empty argv and started
|
||||
// listening instead.
|
||||
if (result.timedOut) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
|
||||
`empty argv under plain Node, so the smoke killed it.`
|
||||
)
|
||||
}
|
||||
if (result.signal) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr
|
||||
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
||||
)
|
||||
}
|
||||
if (!stderr.includes('Usage: daemon-entry')) {
|
||||
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
|
||||
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
|
||||
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
|
||||
`${result.status}). stderr:\n${stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
export function createPlainNodeEntryGuardPlugin(
|
||||
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
|
||||
): Plugin {
|
||||
let daemonOutputDir: string | undefined
|
||||
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
buildStart(options: NormalizedInputOptions) {
|
||||
assertEntryNamesAreRollupInputs(options.input)
|
||||
},
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
||||
// every rebuild, and the guard only needs to gate produced builds.
|
||||
@@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
daemonOutputDir = options.dir
|
||||
}
|
||||
},
|
||||
closeBundle() {
|
||||
async closeBundle() {
|
||||
if (daemonOutputDir) {
|
||||
const outputDir = daemonOutputDir
|
||||
daemonOutputDir = undefined
|
||||
smokeLoadDaemonEntry(outputDir)
|
||||
await smokeLoadDaemonEntry(outputDir, smokeTimings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@10.24.0 --activate
|
||||
&& corepack prepare pnpm@12.0.0 --activate \
|
||||
&& pnpm --version
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dbus-x11 \
|
||||
iproute2 \
|
||||
jq \
|
||||
libatk-bridge2.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libasound2t64 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
p7zip-full \
|
||||
procps \
|
||||
util-linux \
|
||||
xauth \
|
||||
xvfb \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash orca
|
||||
|
||||
COPY run-signal-case.sh /usr/local/bin/run-signal-case
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
signal_name=${1:?signal name is required}
|
||||
app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
|
||||
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
|
||||
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
|
||||
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
|
||||
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
|
||||
|
||||
if ((EUID == 0)); then
|
||||
exec runuser --user orca --preserve-environment -- "$0" "$@"
|
||||
fi
|
||||
|
||||
case "$signal_name" in
|
||||
INT|TERM) ;;
|
||||
*) echo "unsupported signal: $signal_name" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
state_dir=$(mktemp -d "/tmp/orca-shutdown-${signal_name}.XXXXXX")
|
||||
stdout_log="$state_dir/stdout.log"
|
||||
stderr_log="$state_dir/stderr.log"
|
||||
ulimit -c 0
|
||||
|
||||
sleep 300 &
|
||||
canary_pid=$!
|
||||
canary_start_ticks=$(awk '{print $22}' "/proc/$canary_pid/stat")
|
||||
cleanup() {
|
||||
kill "$canary_pid" 2>/dev/null || true
|
||||
wait "$canary_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export HOME="$state_dir/home"
|
||||
export XDG_CONFIG_HOME="$state_dir/config"
|
||||
export XDG_CACHE_HOME="$state_dir/cache"
|
||||
export XDG_RUNTIME_DIR="$state_dir/runtime"
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
case "$entrypoint_kind" in
|
||||
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
|
||||
launcher)
|
||||
export ELECTRON_DISABLE_SANDBOX=1
|
||||
entrypoint=("$app_root/resources/bin/orca-ide")
|
||||
;;
|
||||
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.0.1 --json \
|
||||
>"$stdout_log" 2>"$stderr_log" &
|
||||
app_pid=$!
|
||||
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
|
||||
|
||||
# The inner shell expands its positional parameters.
|
||||
# shellcheck disable=SC2016
|
||||
ready_line=$(timeout "$startup_timeout_seconds" bash -c '
|
||||
tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
|
||||
| jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
|
||||
' bash "$app_pid" "$stdout_log" || true)
|
||||
if [[ -z "$ready_line" ]]; then
|
||||
cat "$stdout_log" "$stderr_log" >&2
|
||||
echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
|
||||
bound_port=${bound_endpoint##*:}
|
||||
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
|
||||
if [[ -z "$listener_before" ]]; then
|
||||
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tree_pids=()
|
||||
declare -A tree_start_ticks
|
||||
tree_start_ticks["$app_pid"]=$app_start_ticks
|
||||
frontier=("$app_pid")
|
||||
while ((${#frontier[@]})); do
|
||||
parent=${frontier[0]}
|
||||
frontier=("${frontier[@]:1}")
|
||||
while read -r child; do
|
||||
[[ -n "$child" ]] || continue
|
||||
child_start_ticks=$(awk '{print $22}' "/proc/$child/stat" 2>/dev/null || true)
|
||||
[[ -n "$child_start_ticks" ]] || continue
|
||||
tree_pids+=("$child")
|
||||
tree_start_ticks["$child"]=$child_start_ticks
|
||||
frontier+=("$child")
|
||||
done < <(ps -o pid= --ppid "$parent" | tr -d ' ')
|
||||
done
|
||||
|
||||
tree_pid_csv="$app_pid"
|
||||
for pid in "${tree_pids[@]}"; do
|
||||
tree_pid_csv+=",$pid"
|
||||
done
|
||||
tree_snapshot=$(ps -o pid=,ppid=,pgid=,lstart=,stat=,args= -p "$tree_pid_csv" 2>/dev/null || true)
|
||||
xvfb_pids=$(awk '/[X]vfb :99 / {print $1}' <<<"$tree_snapshot" | paste -sd, -)
|
||||
if [[ -z "$xvfb_pids" ]]; then
|
||||
echo "$tree_snapshot" >&2
|
||||
echo "FAIL: no run-owned Xvfb :99 process found after readiness" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
signal_target_pid=$app_pid
|
||||
if [[ "$signal_target_kind" == serving-electron ]]; then
|
||||
signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
|
||||
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
|
||||
elif [[ "$signal_target_kind" != app ]]; then
|
||||
echo "unsupported signal target: $signal_target_kind" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
signal_target_start_ticks=${tree_start_ticks[$signal_target_pid]:-}
|
||||
if [[ -z "$signal_target_start_ticks" ]] \
|
||||
|| [[ $(awk '{print $22}' "/proc/$signal_target_pid/stat") != "$signal_target_start_ticks" ]]; then
|
||||
echo "FAIL: signal target identity changed before delivery" >&2
|
||||
exit 1
|
||||
fi
|
||||
signal_delivery=pid
|
||||
if [[ "$signal_name" == INT && "$int_delivery" == foreground-process-group ]]; then
|
||||
signal_delivery=$int_delivery
|
||||
kill -s "$signal_name" -- "-$signal_target_pid"
|
||||
else
|
||||
kill -s "$signal_name" "$signal_target_pid"
|
||||
fi
|
||||
|
||||
sleep 30 &
|
||||
watchdog_pid=$!
|
||||
set +e
|
||||
wait -n -p completed_pid "$app_pid" "$watchdog_pid"
|
||||
wait_status=$?
|
||||
set -e
|
||||
if [[ "$completed_pid" == "$watchdog_pid" ]]; then
|
||||
echo "FAIL: foreground AppRun did not exit after $signal_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
wait "$watchdog_pid" 2>/dev/null || true
|
||||
|
||||
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
|
||||
survivors=()
|
||||
for pid in "${tree_pids[@]}"; do
|
||||
if [[ -r "/proc/$pid/stat" ]] \
|
||||
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
|
||||
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
|
||||
survivors+=("$pid")
|
||||
fi
|
||||
done
|
||||
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
|
||||
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
|
||||
|
||||
canary_alive=false
|
||||
if kill -0 "$canary_pid" 2>/dev/null \
|
||||
&& [[ $(awk '{print $22}' "/proc/$canary_pid/stat") == "$canary_start_ticks" ]]; then
|
||||
canary_alive=true
|
||||
fi
|
||||
fatal_evidence=false
|
||||
if grep -Eq 'Failed to shutdown|SIGTRAP|Trace/breakpoint trap|core dumped' \
|
||||
"$stdout_log" "$stderr_log"; then
|
||||
fatal_evidence=true
|
||||
fi
|
||||
|
||||
jq -nc \
|
||||
--arg signal "$signal_name" \
|
||||
--arg signalDelivery "$signal_delivery" \
|
||||
--arg entrypointKind "$entrypoint_kind" \
|
||||
--arg signalTargetKind "$signal_target_kind" \
|
||||
--argjson appPid "$app_pid" \
|
||||
--argjson signalTargetPid "$signal_target_pid" \
|
||||
--arg endpoint "$bound_endpoint" \
|
||||
--arg listenerBefore "$listener_before" \
|
||||
--arg listenerAfter "$listener_after" \
|
||||
--arg xvfbPids "$xvfb_pids" \
|
||||
--arg treeBefore "$tree_snapshot" \
|
||||
--argjson waitStatus "$wait_status" \
|
||||
--argjson fatalEvidence "$fatal_evidence" \
|
||||
--argjson canaryAlive "$canary_alive" \
|
||||
--arg survivors "${survivors[*]:-}" \
|
||||
--arg residue "$owned_residue" \
|
||||
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
|
||||
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
|
||||
|
||||
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|
||||
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
|
||||
echo "--- stdout ---" >&2
|
||||
cat "$stdout_log" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$stderr_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -14,6 +14,9 @@ const {
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
|
||||
// Why: dev-channel builds must carry the *release* identity — same bundle id,
|
||||
@@ -22,15 +25,27 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
|
||||
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
|
||||
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
|
||||
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
|
||||
const isMacRelease =
|
||||
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
|
||||
// Why a second set of variables rather than making the mac ones platform-neutral:
|
||||
// the mac ones gate `isMacRelease` below, which turns on hardened runtime,
|
||||
// notarization, and root-level `forceCodeSigning`. A Windows dev build that
|
||||
// reused them would fail packaging outright for want of a cert it is
|
||||
// deliberately not using.
|
||||
const isWinHourly = process.env.ORCA_WIN_HOURLY === '1'
|
||||
const isWinDaily = process.env.ORCA_WIN_DAILY === '1'
|
||||
const isWinAdhoc = process.env.ORCA_WIN_ADHOC === '1'
|
||||
const isWinDevChannel = isWinHourly || isWinDaily || isWinAdhoc
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const devChannelBuildVersion = isMacHourly
|
||||
const localBuildVersion =
|
||||
isMacRelease || isWinDevChannel ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const isHourlyChannel = isMacHourly || isWinHourly
|
||||
const isDailyChannel = isMacDaily || isWinDaily
|
||||
const isAdhocChannel = isMacAdhoc || isWinAdhoc
|
||||
const devChannelBuildVersion = isHourlyChannel
|
||||
? process.env.ORCA_HOURLY_BUILD_VERSION
|
||||
: isMacDaily
|
||||
: isDailyChannel
|
||||
? process.env.ORCA_DAILY_BUILD_VERSION
|
||||
: isMacAdhoc
|
||||
: isAdhocChannel
|
||||
? process.env.ORCA_ADHOC_BUILD_VERSION
|
||||
: undefined
|
||||
// Why each dev channel gets its own repo rather than tagging into the main one:
|
||||
@@ -39,11 +54,11 @@ const devChannelBuildVersion = isMacHourly
|
||||
// to install. Keeping adhoc/daily separate from hourly too means a branch build
|
||||
// or a once-a-day cut cannot be picked up by someone who only meant to ride
|
||||
// main's hourlies.
|
||||
const devChannelRepo = isMacHourly
|
||||
const devChannelRepo = isHourlyChannel
|
||||
? 'orca-hourly'
|
||||
: isMacDaily
|
||||
: isDailyChannel
|
||||
? 'orca-daily'
|
||||
: isMacAdhoc
|
||||
: isAdhocChannel
|
||||
? 'orca-adhoc'
|
||||
: null
|
||||
const appId = 'com.stablyai.orca'
|
||||
@@ -75,6 +90,8 @@ const bundledPluginResources = {
|
||||
// runtime dependency closure to Resources/node_modules so bare require() calls
|
||||
// do not fall through to a developer checkout's node_modules.
|
||||
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
|
||||
// Why: native speech addons must be real files outside app.asar; copy only the
|
||||
// package matching the artifact target instead of every optional variant.
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
@@ -92,6 +109,7 @@ const winSpeechNativeResource = {
|
||||
module.exports = {
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
protocols: [{ name: 'Orca', schemes: ['orca'] }],
|
||||
...(devChannelBuildVersion
|
||||
? { extraMetadata: { version: devChannelBuildVersion } }
|
||||
: localBuildVersion
|
||||
@@ -124,11 +142,21 @@ module.exports = {
|
||||
// it is gitignored, but exclude it defensively so a stray local capture at
|
||||
// package time never bloats app.asar.
|
||||
'!pr-evidence{,/**/*}',
|
||||
// Why: local agent/tooling directories may contain worktree symlink loops;
|
||||
// they are never runtime inputs and must not be traversed by electron-builder.
|
||||
'!{.claude,.grok,.agents,.codex}{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
// Why: main builds with sourcemap:'hidden' so release CI can publish maps
|
||||
// for decoding minified crash traces. The app never loads them (no
|
||||
// sourceMappingURL is emitted), and packing them would add ~34MB to app.asar.
|
||||
'!out/**/*.map',
|
||||
// Why: Vite's manifest is only used to project the paired web client.
|
||||
'!out/renderer/.vite{,/**/*}',
|
||||
// Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each).
|
||||
// CI never creates it, but packaging on a machine that has run dev would pack them all.
|
||||
'!out/electron-dev{,/**/*}',
|
||||
'!electron.vite.config.{js,ts,mjs,cjs}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
@@ -140,6 +168,10 @@ module.exports = {
|
||||
// Why: bundled plugins ship via extraResources to resources/plugins/launch;
|
||||
// packing the source tree into app.asar would duplicate those exact bytes.
|
||||
'!resources/plugins/launch/**',
|
||||
// Why: speech packages are copied selectively through the platform
|
||||
// extraResources entry below; keeping them in app.asar would ship every
|
||||
// native variant (and duplicate the selected one).
|
||||
'!node_modules/sherpa-onnx*{,/**/*}',
|
||||
// Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd
|
||||
// (beside the native resources/bin/orca.exe). Packing the source tree into
|
||||
// app.asar too lets asarUnpack:['resources/**'] extract a second copy at
|
||||
@@ -163,9 +195,12 @@ 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: sherpa-onnx native bindings (platform-specific subpackages) must be
|
||||
// unpacked because they ship .node addons + .dylib/.so files that cannot be
|
||||
// dlopen()'d from inside the asar archive.
|
||||
// Why: the OpenCode SQLite worker 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
|
||||
// entries reached solely from the Electron main process stay packed, since
|
||||
// asar redirects their app.asar paths.
|
||||
asarUnpack: [
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
@@ -183,6 +218,8 @@ module.exports = {
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/wsl-transcript-fs-process-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
@@ -191,8 +228,7 @@ module.exports = {
|
||||
'node_modules/ws/**',
|
||||
'node_modules/tweetnacl/**',
|
||||
'node_modules/zod/**',
|
||||
'node_modules/yaml/**',
|
||||
'node_modules/sherpa-onnx*/**'
|
||||
'node_modules/yaml/**'
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
|
||||
@@ -242,6 +278,13 @@ module.exports = {
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
if (process.platform === 'win32' && canExecuteTargetArch) {
|
||||
verifyPackagedNodePtyJobOwnership(resourcesDir)
|
||||
} else {
|
||||
console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package')
|
||||
}
|
||||
}
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
})
|
||||
@@ -277,8 +320,14 @@ module.exports = {
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
|
||||
await signMacNotificationStatusHelper(
|
||||
await signMacStandaloneHelper(
|
||||
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
|
||||
'orca-notification-status',
|
||||
context.packager
|
||||
)
|
||||
await signMacStandaloneHelper(
|
||||
join(resourcesDir, '..', 'MacOS', 'orca-keyboard-layout'),
|
||||
'orca-keyboard-layout',
|
||||
context.packager
|
||||
)
|
||||
}
|
||||
@@ -287,9 +336,18 @@ module.exports = {
|
||||
executableName: 'Orca',
|
||||
// Why: Windows installers are signed after electron-builder packaging by
|
||||
// SignPath, so the packager cannot infer the updater publisherName.
|
||||
signtoolOptions: {
|
||||
publisherName: 'SignPath Foundation'
|
||||
},
|
||||
//
|
||||
// Why dev channels drop it instead: they ship unsigned, because SignPath's
|
||||
// approval waits are budgeted in hours and cannot fit an hourly cadence.
|
||||
// electron-updater Authenticode-verifies every installer it downloads
|
||||
// against the publisherName baked into the *installed* app's app-update.yml
|
||||
// (NsisUpdater.verifySignature), and skips verification entirely when that
|
||||
// name is absent. An unsigned build that still claimed 'SignPath Foundation'
|
||||
// would therefore reject its own channel's next build — and its way back to
|
||||
// stable with it. Dropping it is what makes dev→dev and dev→stable work.
|
||||
...(isWinDevChannel
|
||||
? { verifyUpdateCodeSignature: false }
|
||||
: { signtoolOptions: { publisherName: 'SignPath Foundation' } }),
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('win32'),
|
||||
@@ -373,12 +431,6 @@ module.exports = {
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
|
||||
to: 'agent-browser-darwin-${arch}'
|
||||
},
|
||||
// Why: serve-sim resolves its helper binary and camera assets relative
|
||||
// to dist/serve-sim.js, so the whole package must be a real resource dir.
|
||||
{
|
||||
from: 'node_modules/serve-sim',
|
||||
to: 'serve-sim'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
|
||||
to: 'Orca Computer Use.app'
|
||||
@@ -392,6 +444,10 @@ module.exports = {
|
||||
{
|
||||
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
||||
to: 'MacOS/orca-notification-status'
|
||||
},
|
||||
{
|
||||
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
|
||||
to: 'MacOS/orca-keyboard-layout'
|
||||
}
|
||||
],
|
||||
target: [
|
||||
@@ -564,10 +620,10 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
|
||||
})
|
||||
}
|
||||
|
||||
async function signMacNotificationStatusHelper(helperPath, packager) {
|
||||
async function signMacStandaloneHelper(helperPath, helperName, packager) {
|
||||
if (!existsSync(helperPath)) {
|
||||
if (isMacRelease) {
|
||||
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
|
||||
throw new Error(`Missing ${helperName} helper at ${helperPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -580,12 +636,9 @@ async function signMacNotificationStatusHelper(helperPath, packager) {
|
||||
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
|
||||
(isMacRelease ? null : '-')
|
||||
if (!identity) {
|
||||
throw new Error('Missing signing identity for orca-notification-status helper')
|
||||
throw new Error(`Missing signing identity for ${helperName} helper`)
|
||||
}
|
||||
// Why: macOS keys notification records to the code-signing identifier; the
|
||||
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
|
||||
// (and any later) `codesign --force` derives the correct identifier. Sign
|
||||
// before the outer Orca.app is sealed, like the computer-use helper.
|
||||
// Why: nested executables must be signed before the outer app bundle is sealed.
|
||||
const args = ['--force', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
args.push('--options', 'runtime', '--timestamp')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { electronViteConfig } from '../electron.vite.config'
|
||||
|
||||
const target = process.env.ORCA_ELECTRON_VITE_TARGET
|
||||
const configByTarget = {
|
||||
main: { main: electronViteConfig.main },
|
||||
preload: { preload: electronViteConfig.preload },
|
||||
renderer: { renderer: electronViteConfig.renderer }
|
||||
}
|
||||
|
||||
if (!target || !Object.hasOwn(configByTarget, target)) {
|
||||
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
export default defineConfig(configByTarget[target as keyof typeof configByTarget])
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { electronViteConfig } from '../electron.vite.config'
|
||||
|
||||
const target = process.env.ORCA_ELECTRON_VITE_TARGET
|
||||
const configByTarget = {
|
||||
main: { main: electronViteConfig.main },
|
||||
preload: { preload: electronViteConfig.preload },
|
||||
renderer: { renderer: electronViteConfig.renderer }
|
||||
}
|
||||
|
||||
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
|
||||
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
export default defineConfig(configByTarget[target as keyof typeof configByTarget])
|
||||
@@ -14,7 +14,6 @@
|
||||
"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",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
"src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"src/main/claude-accounts/keychain.ts",
|
||||
"src/renderer/src/main.tsx",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -2,350 +2,47 @@
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/cli/index.test.ts
|
||||
inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-guest-ui.ts
|
||||
inline src/main/browser/browser-manager-grab.test.ts
|
||||
inline src/main/browser/browser-manager.test.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/browser-screencast-stream.ts
|
||||
inline src/main/browser/browser-session-registry.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/cdp-ws-proxy.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/browser/snapshot-engine.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.test.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.test.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/claude-usage/scanner.ts
|
||||
inline src/main/claude-usage/store.ts
|
||||
inline src/main/cli/cli-installer.test.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.test.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.test.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex-usage/scanner.ts
|
||||
inline src/main/codex-usage/store.test.ts
|
||||
inline src/main/codex-usage/store.ts
|
||||
inline src/main/codex/config-toml-trust.test.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.test.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/copilot/hook-service.ts
|
||||
inline src/main/daemon/client.ts
|
||||
inline src/main/daemon/daemon-health.ts
|
||||
inline src/main/daemon/daemon-init.test.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.test.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.test.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/daemon/session.ts
|
||||
inline src/main/daemon/shell-ready.ts
|
||||
inline src/main/git/remove-worktree.test.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.test.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.test.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.test.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/issues.ts
|
||||
inline src/main/github/pr-refresh-coordinator.test.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client-mr.test.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/gitlab/issues.ts
|
||||
inline src/main/gitlab/work-item-details.ts
|
||||
inline src/main/hermes/hook-service.ts
|
||||
inline src/main/hooks.test.ts
|
||||
inline src/main/hooks.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/browser.ts
|
||||
inline src/main/ipc/crash-reporting.ts
|
||||
inline src/main/ipc/filesystem-auth.ts
|
||||
inline src/main/ipc/filesystem-mutations.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.test.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.test.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/gitlab.ts
|
||||
inline src/main/ipc/linear.ts
|
||||
inline src/main/ipc/notifications.test.ts
|
||||
inline src/main/ipc/notifications.ts
|
||||
inline src/main/ipc/pet.ts
|
||||
inline src/main/ipc/preflight.test.ts
|
||||
inline src/main/ipc/pty.test.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/remote-workspace.ts
|
||||
inline src/main/ipc/repos-remote.test.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/runtime-environments.test.ts
|
||||
inline src/main/ipc/ssh.test.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.test.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/client.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/client.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode-usage/scanner.ts
|
||||
inline src/main/opencode-usage/store.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence.test.ts
|
||||
inline src/main/persistence.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.test.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/local-pty-shell-ready.test.ts
|
||||
inline src/main/providers/local-pty-shell-ready.ts
|
||||
inline src/main/providers/ssh-git-provider.test.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.test.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
inline src/main/rate-limits/service.test.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.test.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/coordinator.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/files.ts
|
||||
inline src/main/runtime/rpc/methods/git.ts
|
||||
inline src/main/runtime/rpc/methods/github.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.test.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/rpc/terminal-multiplex.test.ts
|
||||
inline src/main/runtime/runtime-rpc.test.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.test.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/star-nag/service.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.test.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.test.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/api-types.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/agent-hook-server.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/fs-handler.ts
|
||||
inline src/relay/git-handler.test.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.test.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/App.tsx
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/PullRequestPage.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
|
||||
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/editor/MonacoEditor.tsx
|
||||
inline src/renderer/src/components/editor/editor-autosave-controller.ts
|
||||
inline src/renderer/src/components/editor/ipynb-parse.ts
|
||||
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/linear-project-view-surfaces.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/right-sidebar/index.tsx
|
||||
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
|
||||
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
|
||||
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/lib/worktree-activation.test.ts
|
||||
inline src/renderer/src/lib/worktree-activation.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.test.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-git-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.test.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.test.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.test.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/editor.test.ts
|
||||
inline src/renderer/src/store/slices/editor.ts
|
||||
inline src/renderer/src/store/slices/github.test.ts
|
||||
inline src/renderer/src/store/slices/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.test.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/store-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/store-session-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.test.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/store/slices/worktrees.test.ts
|
||||
inline src/renderer/src/store/slices/worktrees.ts
|
||||
inline src/renderer/src/web/web-preload-api.test.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.test.ts
|
||||
inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/github-project-types.ts
|
||||
inline src/shared/keybindings.test.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/remote-runtime-client.ts
|
||||
inline src/shared/runtime-types.ts
|
||||
inline src/shared/source-control-ai.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline src/shared/types.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
inline tests/e2e/terminal-panes.spec.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"import/namespace": "warn",
|
||||
"import/no-cycle": ["warn", { "maxDepth": 3 }],
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-empty-named-blocks": "warn",
|
||||
"import/no-self-import": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"typescript/await-thenable": "warn",
|
||||
"typescript/no-redundant-type-constituents": "warn",
|
||||
"typescript/restrict-plus-operands": "warn",
|
||||
"typescript/restrict-template-expressions": "warn",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
|
||||
@@ -22,6 +22,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'jsonc-parser',
|
||||
'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',
|
||||
@@ -31,7 +32,10 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@vscode/windows-process-tree',
|
||||
'windows-native-registry'
|
||||
]
|
||||
|
||||
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
|
||||
darwin: 'darwin-',
|
||||
@@ -53,6 +57,7 @@ const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
||||
}
|
||||
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const JS_SOURCE_MAP_ARTIFACT_RE = /\.(?:c|m)?js\.map$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
|
||||
const NODE_BUILTINS = new Set([
|
||||
@@ -345,6 +350,35 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
return
|
||||
}
|
||||
|
||||
// Why delete only conpty.node: node-pty's loader tries build/Release, then
|
||||
// build/Debug, then prebuilds/<platform>-<arch>, swallowing every failure in
|
||||
// between. Only the source build carries Orca's job-object exports, so an ABI
|
||||
// mismatch or an AV quarantine of build/Release/conpty.node would silently
|
||||
// fall through to the UNPATCHED prebuild -- teardown back to guessing by PID
|
||||
// ancestry, with no error anywhere.
|
||||
//
|
||||
// Why NOT the whole prebuilds/ tree: Orca's own patch deletes the
|
||||
// `conpty_console_list` and winpty `pty` gyp targets, so a Windows source
|
||||
// build emits conpty.node and nothing else. conpty_console_list.node,
|
||||
// pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them
|
||||
// silently kills console-membership probing (the forked agent throws at
|
||||
// require, and its caller resolves null with silent: true), and removes the
|
||||
// winpty backend that node-pty still selects below Windows build 18309.
|
||||
//
|
||||
// Why the arch check: a cross-arch package copies the host's build/Release,
|
||||
// so its mere presence does not mean it matches electronArch -- deleting the
|
||||
// target-arch prebuild would then remove the only loadable binary.
|
||||
if (
|
||||
electronPlatformName === 'win32' &&
|
||||
electronArch === process.arch &&
|
||||
existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))
|
||||
) {
|
||||
const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`)
|
||||
for (const staleFallback of ['conpty.node', 'conpty.pdb']) {
|
||||
rmSync(join(prebuildDir, staleFallback), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
pruneNodePtyNativeDirectories(
|
||||
@@ -405,12 +439,22 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electron
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedRuntimeTypeDeclarations(resourcesDir) {
|
||||
// Why type declarations: they are compile-time only; the packaged app never resolves them.
|
||||
// Why source maps: they embed the original sources (megabytes for @linear/sdk alone) and
|
||||
// nothing in the packaged app turns on Node's source-map support, so they are never read.
|
||||
// Orca's own main-process maps live outside node_modules and ship as a separate release artifact.
|
||||
function isPrunableTypeOrSourceMapArtifact(filename) {
|
||||
return TYPE_DECLARATION_ARTIFACT_RE.test(filename) || JS_SOURCE_MAP_ARTIFACT_RE.test(filename)
|
||||
}
|
||||
|
||||
// Why one walk: pruneMatchingFiles only ever deletes files, so passes over the same tree
|
||||
// commute — a second recursive traversal costs seconds for no extra deletions.
|
||||
function prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir) {
|
||||
const nodeModulesDir = join(resourcesDir, 'node_modules')
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
return
|
||||
}
|
||||
pruneMatchingFiles(nodeModulesDir, (filename) => TYPE_DECLARATION_ARTIFACT_RE.test(filename))
|
||||
pruneMatchingFiles(nodeModulesDir, isPrunableTypeOrSourceMapArtifact)
|
||||
}
|
||||
|
||||
function prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) {
|
||||
@@ -448,9 +492,10 @@ function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, ele
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedRuntimeTypeDeclarations(resourcesDir)
|
||||
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
||||
// Why before the filename walk: zod/src is deleted wholesale, so walking it first is wasted work.
|
||||
prunePackagedZodSources(resourcesDir)
|
||||
prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
|
||||
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
||||
}
|
||||
|
||||
function pruneMatchingFiles(directory, shouldPrune) {
|
||||
@@ -473,8 +518,8 @@ module.exports = {
|
||||
prunePackagedNodePty,
|
||||
prunePackagedParcelWatcher,
|
||||
prunePackagedRuntimeNodeModules,
|
||||
prunePackagedRuntimeTypeAndSourceMapArtifacts,
|
||||
prunePackagedSherpaOnnx,
|
||||
prunePackagedRuntimeTypeDeclarations,
|
||||
prunePackagedZodSources,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -15,12 +14,11 @@
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
- "include_dirs": [],
|
||||
+ "include_dirs": ["deps/node-addon-api"],
|
||||
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
+ "ExceptionHandling": 1,
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
--- a/src/process.cc
|
||||
+++ b/src/process.cc
|
||||
@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
process_info.push_back(std::move(pinfo));
|
||||
process_count++;
|
||||
}
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
+ } while (Process32Next(snapshot_handle, &process_entry));
|
||||
}
|
||||
|
||||
CloseHandle(snapshot_handle);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,125 @@
|
||||
diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js
|
||||
index 3a32d6a9cd65c7ecb6b3cbccfce5a15c96530435..363513128bf7302eab73ea775102bb8a633a3b8b 100644
|
||||
--- a/lib/gitWorkflow.js
|
||||
+++ b/lib/gitWorkflow.js
|
||||
@@ -50,6 +50,9 @@ export const STASH = 'lint-staged automatic backup'
|
||||
|
||||
const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
|
||||
|
||||
+const BACKUP_REF_PREFIX = 'refs/worktree/lint-staged-backups'
|
||||
+const ZERO_OID = '0000000000000000000000000000000000000000'
|
||||
+
|
||||
const GIT_DIFF_ARGS = [
|
||||
'--binary', // support binary files
|
||||
'--unified=0', // do not add lines around diff for consistent behaviour
|
||||
@@ -100,6 +103,8 @@ export class GitWorkflow {
|
||||
/** @type {import('./getStagedFiles.js').StagedFile[][]} */
|
||||
this.matchedFileChunks = matchedFileChunks
|
||||
this.topLevelDir = topLevelDir
|
||||
+ this.backupOid = null
|
||||
+ this.backupRef = null
|
||||
|
||||
/**
|
||||
* These three files hold state about an ongoing git merge
|
||||
@@ -122,6 +127,17 @@ export class GitWorkflow {
|
||||
* Get name of backup stash
|
||||
*/
|
||||
async getBackupStash(ctx) {
|
||||
+ if (this.backupRef) {
|
||||
+ const backupOid = await this.execGit(['rev-parse', '--verify', this.backupRef])
|
||||
+
|
||||
+ if (backupOid !== this.backupOid) {
|
||||
+ ctx.errors.add(GetBackupStashError)
|
||||
+ throw new Error('lint-staged automatic backup is missing!')
|
||||
+ }
|
||||
+
|
||||
+ return this.backupRef
|
||||
+ }
|
||||
+
|
||||
/** Print stash list with short hash and subject */
|
||||
const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z'])
|
||||
.then(parseGitZOutput)
|
||||
@@ -270,11 +286,14 @@ export class GitWorkflow {
|
||||
} else {
|
||||
/** Save stash of all changes, keeping all files as-is */
|
||||
const stashHash = await this.execGit(['stash', 'create'])
|
||||
+ this.backupOid = stashHash
|
||||
+ this.backupRef = `${BACKUP_REF_PREFIX}/${crypto.randomUUID()}`
|
||||
ctx.backupHash = await this.execGit(['rev-parse', '--short', stashHash])
|
||||
- await this.execGit(['stash', 'store', '--quiet', '--message', STASH, ctx.backupHash])
|
||||
+ await this.execGit(['update-ref', this.backupRef, this.backupOid, ZERO_OID])
|
||||
+ ctx.backupRef = this.backupRef
|
||||
}
|
||||
|
||||
- task.title = `Backed up original state in git stash (${ctx.backupHash})`
|
||||
+ task.title = `Backed up original state (${ctx.backupHash})`
|
||||
debugLog(task.title)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -425,7 +444,11 @@ export class GitWorkflow {
|
||||
async cleanup(ctx) {
|
||||
try {
|
||||
debugLog('Dropping backup stash...')
|
||||
- await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
|
||||
+ if (this.backupRef) {
|
||||
+ await this.execGit(['update-ref', '-d', this.backupRef, this.backupOid])
|
||||
+ } else {
|
||||
+ await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
|
||||
+ }
|
||||
debugLog('Done dropping backup stash!')
|
||||
} catch (error) {
|
||||
handleError(error, ctx)
|
||||
diff --git a/lib/index.js b/lib/index.js
|
||||
index 75eeacee48759ef90249df5524639d61bce10918..24b4852eba9b14e05e10c3df8a2b2dae75af19f3 100644
|
||||
--- a/lib/index.js
|
||||
+++ b/lib/index.js
|
||||
@@ -157,7 +157,7 @@ const lintStaged = async (
|
||||
logger.warn(PREVENTED_EMPTY_COMMIT)
|
||||
} else if (ctx.errors.has(FailOnChangesError)) {
|
||||
logger.warn(PREVENTED_TASK_MODIFICATIONS + '\n')
|
||||
- logger.warn(restoreStashExample(ctx.backupHash))
|
||||
+ logger.warn(restoreStashExample(ctx.backupHash, ctx.backupRef))
|
||||
} else if (ctx.errors.has(RestoreUnstagedChangesError)) {
|
||||
logger.warn(UNSTAGED_CHANGES_BACKUP_STASH_LOCATION)
|
||||
logger.warn(ctx.unstagedPatch)
|
||||
@@ -168,7 +168,7 @@ const lintStaged = async (
|
||||
logger.error(GIT_ERROR)
|
||||
if (ctx.shouldBackup) {
|
||||
// No sense to show this if the backup stash itself is missing.
|
||||
- logger.error(restoreStashExample(ctx.backupHash) + '\n')
|
||||
+ logger.error(restoreStashExample(ctx.backupHash, ctx.backupRef) + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/lib/messages.js b/lib/messages.js
|
||||
index 993f8d81cac48132b5ae2ebdc8ca452c7217caad..2dc2291bc82451eacc5babfc8ce408051e1101e3 100644
|
||||
--- a/lib/messages.js
|
||||
+++ b/lib/messages.js
|
||||
@@ -66,9 +66,12 @@ export const PREVENTED_EMPTY_COMMIT = `
|
||||
Use the --allow-empty option to continue, or check your task configuration`)}
|
||||
`
|
||||
|
||||
-export const restoreStashExample = (
|
||||
- hash = 'h0a0s0h0'
|
||||
-) => `Any lost modifications can be restored from a git stash:
|
||||
+export const restoreStashExample = (hash = 'h0a0s0h0', backupRef) =>
|
||||
+ backupRef
|
||||
+ ? `Any lost modifications can be restored from the worktree backup:
|
||||
+
|
||||
+ > git stash apply --index ${backupRef}`
|
||||
+ : `Any lost modifications can be restored from a git stash:
|
||||
|
||||
> git stash list --format="%h %s"
|
||||
${hash} On main: lint-staged automatic backup
|
||||
diff --git a/lib/state.js b/lib/state.js
|
||||
index da30e6f639d31307ebf503691aa991bb93948f8a..4a38799fa7773a8e8629c7c3a6b72287e6b22f4b 100644
|
||||
--- a/lib/state.js
|
||||
+++ b/lib/state.js
|
||||
@@ -15,6 +15,7 @@ export const getInitialState = ({
|
||||
revert = true,
|
||||
} = {}) => ({
|
||||
backupHash: null,
|
||||
+ backupRef: null,
|
||||
errors: new Set([]),
|
||||
shouldFailOnChanges: failOnChanges,
|
||||
hasFilesToHide: null,
|
||||
@@ -2,8 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -5,9 +5,6 @@
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
|
||||
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
|
||||
+ 'cflags_cc!': [
|
||||
+ '-std=gnu++20'
|
||||
+ ],
|
||||
+ 'cflags_cc': [
|
||||
+ '-std=gnu++2a'
|
||||
+ ],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
@@ -12,6 +25,39 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc83
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': {
|
||||
'AdditionalOptions': [
|
||||
@@ -42,32 +39,6 @@
|
||||
'-lshlwapi'
|
||||
],
|
||||
},
|
||||
- {
|
||||
- 'target_name': 'conpty_console_list',
|
||||
- 'sources' : [
|
||||
- 'src/win/conpty_console_list.cc'
|
||||
- ],
|
||||
- },
|
||||
- {
|
||||
- 'target_name': 'pty',
|
||||
- 'include_dirs' : [
|
||||
- '<!(node -p "require(\'node-addon-api\').include_dir")',
|
||||
- 'deps/winpty/src/include',
|
||||
- ],
|
||||
- # Disabled due to winpty
|
||||
- 'msvs_disabled_warnings': [ 4506, 4530 ],
|
||||
- 'dependencies' : [
|
||||
- 'deps/winpty/src/winpty.gyp:winpty-agent',
|
||||
- 'deps/winpty/src/winpty.gyp:winpty',
|
||||
- ],
|
||||
- 'sources' : [
|
||||
- 'src/win/winpty.cc',
|
||||
- 'src/win/path_util.cc'
|
||||
- ],
|
||||
- 'libraries': [
|
||||
- '-lshlwapi'
|
||||
- ],
|
||||
- }
|
||||
]
|
||||
}, { # OS!="win"
|
||||
'targets': [
|
||||
@@ -88,6 +85,16 @@
|
||||
'libraries!': [
|
||||
'-lutil'
|
||||
@@ -35,7 +81,7 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
|
||||
+++ b/deps/winpty/src/winpty.gyp
|
||||
@@ -10,7 +10,7 @@
|
||||
# make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++"
|
||||
|
||||
|
||||
'variables': {
|
||||
- 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && GetCommitHash.bat")',
|
||||
+ 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && .\\GetCommitHash.bat")',
|
||||
@@ -116,7 +162,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd
|
||||
--- a/src/conpty_console_list_agent.ts
|
||||
+++ b/src/conpty_console_list_agent.ts
|
||||
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
|
||||
|
||||
|
||||
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
const shellPid = parseInt(process.argv[2], 10);
|
||||
-const consoleProcessList = getConsoleProcessList(shellPid);
|
||||
@@ -141,12 +187,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
#include <unistd.h>
|
||||
+#include <string>
|
||||
#include <thread>
|
||||
|
||||
|
||||
#include <sys/types.h>
|
||||
@@ -47,6 +49,25 @@
|
||||
#include <termios.h>
|
||||
#endif
|
||||
|
||||
|
||||
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
|
||||
+ * under new symbol versions, so building on a newer glibc produces references
|
||||
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
|
||||
@@ -171,7 +217,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
#define VWERASE VWERSE
|
||||
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(__APPLE__) || defined(__OpenBSD__)
|
||||
+struct pty_spawn_error {
|
||||
+ const char* step;
|
||||
@@ -192,12 +238,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
- int* err);
|
||||
+ pty_spawn_error* err);
|
||||
#endif
|
||||
|
||||
|
||||
struct DelBuf {
|
||||
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
|
||||
argv[i + 3] = strdup(arg.c_str());
|
||||
}
|
||||
|
||||
|
||||
- int err = -1;
|
||||
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
|
||||
- if (err != 0) {
|
||||
@@ -212,7 +258,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
|
||||
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(__APPLE__)
|
||||
+static const char*
|
||||
+pty_errno_name(int errnum) {
|
||||
@@ -283,7 +329,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ bool acts_initialized = false;
|
||||
+ posix_spawnattr_t attrs;
|
||||
+ bool attrs_initialized = false;
|
||||
|
||||
|
||||
for (; count < 3; count++) {
|
||||
low_fds[count] = posix_openpt(O_RDWR);
|
||||
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
|
||||
@@ -294,7 +340,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ pty_set_spawn_error(err, "posix_openpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
|
||||
- int res = grantpt(*master) || unlockpt(*master);
|
||||
+ res = grantpt(*master);
|
||||
if (res == -1) {
|
||||
@@ -308,7 +354,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ pty_set_spawn_error(err, "unlockpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
|
||||
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
|
||||
- int slave;
|
||||
char slave_pty_name[128];
|
||||
@@ -318,14 +364,14 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
|
||||
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
|
||||
if (slave == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
|
||||
if (termp) {
|
||||
res = tcsetattr(slave, TCSANOW, termp);
|
||||
if (res == -1) {
|
||||
@@ -334,7 +380,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ goto done;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (winp) {
|
||||
res = ioctl(slave, TIOCSWINSZ, winp);
|
||||
if (res == -1) {
|
||||
@@ -343,7 +389,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ goto done;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
- posix_spawn_file_actions_t acts;
|
||||
- posix_spawn_file_actions_init(&acts);
|
||||
+ res = posix_spawn_file_actions_init(&acts);
|
||||
@@ -357,7 +403,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
|
||||
posix_spawn_file_actions_addclose(&acts, slave);
|
||||
posix_spawn_file_actions_addclose(&acts, *master);
|
||||
|
||||
|
||||
- posix_spawnattr_t attrs;
|
||||
- posix_spawnattr_init(&attrs);
|
||||
- *err = posix_spawnattr_setflags(&attrs, flags);
|
||||
@@ -373,7 +419,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
|
||||
sigset_t signal_set;
|
||||
/* Reset all signal the child to their default behavior */
|
||||
sigfillset(&signal_set);
|
||||
@@ -384,7 +430,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
|
||||
/* Reset the signal mask for all signals */
|
||||
sigemptyset(&signal_set);
|
||||
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
|
||||
@@ -394,7 +440,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
|
||||
do
|
||||
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
|
||||
- while (*err == EINTR);
|
||||
@@ -419,7 +465,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
+ close(*master);
|
||||
+ *master = -1;
|
||||
+ }
|
||||
|
||||
|
||||
- for (; count > 0; count--) {
|
||||
- close(low_fds[count]);
|
||||
+ for (size_t i = 0; i <= count && i < 3; i++) {
|
||||
@@ -429,3 +475,514 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
|
||||
}
|
||||
}
|
||||
#endif
|
||||
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
|
||||
index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a6a4082ce 100644
|
||||
--- a/src/win/conpty.cc
|
||||
+++ b/src/win/conpty.cc
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
+#include <mutex>
|
||||
#include <vector>
|
||||
#include <Windows.h>
|
||||
#include <strsafe.h>
|
||||
@@ -44,12 +45,29 @@ struct pty_baton {
|
||||
HANDLE hOut;
|
||||
HPCON hpc;
|
||||
|
||||
- HANDLE hShell;
|
||||
+ HANDLE hShell = nullptr;
|
||||
+ // Orca: the shell's pid, captured at spawn. The ownership guard compares
|
||||
+ // against this rather than calling GetProcessId(hShell), because the exit
|
||||
+ // watcher closes hShell on another thread -- reading it there is an
|
||||
+ // invalid-handle operation, and under strict handle checks that is fatal.
|
||||
+ DWORD shellPid = 0;
|
||||
+
|
||||
+ // Orca: job object owning this pty's whole process tree. Null when the OS
|
||||
+ // refused to create or assign one (an outer job without breakaway rights),
|
||||
+ // in which case callers fall back to their pre-job behaviour.
|
||||
+ HANDLE hJob = nullptr;
|
||||
|
||||
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
|
||||
};
|
||||
|
||||
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
|
||||
+// Orca: guards the job accessors below against the exit watcher thread. It does
|
||||
+// NOT make the whole table safe -- PtyResize/PtyClear/PtyKill read it unlocked,
|
||||
+// as they always have -- but it closes the window this patch opened, where the
|
||||
+// watcher can close hShell/hJob and free the baton between a lookup and its use.
|
||||
+// Handle VALUES are recycled aggressively, so an unguarded read could pass the
|
||||
+// shell-pid check against an unrelated process and terminate the wrong job.
|
||||
+static std::mutex ptyJobMutex;
|
||||
static volatile LONG ptyCounter;
|
||||
|
||||
static pty_baton* get_pty_baton(int id) {
|
||||
@@ -102,8 +120,27 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
|
||||
// Get process exit code.
|
||||
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
|
||||
// Clean up handles
|
||||
- CloseHandle(baton->hShell);
|
||||
- assert(remove_pty_baton(baton->id));
|
||||
+ // Orca: release the job once the shell is gone. Without kill-on-close this
|
||||
+ // only frees the handle -- anything the user backgrounded is orphaned, as
|
||||
+ // it was before this patch.
|
||||
+ {
|
||||
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
|
||||
+ CloseHandle(baton->hShell);
|
||||
+ baton->hShell = nullptr;
|
||||
+ if (baton->hJob != nullptr) {
|
||||
+ CloseHandle(baton->hJob);
|
||||
+ baton->hJob = nullptr;
|
||||
+ }
|
||||
+ // Why inside the lock: erasing frees the baton the job accessors hold a
|
||||
+ // pointer to. Note remove_pty_baton must not be an assert() argument --
|
||||
+ // NDEBUG would compile the call away and leak every baton.
|
||||
+ const bool removed = remove_pty_baton(baton->id);
|
||||
+ assert(removed);
|
||||
+ (void)removed;
|
||||
+ }
|
||||
+ // Why the lock ends here: BlockingCall below waits on the JS thread, and the
|
||||
+ // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding
|
||||
+ // the lock across it deadlocks. Do not widen this scope.
|
||||
|
||||
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
|
||||
switch (status) {
|
||||
@@ -409,6 +446,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
|
||||
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
|
||||
}
|
||||
|
||||
+ // Orca: resolve the DLL BEFORE creating anything. It throws when conpty.dll
|
||||
+ // is missing -- a real state, and one this branch hit during development --
|
||||
+ // and every throw between CreateProcessW and SetupExitCallback leaks the job,
|
||||
+ // process and thread handles AND leaves an untracked shell tree running,
|
||||
+ // once per attempt. Validating first means the only throw after creation is
|
||||
+ // the resume failure, which cleans up after itself.
|
||||
+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
|
||||
+ bool fLoadedDll = hLibrary != nullptr;
|
||||
+
|
||||
PROCESS_INFORMATION piClient{};
|
||||
fSuccess = !!CreateProcessW(
|
||||
nullptr,
|
||||
@@ -416,7 +462,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
|
||||
nullptr, // lpProcessAttributes
|
||||
nullptr, // lpThreadAttributes
|
||||
false, // bInheritHandles VERY IMPORTANT that this is false
|
||||
- EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags
|
||||
+ // Orca: CREATE_SUSPENDED so the shell is inside its job before it can
|
||||
+ // spawn anything. Assigning after the fact leaves a window in which a
|
||||
+ // fast child escapes the job and outlives the pane.
|
||||
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, // dwCreationFlags
|
||||
envArg, // lpEnvironment
|
||||
mutableCwd.get(), // lpCurrentDirectory
|
||||
&siEx.StartupInfo, // lpStartupInfo
|
||||
@@ -426,8 +475,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
|
||||
throw errorWithCode(info, "Cannot create process");
|
||||
}
|
||||
|
||||
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
|
||||
- bool fLoadedDll = hLibrary != nullptr;
|
||||
+ // Orca: own the tree with a handle instead of inferring it later from a
|
||||
+ // parent-pid walk. A pid walk cannot survive pid reuse and cannot see a
|
||||
+ // descendant that reparented, which is why detached agent children outlived
|
||||
+ // their pane and held the worktree directory open.
|
||||
+ //
|
||||
+ // Deliberately WITHOUT JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Measured on
|
||||
+ // Windows 11: with that flag, closing the handle when the shell exits also
|
||||
+ // kills whatever the user left running, so typing `exit` in a pane reaped a
|
||||
+ // `start /b` server that used to survive. This job exists to make an
|
||||
+ // EXPLICIT teardown exact, not to redefine what a clean exit means.
|
||||
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
|
||||
+ if (hJob != nullptr) {
|
||||
+ // Why BREAKAWAY_OK and not a bare job: with no limits set, a child asking
|
||||
+ // for CREATE_BREAKAWAY_FROM_JOB is refused with ERROR_ACCESS_DENIED.
|
||||
+ // Installers, msiexec and some updater and service-control paths spawn that
|
||||
+ // way deliberately, so a bare job breaks them ONLY inside an Orca terminal.
|
||||
+ // With this flag a child has to ask, so ordinary descendants stay owned.
|
||||
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{};
|
||||
+ jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
+ if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) ||
|
||||
+ !AssignProcessToJobObject(hJob, piClient.hProcess)) {
|
||||
+ // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK
|
||||
+ // (some EDR and container hosts) refuses the assignment. The pty must
|
||||
+ // still start; ownership just degrades to the older best-effort path.
|
||||
+ CloseHandle(hJob);
|
||||
+ hJob = nullptr;
|
||||
+ }
|
||||
+ }
|
||||
+ // Safe to run now: either it is in the job, or we accepted that it is not.
|
||||
+ if (ResumeThread(piClient.hThread) == static_cast<DWORD>(-1)) {
|
||||
+ // Why fatal: a shell left suspended produces a pane that never prints and
|
||||
+ // never exits, which is far harder to diagnose than a failed spawn.
|
||||
+ if (hJob != nullptr) {
|
||||
+ CloseHandle(hJob);
|
||||
+ }
|
||||
+ TerminateProcess(piClient.hProcess, 1);
|
||||
+ CloseHandle(piClient.hProcess);
|
||||
+ CloseHandle(piClient.hThread);
|
||||
+ throw errorWithCode(info, "Cannot resume process");
|
||||
+ }
|
||||
+
|
||||
if (useConptyDll && fLoadedDll)
|
||||
{
|
||||
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
|
||||
@@ -440,6 +528,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
|
||||
|
||||
// Update handle
|
||||
handle->hShell = piClient.hProcess;
|
||||
+ handle->shellPid = piClient.dwProcessId;
|
||||
+ handle->hJob = hJob;
|
||||
|
||||
// Close the thread handle to avoid resource leak
|
||||
CloseHandle(piClient.hThread);
|
||||
@@ -567,6 +657,143 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
+/**
|
||||
+ * Orca: confirm a baton really is the pty the caller means.
|
||||
+ *
|
||||
+ * The winpty backend mints its own `pty` ids from a separate counter, and the
|
||||
+ * JS layer stores both in the same field -- so a winpty terminal's id can
|
||||
+ * collide with a live ConPTY baton here and terminate an unrelated pane's whole
|
||||
+ * process tree. Matching the shell pid makes the id unforgeable.
|
||||
+ */
|
||||
+static bool ownsShell(const pty_baton* handle, DWORD expectedShellPid) {
|
||||
+ return handle != nullptr && handle->hJob != nullptr && expectedShellPid != 0 &&
|
||||
+ handle->shellPid == expectedShellPid;
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Orca: kill this pty's entire tree in one syscall.
|
||||
+ *
|
||||
+ * Replaces "scrape the process table, walk parent pids, hope none were
|
||||
+ * recycled, then taskkill /T /F". Returns false when no job was assigned so
|
||||
+ * the caller knows to fall back rather than assume the tree is gone.
|
||||
+ */
|
||||
+static Napi::Value PtyTerminateJob(const Napi::CallbackInfo& info) {
|
||||
+ Napi::Env env(info.Env());
|
||||
+ Napi::HandleScope scope(env);
|
||||
+
|
||||
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
|
||||
+ throw Napi::Error::New(env, "Usage: pty.terminateJob(id, shellPid)");
|
||||
+ }
|
||||
+
|
||||
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
|
||||
+ // close these handles and free the baton in between.
|
||||
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
|
||||
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
|
||||
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
|
||||
+ return Napi::Boolean::New(env, false);
|
||||
+ }
|
||||
+ return Napi::Boolean::New(env, !!TerminateJobObject(handle->hJob, 1));
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Orca: the pids still alive in this pty's tree, straight from the kernel.
|
||||
+ *
|
||||
+ * Descendant liveness for a tree that is still tracked, including children that
|
||||
+ * detached from the console. Once the shell exits the baton is gone, so this
|
||||
+ * returns null rather than an empty list -- null means "no answer", never
|
||||
+ * "they died". Also returns null when no job was assigned.
|
||||
+ *
|
||||
+ * Does not include the ConPTY console host: CreatePseudoConsole spawns it
|
||||
+ * before this job exists, so it is not a member and ClosePseudoConsole is what
|
||||
+ * reaps it.
|
||||
+ */
|
||||
+static Napi::Value PtyListJobProcessIds(const Napi::CallbackInfo& info) {
|
||||
+ Napi::Env env(info.Env());
|
||||
+ Napi::HandleScope scope(env);
|
||||
+
|
||||
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
|
||||
+ throw Napi::Error::New(env, "Usage: pty.listJobProcessIds(id, shellPid)");
|
||||
+ }
|
||||
+
|
||||
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
|
||||
+ // close these handles and free the baton in between.
|
||||
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
|
||||
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
|
||||
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
|
||||
+ return env.Null();
|
||||
+ }
|
||||
+
|
||||
+ // Grow until the buffer holds every pid: the count can change between calls,
|
||||
+ // and a truncated list would read as "these children are gone".
|
||||
+ DWORD capacity = 64;
|
||||
+ for (int attempt = 0; attempt < 8; attempt++) {
|
||||
+ const size_t bytes = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST) + sizeof(ULONG_PTR) * capacity;
|
||||
+ std::vector<char> buffer(bytes, 0);
|
||||
+ auto* list = reinterpret_cast<JOBOBJECT_BASIC_PROCESS_ID_LIST*>(buffer.data());
|
||||
+ if (QueryInformationJobObject(handle->hJob, JobObjectBasicProcessIdList, list, static_cast<DWORD>(bytes), nullptr)) {
|
||||
+ auto pids = Napi::Array::New(env, list->NumberOfProcessIdsInList);
|
||||
+ for (DWORD i = 0; i < list->NumberOfProcessIdsInList; i++) {
|
||||
+ pids.Set(i, Napi::Number::New(env, static_cast<double>(list->ProcessIdList[i])));
|
||||
+ }
|
||||
+ return pids;
|
||||
+ }
|
||||
+ if (GetLastError() != ERROR_MORE_DATA) {
|
||||
+ return env.Null();
|
||||
+ }
|
||||
+ capacity *= 4;
|
||||
+ }
|
||||
+ return env.Null();
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Orca: put THIS process in a kill-on-close job, so its whole descendant tree
|
||||
+ * dies with it.
|
||||
+ *
|
||||
+ * Why here and not per-pty: a per-pty job cannot carry KILL_ON_JOB_CLOSE,
|
||||
+ * because its handle is released when the shell exits and that would reap
|
||||
+ * whatever the user had backgrounded. This job's handle is released only when
|
||||
+ * the process itself dies, so it reaps a crashed host without changing what a
|
||||
+ * clean shell exit means. Children inherit job membership, so every pty the
|
||||
+ * caller later spawns is covered without further work, and the per-pty jobs
|
||||
+ * simply nest inside this one.
|
||||
+ *
|
||||
+ * The handle is deliberately never closed: it must outlive every caller, and
|
||||
+ * process teardown is what releases it.
|
||||
+ */
|
||||
+static Napi::Value PtyAssignCurrentProcessToJob(const Napi::CallbackInfo& info) {
|
||||
+ Napi::Env env(info.Env());
|
||||
+ Napi::HandleScope scope(env);
|
||||
+
|
||||
+ // Why locked: two callers racing here would each create a job, put the
|
||||
+ // process in both, and leak the first handle -- and since the handle is what
|
||||
+ // keeps a kill-on-close job alive, a leaked one is never released. A worker
|
||||
+ // thread with its own N-API env shares these statics, so "only JS calls it"
|
||||
+ // is not a guarantee.
|
||||
+ static HANDLE hHostJob = nullptr;
|
||||
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
|
||||
+ if (hHostJob != nullptr) {
|
||||
+ return Napi::Boolean::New(env, true);
|
||||
+ }
|
||||
+
|
||||
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
|
||||
+ if (job == nullptr) {
|
||||
+ return Napi::Boolean::New(env, false);
|
||||
+ }
|
||||
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
||||
+ // BREAKAWAY_OK for the same reason as the per-pty job: without it a child
|
||||
+ // asking for CREATE_BREAKAWAY_FROM_JOB is refused outright.
|
||||
+ limits.BasicLimitInformation.LimitFlags =
|
||||
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) ||
|
||||
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
+ // An outer job that forbids nesting refuses this; the caller degrades.
|
||||
+ CloseHandle(job);
|
||||
+ return Napi::Boolean::New(env, false);
|
||||
+ }
|
||||
+ hHostJob = job;
|
||||
+ return Napi::Boolean::New(env, true);
|
||||
+}
|
||||
+
|
||||
/**
|
||||
* Init
|
||||
*/
|
||||
@@ -577,6 +804,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
|
||||
exports.Set("resize", Napi::Function::New(env, PtyResize));
|
||||
exports.Set("clear", Napi::Function::New(env, PtyClear));
|
||||
exports.Set("kill", Napi::Function::New(env, PtyKill));
|
||||
+ exports.Set("terminateJob", Napi::Function::New(env, PtyTerminateJob));
|
||||
+ exports.Set("listJobProcessIds", Napi::Function::New(env, PtyListJobProcessIds));
|
||||
+ exports.Set("assignCurrentProcessToJob", Napi::Function::New(env, PtyAssignCurrentProcessToJob));
|
||||
return exports;
|
||||
};
|
||||
|
||||
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
|
||||
index a358ffb..fb3a96f 100644
|
||||
--- a/lib/windowsPtyAgent.js
|
||||
+++ b/lib/windowsPtyAgent.js
|
||||
@@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () {
|
||||
if (this._useConpty) {
|
||||
if (!this._useConptyDll) {
|
||||
this._inSocket.readable = false;
|
||||
+ // The non-DLL path previously only flipped `readable`, leaving the
|
||||
+ // conin PipeWrap alive until the host exited (#947).
|
||||
+ this._inSocket.destroy();
|
||||
this._outSocket.readable = false;
|
||||
this._getConsoleProcessList().then(function (consoleProcessList) {
|
||||
consoleProcessList.forEach(function (pid) {
|
||||
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
|
||||
index 3c38f89..e20b3e6 100644
|
||||
--- a/lib/windowsTerminal.js
|
||||
+++ b/lib/windowsTerminal.js
|
||||
@@ -50,6 +50,27 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
||||
// Create new termal.
|
||||
_this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
|
||||
_this._socket = _this._agent.outSocket;
|
||||
+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.
|
||||
+ _this._socket.on('error', function (err) {
|
||||
+ var code = err && err.code;
|
||||
+ // PTY output can report EPIPE before `_close()` wins the race.
|
||||
+ _this._close();
|
||||
+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {
|
||||
+ return;
|
||||
+ }
|
||||
+ // EIO, happens when someone closes our child process: the only process
|
||||
+ // in the terminal.
|
||||
+ // node < 0.6.14: errno 5
|
||||
+ // node >= 0.6.14: read EIO
|
||||
+ if (typeof code === 'string') {
|
||||
+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))
|
||||
+ return;
|
||||
+ }
|
||||
+ // Throw anything else.
|
||||
+ if (_this.listeners('error').length < 2) {
|
||||
+ throw err;
|
||||
+ }
|
||||
+ });
|
||||
// Not available until `ready` event emitted.
|
||||
_this._pid = _this._agent.innerPid;
|
||||
_this._fd = _this._agent.fd;
|
||||
@@ -76,23 +99,6 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
||||
_this._deferreds = [];
|
||||
}
|
||||
});
|
||||
- // Shutdown if `error` event is emitted.
|
||||
- _this._socket.on('error', function (err) {
|
||||
- // Close terminal session.
|
||||
- _this._close();
|
||||
- // EIO, happens when someone closes our child process: the only process
|
||||
- // in the terminal.
|
||||
- // node < 0.6.14: errno 5
|
||||
- // node >= 0.6.14: read EIO
|
||||
- if (err.code) {
|
||||
- if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))
|
||||
- return;
|
||||
- }
|
||||
- // Throw anything else.
|
||||
- if (_this.listeners('error').length < 2) {
|
||||
- throw err;
|
||||
- }
|
||||
- });
|
||||
// Cleanup after the socket is closed.
|
||||
_this._socket.on('close', function () {
|
||||
_this.emit('exit', _this._agent.exitCode);
|
||||
@@ -103,6 +109,20 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
||||
_this._name = name;
|
||||
_this._readable = true;
|
||||
_this._writable = true;
|
||||
+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes
|
||||
+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.
|
||||
+ _this._agent.inSocket.on('error', function () {
|
||||
+ if (!_this._writable) {
|
||||
+ return;
|
||||
+ }
|
||||
+ _this._close();
|
||||
+ try {
|
||||
+ _this._agent.kill();
|
||||
+ }
|
||||
+ catch (_a) {
|
||||
+ // The failing pipe may have raced process exit; the terminal is already unwritable.
|
||||
+ }
|
||||
+ });
|
||||
_this._forwardEvents();
|
||||
return _this;
|
||||
}
|
||||
@@ -196,4 +216,4 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
||||
return WindowsTerminal;
|
||||
}(terminal_1.Terminal));
|
||||
exports.WindowsTerminal = WindowsTerminal;
|
||||
-//# sourceMappingURL=windowsTerminal.js.map
|
||||
\ No newline at end of file
|
||||
+//# sourceMappingURL=windowsTerminal.js.map
|
||||
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
|
||||
index d705444..ce611b8 100644
|
||||
--- a/src/windowsPtyAgent.ts
|
||||
+++ b/src/windowsPtyAgent.ts
|
||||
@@ -143,6 +143,9 @@ export class WindowsPtyAgent {
|
||||
if (this._useConpty) {
|
||||
if (!this._useConptyDll) {
|
||||
this._inSocket.readable = false;
|
||||
+ // The non-DLL path previously only flipped `readable`, leaving the
|
||||
+ // conin PipeWrap alive until the host exited (#947).
|
||||
+ this._inSocket.destroy();
|
||||
this._outSocket.readable = false;
|
||||
this._getConsoleProcessList().then(consoleProcessList => {
|
||||
consoleProcessList.forEach((pid: number) => {
|
||||
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
|
||||
index 13f6c6d..eda63c8 100644
|
||||
--- a/src/windowsTerminal.ts
|
||||
+++ b/src/windowsTerminal.ts
|
||||
@@ -51,6 +51,30 @@ export class WindowsTerminal extends Terminal {
|
||||
this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
|
||||
this._socket = this._agent.outSocket;
|
||||
-
|
||||
+
|
||||
+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.
|
||||
+ this._socket.on('error', err => {
|
||||
+ const code = (<any>err).code;
|
||||
+
|
||||
+ // PTY output can report EPIPE before `_close()` wins the race.
|
||||
+ this._close();
|
||||
+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // EIO, happens when someone closes our child process: the only process
|
||||
+ // in the terminal.
|
||||
+ // node < 0.6.14: errno 5
|
||||
+ // node >= 0.6.14: read EIO
|
||||
+ if (typeof code === 'string') {
|
||||
+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO')) return;
|
||||
+ }
|
||||
+
|
||||
+ // Throw anything else.
|
||||
+ if (this.listeners('error').length < 2) {
|
||||
+ throw err;
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
// Not available until `ready` event emitted.
|
||||
this._pid = this._agent.innerPid;
|
||||
this._fd = this._agent.fd;
|
||||
@@ -82,25 +108,6 @@ export class WindowsTerminal extends Terminal {
|
||||
}
|
||||
});
|
||||
-
|
||||
+
|
||||
- // Shutdown if `error` event is emitted.
|
||||
- this._socket.on('error', err => {
|
||||
- // Close terminal session.
|
||||
- this._close();
|
||||
-
|
||||
- // EIO, happens when someone closes our child process: the only process
|
||||
- // in the terminal.
|
||||
- // node < 0.6.14: errno 5
|
||||
- // node >= 0.6.14: read EIO
|
||||
- if ((<any>err).code) {
|
||||
- if (~(<any>err).code.indexOf('errno 5') || ~(<any>err).code.indexOf('EIO')) return;
|
||||
- }
|
||||
-
|
||||
- // Throw anything else.
|
||||
- if (this.listeners('error').length < 2) {
|
||||
- throw err;
|
||||
- }
|
||||
- });
|
||||
-
|
||||
// Cleanup after the socket is closed.
|
||||
this._socket.on('close', () => {
|
||||
this.emit('exit', this._agent.exitCode);
|
||||
@@ -114,6 +121,19 @@ export class WindowsTerminal extends Terminal {
|
||||
-
|
||||
+
|
||||
this._readable = true;
|
||||
this._writable = true;
|
||||
+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes
|
||||
+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.
|
||||
+ this._agent.inSocket.on('error', () => {
|
||||
+ if (!this._writable) {
|
||||
+ return;
|
||||
+ }
|
||||
+ this._close();
|
||||
+ try {
|
||||
+ this._agent.kill();
|
||||
+ } catch {
|
||||
+ // The failing pipe may have raced process exit; the terminal is already unwritable.
|
||||
+ }
|
||||
+ });
|
||||
-
|
||||
+
|
||||
this._forwardEvents();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts
|
||||
index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b01fdff0a 100644
|
||||
--- a/src/SerializeAddon.ts
|
||||
+++ b/src/SerializeAddon.ts
|
||||
@@ -12,6 +12,36 @@ import { IAttributeData } from 'common/buffer/Types';
|
||||
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
|
||||
import { UnderlineStyle } from 'common/buffer/Constants';
|
||||
|
||||
+type OscLinkData = { id?: string; uri: string };
|
||||
+type OscLinkedCell = { extended?: { urlId?: number } };
|
||||
+type TerminalWithOscLinks = Terminal & {
|
||||
+ _core?: {
|
||||
+ _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };
|
||||
+ _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };
|
||||
+ };
|
||||
+};
|
||||
+
|
||||
+function getOscLinkId(cell: IBufferCell | IAttributeData): number {
|
||||
+ return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;
|
||||
+}
|
||||
+
|
||||
+function getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {
|
||||
+ if (!linkId) {
|
||||
+ return '';
|
||||
+ }
|
||||
+ const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);
|
||||
+ if (!data) {
|
||||
+ return '';
|
||||
+ }
|
||||
+ const params = data.id === undefined ? '' : `id=${data.id}`;
|
||||
+ return `\u001b]8;${params};${data.uri}\u001b\\`;
|
||||
+}
|
||||
+
|
||||
+function getActiveOscLinkSequence(terminal: Terminal): string {
|
||||
+ const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;
|
||||
+ return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';
|
||||
+}
|
||||
+
|
||||
function constrain(value: number, low: number, high: number): number {
|
||||
return Math.max(low, Math.min(value, high));
|
||||
}
|
||||
@@ -148,12 +178,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
|
||||
// this is a null cell for reference for checking whether background is empty or not
|
||||
private _backgroundCell: IBufferCell = this._buffer.getNullCell();
|
||||
+ private _defaultCell: IBufferCell = this._buffer.getNullCell();
|
||||
|
||||
private _firstRow: number = 0;
|
||||
private _lastCursorRow: number = 0;
|
||||
private _lastCursorCol: number = 0;
|
||||
private _lastContentCursorRow: number = 0;
|
||||
private _lastContentCursorCol: number = 0;
|
||||
+ private _activeOscLinkId: number = 0;
|
||||
|
||||
constructor(
|
||||
buffer: IBuffer,
|
||||
@@ -214,7 +246,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
if (
|
||||
// you must output character to cause overflow, control sequence can't do this
|
||||
nextRowFirstChar.getChars() &&
|
||||
- isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0
|
||||
+ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)
|
||||
) {
|
||||
if (
|
||||
// the last character can't be null,
|
||||
@@ -251,9 +283,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
if (this._nullCellCount > 0) {
|
||||
// do these because we filled the last several null slot, which we shouldn't
|
||||
rowSeparator += '\u001b[A';
|
||||
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`;
|
||||
+ const contentCellCount = currentLine.length - this._nullCellCount;
|
||||
+ if (contentCellCount > 0) {
|
||||
+ rowSeparator += `\u001b[${contentCellCount}C`;
|
||||
+ }
|
||||
rowSeparator += `\u001b[${this._nullCellCount}X`;
|
||||
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`;
|
||||
+ if (contentCellCount > 0) {
|
||||
+ rowSeparator += `\u001b[${contentCellCount}D`;
|
||||
+ }
|
||||
rowSeparator += '\u001b[B';
|
||||
}
|
||||
|
||||
@@ -310,7 +347,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
if (flagsChanged) {
|
||||
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
|
||||
- if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
|
||||
+ // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so
|
||||
+ // they must be diffed as one intensity group with the clearing 22 emitted
|
||||
+ // BEFORE any re-set. Upstream's independent per-flag diff could emit
|
||||
+ // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB
|
||||
+ // loses B's bold on round-trip) or a bare "22" that drops a still-set
|
||||
+ // bold/dim, garbling Orca's hidden-terminal snapshot restores.
|
||||
+ const boldChanged = cell.isBold() !== oldCell.isBold();
|
||||
+ const dimChanged = cell.isDim() !== oldCell.isDim();
|
||||
+ if (boldChanged || dimChanged) {
|
||||
+ const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());
|
||||
+ if (clearsIntensity) { sgrSeq.push(22); }
|
||||
+ if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }
|
||||
+ if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }
|
||||
+ }
|
||||
if (!equalUnderline(cell, oldCell)) {
|
||||
const style = cell.getUnderlineStyle();
|
||||
if (style === UnderlineStyle.NONE) {
|
||||
@@ -337,7 +387,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }
|
||||
if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }
|
||||
if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }
|
||||
- if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); }
|
||||
+ // PATCH(orca): dim handled in the intensity group above.
|
||||
if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }
|
||||
}
|
||||
}
|
||||
@@ -346,6 +396,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
return sgrSeq;
|
||||
}
|
||||
|
||||
+ private _setOscLink(linkId: number): string {
|
||||
+ if (linkId === this._activeOscLinkId) {
|
||||
+ return '';
|
||||
+ }
|
||||
+ let sequence = this._activeOscLinkId ? '\u001b]8;;\u001b\\' : '';
|
||||
+ this._activeOscLinkId = 0;
|
||||
+ const openSequence = getOscLinkOpenSequence(this._terminal, linkId);
|
||||
+ if (openSequence) {
|
||||
+ this._activeOscLinkId = linkId;
|
||||
+ sequence += openSequence;
|
||||
+ }
|
||||
+ return sequence;
|
||||
+ }
|
||||
+
|
||||
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
|
||||
// a width 0 cell don't need to be count because it is just a placeholder after a CJK character;
|
||||
const isPlaceHolderCell = cell.getWidth() === 0;
|
||||
@@ -356,12 +420,21 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
|
||||
// this cell don't have content
|
||||
const isEmptyCell = cell.getChars() === '';
|
||||
+ const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;
|
||||
+ const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);
|
||||
+ // A pending wide glyph recreates its own final-column padding during replay.
|
||||
+ const isWideWrapPadding = col === this._terminal.cols - 1 &&
|
||||
+ nextLine?.isWrapped &&
|
||||
+ (nextRowFirstCell?.getWidth() ?? 0) > 1 &&
|
||||
+ !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);
|
||||
+ // Cursor movement cannot reproduce an inverse cell's visible background.
|
||||
+ const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;
|
||||
|
||||
const sgrSeq = this._diffStyle(cell, this._cursorStyle);
|
||||
|
||||
- // the empty cell style is only assumed to be changed when background changed, because
|
||||
- // foreground is always 0.
|
||||
- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;
|
||||
+ const styleChanged = isEmptyCell
|
||||
+ ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)
|
||||
+ : sgrSeq.length > 0;
|
||||
|
||||
/**
|
||||
* handles style change
|
||||
@@ -395,7 +468,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
/**
|
||||
* handles actual content
|
||||
*/
|
||||
- if (isEmptyCell) {
|
||||
+ if (isEmptyCell && !materializeEmptyCell) {
|
||||
this._nullCellCount += cell.getWidth();
|
||||
} else {
|
||||
if (this._nullCellCount > 0) {
|
||||
@@ -411,7 +484,22 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
this._nullCellCount = 0;
|
||||
}
|
||||
|
||||
- this._currentRow += cell.getChars();
|
||||
+ // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.
|
||||
+ this._currentRow += this._setOscLink(getOscLinkId(cell));
|
||||
+
|
||||
+ if (materializeEmptyCell) {
|
||||
+ const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();
|
||||
+ if (hasDecoration) {
|
||||
+ this._currentRow += '\u001b[24;29;55m';
|
||||
+ }
|
||||
+ this._currentRow += ' '.repeat(cell.getWidth());
|
||||
+ if (hasDecoration) {
|
||||
+ const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);
|
||||
+ this._currentRow += `\u001b[0m\u001b[${restoreSgrSeq.join(';')}m`;
|
||||
+ }
|
||||
+ } else {
|
||||
+ this._currentRow += cell.getChars();
|
||||
+ }
|
||||
|
||||
// update cursor
|
||||
this._lastContentCursorRow = this._lastCursorRow = row;
|
||||
@@ -439,6 +527,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
+ // Each buffer is self-contained so links cannot leak into a following buffer.
|
||||
+ content += this._setOscLink(0);
|
||||
+
|
||||
// restore the cursor
|
||||
if (!excludeFinalCursorPosition) {
|
||||
const realCursorRow = this._buffer.baseY + this._buffer.cursorY;
|
||||
@@ -616,6 +707,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi {
|
||||
content += this._serializeScrollRegion(this._terminal);
|
||||
}
|
||||
|
||||
+ // Restore the source terminal's live OSC pen only after all buffers are complete.
|
||||
+ content += getActiveOscLinkSequence(this._terminal);
|
||||
+
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
|
||||
index 742f0879ff4f04509e4a07c8efdf0d5743fe8ee5..885a206a394fff783737a412c0b75bf935dd0eca 100644
|
||||
--- a/src/GlyphRenderer.ts
|
||||
+++ b/src/GlyphRenderer.ts
|
||||
@@ -61,6 +61,8 @@ function createFragmentShaderSource(maxFragmentShaderTextureUnits: number): stri
|
||||
for (let i = 1; i < maxFragmentShaderTextureUnits; i++) {
|
||||
textureConditionals += ` else if (v_texpage == ${i}) { outColor = texture(u_texture[${i}], v_texcoord); }`;
|
||||
}
|
||||
+ // A v_texpage beyond the sampler budget matches no branch above. Leaving outColor unwritten
|
||||
+ // is undefined behaviour in GLSL ES and paints garbage, so fall through to transparent.
|
||||
return (`#version 300 es
|
||||
precision lowp float;
|
||||
|
||||
@@ -74,7 +76,7 @@ out vec4 outColor;
|
||||
void main() {
|
||||
if (v_texpage == 0) {
|
||||
outColor = texture(u_texture[0], v_texcoord);
|
||||
- } ${textureConditionals}
|
||||
+ } ${textureConditionals} else { outColor = vec4(0.0, 0.0, 0.0, 0.0); }
|
||||
}`);
|
||||
}
|
||||
|
||||
diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts
|
||||
index 4977ad741065e26bbfd35bc50e7558a033b13340..f55805ddc3f266415f7f8e81e5b8c318e7db194c 100644
|
||||
--- a/src/TextureAtlas.ts
|
||||
+++ b/src/TextureAtlas.ts
|
||||
@@ -3,6 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
+import { FontWeight } from '@xterm/xterm';
|
||||
import { IColorContrastCache } from 'browser/Types';
|
||||
import { DIM_OPACITY, TEXT_BASELINE } from './Constants';
|
||||
import { tryDrawCustomGlyph } from './customGlyphs/CustomGlyphRasterizer';
|
||||
@@ -135,21 +136,23 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
private _pageLayoutVersion = 0;
|
||||
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
|
||||
|
||||
+ // Orca diagnostics: read by terminal-render-desync-weight-probe.ts to tell a real
|
||||
+ // bold-collapse from a repaint problem. Canvas silently keeps its previous font when an
|
||||
+ // assignment fails to parse, which rasterizes glyphs at a stale weight.
|
||||
+ public fontProbeMismatchCount = 0;
|
||||
+ public fontProbeLastMismatch: { desired: string, actual: string } | undefined;
|
||||
+
|
||||
public clearTexture(): void {
|
||||
- if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
|
||||
+ // Guard on every page rather than pages[0]: a merged page is never written through
|
||||
+ // currentRow, so once one lands at index 0 the old check made every later clear a no-op.
|
||||
+ if (this._pages.every(page => page.glyphs.length === 0 && page.currentRow.x === 0 && page.currentRow.y === 0)) {
|
||||
return;
|
||||
}
|
||||
- for (const page of this._pages) {
|
||||
- page.clear();
|
||||
- }
|
||||
- this._cacheMap.clear();
|
||||
- this._cacheMapCombined.clear();
|
||||
- this._didWarmUp = false;
|
||||
-
|
||||
- // Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in
|
||||
- // which case the clearing renderer has cleared only its own model and every other owner still
|
||||
- // holds texture coords into the rows just wiped.
|
||||
- this._pageLayoutVersion++;
|
||||
+ // Return the atlas to its constructor state instead of clearing in place: page.clear() leaves
|
||||
+ // page.glyphs populated, which would keep the guard above from ever firing again. Eviction
|
||||
+ // also bumps _pageLayoutVersion, so every renderer sharing this atlas rebuilds its model.
|
||||
+ this._evictAllPages();
|
||||
+ this._createNewPage();
|
||||
}
|
||||
|
||||
private _createNewPage(): AtlasPage {
|
||||
@@ -465,6 +468,36 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
return this._config.colors.contrastCache;
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * Orca diagnostic. Canvas ignores a font assignment it cannot parse and silently keeps the
|
||||
+ * previous value, so a bad family or weight rasterizes every glyph at a stale weight. Record
|
||||
+ * the mismatch rather than correcting it: the goal is to tell that failure apart from a
|
||||
+ * repaint bug when a terminal renders bold-collapsed.
|
||||
+ */
|
||||
+ private _probeRasterizationFontWeight(fontWeight: FontWeight): void {
|
||||
+ const desired = String(fontWeight);
|
||||
+ // Only numeric weights are comparable; keywords round-trip through Canvas unchanged.
|
||||
+ if (!/^(?:[1-8]\d{2}|900)$/.test(desired)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ // Canvas normalizes font serialization: Chromium omits 400 and emits the keyword bold for 700.
|
||||
+ const token = this._tmpCtx.font.match(
|
||||
+ /(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/
|
||||
+ )?.[1] ?? '400';
|
||||
+ const actual = token === 'normal' ? '400' : token === 'bold' ? '700' : token;
|
||||
+ if (actual === desired) {
|
||||
+ return;
|
||||
+ }
|
||||
+ this.fontProbeMismatchCount++;
|
||||
+ this.fontProbeLastMismatch = { desired, actual: this._tmpCtx.font };
|
||||
+ try {
|
||||
+ (globalThis as { __orcaAtlasFontProbe?: (mismatch: { desired: string, actual: string }) => void })
|
||||
+ .__orcaAtlasFontProbe?.(this.fontProbeLastMismatch);
|
||||
+ } catch {
|
||||
+ // Diagnostics only; a throwing listener must never break rasterization.
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
|
||||
const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars;
|
||||
|
||||
@@ -536,6 +569,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
const fontStyle = italic ? 'italic' : '';
|
||||
this._tmpCtx.font =
|
||||
`${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
|
||||
+ this._probeRasterizationFontWeight(fontWeight);
|
||||
this._tmpCtx.textBaseline = TEXT_BASELINE;
|
||||
|
||||
const powerlineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0));
|
||||
diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts
|
||||
index a951efba5c75e82735cd39b6b22c4b5d5fad5928..e7f80c3a8c14dd65a16a97f1d17e3da1d65ed8bf 100644
|
||||
--- a/src/WebglRenderer.ts
|
||||
+++ b/src/WebglRenderer.ts
|
||||
@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
// page's version, so re-run the update and force a full texture rebind.
|
||||
let merged = false;
|
||||
let mergeRetries = 0;
|
||||
- while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
|
||||
+ // Test the retry budget before beginFrame: beginFrame latches the page layout version it
|
||||
+ // observed, so tripping the limit after consuming it would strand a stale model with no
|
||||
+ // later frame able to notice it needs rebuilding.
|
||||
+ while (this._charAtlas && mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()) {
|
||||
merged = true;
|
||||
this._clearModel(true);
|
||||
this._updateModel(0, this._terminal.rows - 1);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
|
||||
"upstream": {
|
||||
"repository": "https://github.com/xtermjs/xterm.js.git",
|
||||
"commit": "d3e32b344dfe7dd6015cff6a9aeaaeaeccdc2789",
|
||||
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
|
||||
},
|
||||
"sourcemaps": {
|
||||
"policy": "include",
|
||||
"why": "terminal-ime-xterm-transaction-events.test.ts reads lib/*.map and asserts the mapped Version.ts matches the runtime version, so the maps have to move with the bundles rather than be dropped. Patching them costs ~5.8MB of the emitted patch; the alternative, deleting them, is only available again once nothing reads them."
|
||||
},
|
||||
"toolchain": {
|
||||
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
|
||||
"esbuild": "0.28.1",
|
||||
"webpack": "5.107.0",
|
||||
"terser": "5.47.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260521.1"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"name": "@xterm/xterm",
|
||||
"version": "6.1.0-beta.303",
|
||||
"packageDir": ".",
|
||||
"versionStampFile": "src/common/Version.ts",
|
||||
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch",
|
||||
"patch": "config/patches/@xterm__xterm@6.1.0-beta.303.patch",
|
||||
"generatedPaths": ["lib/"],
|
||||
"build": [
|
||||
{
|
||||
"cwd": ".",
|
||||
"command": "npm",
|
||||
"args": ["run", "package"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@xterm/addon-webgl",
|
||||
"version": "0.20.0-beta.299",
|
||||
"packageDir": "addons/addon-webgl",
|
||||
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
|
||||
"sourcePatch": "config/patches/xterm-src/@xterm__addon-webgl@0.20.0-beta.299.src.patch",
|
||||
"patch": "config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch",
|
||||
"generatedPaths": ["lib/"],
|
||||
"build": [
|
||||
{
|
||||
"cwd": "../..",
|
||||
"command": "npm",
|
||||
"args": ["run", "build"]
|
||||
},
|
||||
{
|
||||
"cwd": ".",
|
||||
"command": "npm",
|
||||
"args": ["run", "package"]
|
||||
},
|
||||
{
|
||||
"cwd": "../..",
|
||||
"command": "npm",
|
||||
"args": ["run", "esbuild-package"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@xterm/addon-serialize",
|
||||
"version": "0.15.0-beta.300",
|
||||
"packageDir": "addons/addon-serialize",
|
||||
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
|
||||
"sourcePatch": "config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch",
|
||||
"patch": "config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch",
|
||||
"generatedPaths": ["lib/"],
|
||||
"build": [
|
||||
{
|
||||
"cwd": "../..",
|
||||
"command": "npm",
|
||||
"args": ["run", "build"]
|
||||
},
|
||||
{
|
||||
"cwd": ".",
|
||||
"command": "npm",
|
||||
"args": ["run", "package"]
|
||||
},
|
||||
{
|
||||
"cwd": "../..",
|
||||
"command": "npm",
|
||||
"args": ["run", "esbuild-package"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"forbiddenBuildScripts": {
|
||||
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. The packaging scripts are `package` and `esbuild-package`; nothing here may run a development pass after them.",
|
||||
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
|
||||
}
|
||||
}
|
||||
+7595
-512
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
# Modules reachable from the Orca runtime that import `electron`.
|
||||
# Generated by config/scripts/check-runtime-electron-ratchet.mjs.
|
||||
# This list is EMPTY and must stay that way: the runtime boots on plain Node
|
||||
# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;
|
||||
# migrate the module behind a host port instead (src/main/host/).
|
||||
|
||||
@@ -95,6 +95,66 @@ describe('benchmark artifact comparison', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('compares terminal split headline metrics in milliseconds', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'split-baseline.json', {
|
||||
label: 'split baseline',
|
||||
headlineMs: {
|
||||
shortcutToFocusP50: 284.2,
|
||||
shortcutToFocusP95: 676.3
|
||||
}
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'split-candidate.json', {
|
||||
label: 'split candidate',
|
||||
headlineMs: {
|
||||
shortcutToFocusP50: 12.7,
|
||||
shortcutToFocusP95: 13.7
|
||||
}
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
|
||||
expect(comparison.baseline.kind).toBe('terminal-split-activation')
|
||||
expect(comparison.metrics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'shortcutToFocusP50',
|
||||
unit: 'ms',
|
||||
baseline: 284.2,
|
||||
candidate: 12.7,
|
||||
status: 'improved'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'shortcutToFocusP95',
|
||||
unit: 'ms',
|
||||
baseline: 676.3,
|
||||
candidate: 13.7,
|
||||
status: 'improved'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid benchmark artifacts before comparing partial metrics', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'split-invalid.json', {
|
||||
label: 'invalid split',
|
||||
status: 'failed',
|
||||
valid: false,
|
||||
headlineMs: { shortcutToFocusP50: 0 }
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'split-valid.json', {
|
||||
label: 'valid split',
|
||||
status: 'passed',
|
||||
valid: true,
|
||||
headlineMs: { shortcutToFocusP50: 10 }
|
||||
})
|
||||
|
||||
expect(() => comparePaths(baselinePath, candidatePath)).toThrow(
|
||||
'split-invalid.json: benchmark artifact is marked invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const BENCHMARK_SAMPLE_AGGREGATION = Object.freeze({
|
||||
version: 2,
|
||||
median: 'average-middle',
|
||||
p95: 'nearest-rank',
|
||||
p95Role: 'descriptive',
|
||||
p95LinearDriftBoundLaunchSlots: 1
|
||||
})
|
||||
|
||||
export function summarizeBenchmarkSamples(samples) {
|
||||
if (samples.length === 0) {
|
||||
throw new Error('Benchmark samples must not be empty')
|
||||
}
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
const middle = sorted.length / 2
|
||||
const median = Number.isInteger(middle)
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[Math.floor(middle)]
|
||||
const p95 = sorted[Math.ceil(0.95 * sorted.length) - 1]
|
||||
|
||||
return {
|
||||
samples: samples.length,
|
||||
medianMs: Number(median.toFixed(1)),
|
||||
p95Ms: Number(p95.toFixed(1)),
|
||||
minMs: Number(sorted[0].toFixed(1)),
|
||||
maxMs: Number(sorted.at(-1).toFixed(1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
|
||||
describe('benchmark sample summary', () => {
|
||||
it('averages the two middle samples for an even-sized median', () => {
|
||||
expect(summarizeBenchmarkSamples([100, 1, 2, 99]).medianMs).toBe(50.5)
|
||||
})
|
||||
|
||||
it('selects the middle sample for an odd-sized median', () => {
|
||||
expect(summarizeBenchmarkSamples([100, 1, 2, 99, 3]).medianMs).toBe(3)
|
||||
})
|
||||
|
||||
it('preserves nearest-rank p95 and range reporting', () => {
|
||||
expect(summarizeBenchmarkSamples([1, 2, 3, 4, 5, 6])).toEqual({
|
||||
samples: 6,
|
||||
medianMs: 3.5,
|
||||
p95Ms: 6,
|
||||
minMs: 1,
|
||||
maxMs: 6
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty samples', () => {
|
||||
expect(() => summarizeBenchmarkSamples([])).toThrow('must not be empty')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = path.join(repoRoot, 'native', 'keyboard-layout-macos', 'main.swift')
|
||||
const defaultOutputPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'keyboard-layout-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'orca-keyboard-layout'
|
||||
)
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath
|
||||
const singleArch = args.includes('--single-arch')
|
||||
const workDir = mkdtempSync(path.join(tmpdir(), 'orca-keyboard-layout-'))
|
||||
|
||||
try {
|
||||
const triples = singleArch
|
||||
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
|
||||
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
|
||||
const builtBinaries = triples.map((triple) => {
|
||||
const output = path.join(workDir, `orca-keyboard-layout-${triple}`)
|
||||
execFileSync(
|
||||
'swiftc',
|
||||
[
|
||||
'-O',
|
||||
sourcePath,
|
||||
'-target',
|
||||
triple.replace('-apple-macosx', '-apple-macosx11.0'),
|
||||
'-o',
|
||||
output
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
return output
|
||||
})
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
if (builtBinaries.length === 1) {
|
||||
execFileSync('cp', [builtBinaries[0], outputPath])
|
||||
} else {
|
||||
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
|
||||
}
|
||||
chmodSync(outputPath, 0o755)
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index === -1 ? undefined : args[index + 1]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
|
||||
@@ -13,18 +14,13 @@ if (process.platform !== 'darwin') {
|
||||
}
|
||||
|
||||
runPnpmScript('build:computer-macos')
|
||||
runPnpmScript('build:keyboard-layout-macos')
|
||||
runPnpmScript('build:notification-status-macos')
|
||||
process.exit(0)
|
||||
|
||||
function runPnpmScript(scriptName) {
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const command = npmExecPath
|
||||
? process.execPath
|
||||
: process.platform === 'win32'
|
||||
? 'pnpm.cmd'
|
||||
: 'pnpm'
|
||||
const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName]
|
||||
const result = spawnSync(command, args, { stdio: 'inherit' })
|
||||
const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
|
||||
const result = spawnSync(command, [...prefixArgs, 'run', scriptName], { stdio: 'inherit', shell })
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
|
||||
@@ -79,7 +79,7 @@ try {
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index >= 0 ? args[index + 1] : undefined
|
||||
return index !== -1 ? args[index + 1] : undefined
|
||||
}
|
||||
|
||||
function embeddedInfoPlist(identifier) {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's
|
||||
* prebuilds matrix, so a deployment target needs no C/C++ toolchain.
|
||||
*
|
||||
* node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in
|
||||
* this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix:
|
||||
* `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that
|
||||
* keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces
|
||||
* #9902. So the matrix is compiled from patched sources here, and this script refuses to
|
||||
* run if the patch is not in the tree it is about to compile.
|
||||
*
|
||||
* orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies
|
||||
* only platform/arch/libc:
|
||||
* linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl,
|
||||
* darwin-x64, darwin-arm64
|
||||
*
|
||||
* CI runs this once per slot, each inside the container that owns that libc/arch, and
|
||||
* merges the resulting `out/orcad/prebuilds` trees. `--slot=<name>` forces the label so
|
||||
* the glibc/musl distinction is recorded from the container rather than detected.
|
||||
*
|
||||
* Usage:
|
||||
* node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
|
||||
* node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')
|
||||
|
||||
/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
|
||||
export const MATRIX_SLOTS = [
|
||||
'linux-x64-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-x64-musl',
|
||||
'linux-arm64-musl',
|
||||
'darwin-x64',
|
||||
'darwin-arm64'
|
||||
]
|
||||
|
||||
/**
|
||||
* Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the
|
||||
* process is linked against glibc, and musl images have no `ldd` worth parsing.
|
||||
*/
|
||||
export function detectLibc(platform = process.platform, header = readReportHeader()) {
|
||||
if (platform !== 'linux') {
|
||||
return 'none'
|
||||
}
|
||||
return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
|
||||
}
|
||||
|
||||
function readReportHeader() {
|
||||
try {
|
||||
return process.report?.getReport?.()?.header
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) {
|
||||
const forced = argv.find((arg) => arg.startsWith('--slot='))
|
||||
if (forced) {
|
||||
return forced.slice('--slot='.length)
|
||||
}
|
||||
const libc = detectLibc(platform)
|
||||
return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary
|
||||
* that loads fine on the build host and dies on the target — the exact failure the matrix
|
||||
* exists to prevent, now baked into a shipped artifact instead of a first-connect error.
|
||||
*/
|
||||
export function assertNodePtyPatchApplied(nodePtyDir) {
|
||||
const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8')
|
||||
const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8')
|
||||
const missing = []
|
||||
if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) {
|
||||
missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag")
|
||||
}
|
||||
if (!ptySource.includes('.symver openpty,openpty@')) {
|
||||
missing.push('src/unix/pty.cc is missing the .symver glibc pins')
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.',
|
||||
...missing.map((line) => ` - ${line}`),
|
||||
'A prebuilt compiled without it will not load on Ubuntu 20.04 (see',
|
||||
'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function readManifest(prebuildsDir) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why merge rather than overwrite: CI builds one slot per container and merges the trees.
|
||||
* A manifest that records only the last slot would erase every other container's record,
|
||||
* and `--require-slots` would then reject a complete matrix.
|
||||
*/
|
||||
export function mergeManifest(existing, next) {
|
||||
const slots = new Set([...(existing?.slots ?? []), next.slot])
|
||||
return {
|
||||
module: 'node-pty',
|
||||
version: next.version,
|
||||
nodeAbi: next.nodeAbi,
|
||||
slots: [...slots].sort()
|
||||
}
|
||||
}
|
||||
|
||||
function nodePtyDir() {
|
||||
return dirname(require.resolve('node-pty/package.json'))
|
||||
}
|
||||
|
||||
function compileNodePty(dir) {
|
||||
const built = join(dir, 'build', 'Release', 'pty.node')
|
||||
if (existsSync(built)) {
|
||||
console.log(`[orcad-prebuilds] reusing existing build at ${built}`)
|
||||
return built
|
||||
}
|
||||
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['node-gyp', 'rebuild'],
|
||||
{
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
|
||||
}
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`)
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
function requireSlots() {
|
||||
const manifest = readManifest(PREBUILDS_DIR)
|
||||
const have = new Set(manifest?.slots ?? [])
|
||||
const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot))
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` +
|
||||
'Hosts on those slots fall back to a source build and need a C/C++ toolchain.'
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`)
|
||||
}
|
||||
|
||||
function build() {
|
||||
const dir = nodePtyDir()
|
||||
assertNodePtyPatchApplied(dir)
|
||||
const slot = slotName()
|
||||
const slotDir = join(PREBUILDS_DIR, slot)
|
||||
mkdirSync(slotDir, { recursive: true })
|
||||
|
||||
const builtBinary = compileNodePty(dir)
|
||||
copyFileSync(builtBinary, join(slotDir, 'pty.node'))
|
||||
console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
|
||||
|
||||
// Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
|
||||
// so a slot without it installs cleanly and then fails ENOENT the first time a user
|
||||
// opens a terminal. Windows has no spawn-helper.
|
||||
if (process.platform !== 'win32') {
|
||||
const helperSource = join(dirname(builtBinary), 'spawn-helper')
|
||||
if (!existsSync(helperSource)) {
|
||||
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
|
||||
}
|
||||
copyFileSync(helperSource, join(slotDir, 'spawn-helper'))
|
||||
console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`)
|
||||
}
|
||||
|
||||
// The static floor gate, applied to the artifact we are about to ship rather than only
|
||||
// to the packaged desktop app. objdump is Linux-only, which is where the floor lives.
|
||||
if (process.platform === 'linux') {
|
||||
const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
|
||||
verifyLinuxGlibcFloor(slotDir)
|
||||
}
|
||||
|
||||
const manifest = mergeManifest(readManifest(PREBUILDS_DIR), {
|
||||
slot,
|
||||
version: require('node-pty/package.json').version,
|
||||
nodeAbi: process.versions.modules
|
||||
})
|
||||
writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(
|
||||
`[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) {
|
||||
if (process.argv.includes('--require-slots')) {
|
||||
requireSlots()
|
||||
} else {
|
||||
build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertNodePtyPatchApplied,
|
||||
detectLibc,
|
||||
MATRIX_SLOTS,
|
||||
mergeManifest,
|
||||
readManifest,
|
||||
slotName
|
||||
} from './build-orcad-prebuilds.mjs'
|
||||
|
||||
const PATCHED_BINDING_GYP =
|
||||
"'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
|
||||
|
||||
const dirs = []
|
||||
const stage = (bindingGyp, ptyCc) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-'))
|
||||
dirs.push(dir)
|
||||
mkdirSync(join(dir, 'src', 'unix'), { recursive: true })
|
||||
writeFileSync(join(dir, 'binding.gyp'), bindingGyp)
|
||||
writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc)
|
||||
return dir
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('assertNodePtyPatchApplied', () => {
|
||||
it('accepts a tree with both halves of the glibc-floor fix', () => {
|
||||
expect(() =>
|
||||
assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC))
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses to build when the ldflags half is missing', () => {
|
||||
// The .symver pins alone let gcc's --as-needed drop libutil/libpthread from
|
||||
// DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again,
|
||||
// this time baked into a shipped prebuilt.
|
||||
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow(
|
||||
/--no-as-needed,-l:libutil\.so\.1/
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to build when the .symver pins are missing', () => {
|
||||
expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow(
|
||||
/\.symver glibc pins/
|
||||
)
|
||||
})
|
||||
|
||||
it('names the patch and the doc so the fix is findable', () => {
|
||||
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow(
|
||||
/config\/patches\/node-pty@1\.1\.0\.patch/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot naming', () => {
|
||||
it('covers every platform orcad ships to', () => {
|
||||
expect([...MATRIX_SLOTS].sort()).toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64-glibc',
|
||||
'linux-arm64-musl',
|
||||
'linux-x64-glibc',
|
||||
'linux-x64-musl'
|
||||
])
|
||||
})
|
||||
|
||||
it('lets CI force the label so the container decides glibc vs musl', () => {
|
||||
// Detection inside a container that happens to run a differently-linked Node would
|
||||
// file the build under the wrong slot. The forced label must beat detection outright,
|
||||
// so assert against one detection could never produce for this platform/arch.
|
||||
expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe(
|
||||
'linux-x64-glibc'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the libc dimension off Linux', () => {
|
||||
expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64')
|
||||
})
|
||||
|
||||
it('reads glibc from the report header and musl from its absence', () => {
|
||||
expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc')
|
||||
expect(detectLibc('linux', {})).toBe('musl')
|
||||
expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeManifest', () => {
|
||||
it('accumulates slots across the per-container CI runs that build them', () => {
|
||||
// Overwriting would erase every other container's record, and the release gate would
|
||||
// then reject a matrix that is actually complete.
|
||||
const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' })
|
||||
const second = mergeManifest(first, {
|
||||
slot: 'linux-arm64-musl',
|
||||
version: '1.1.0',
|
||||
nodeAbi: '127'
|
||||
})
|
||||
|
||||
expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc'])
|
||||
expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' })
|
||||
})
|
||||
|
||||
it('does not duplicate a slot rebuilt twice', () => {
|
||||
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
|
||||
expect(
|
||||
mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
|
||||
).toEqual(['darwin-arm64'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readManifest', () => {
|
||||
it('returns null instead of throwing when no matrix has been built', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-'))
|
||||
dirs.push(dir)
|
||||
expect(readManifest(dir)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bundle `orcad` — the Orca runtime served from plain Node, no Electron.
|
||||
*
|
||||
* Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and
|
||||
* speech clusters are excluded. That is not a size optimisation — those modules are
|
||||
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
|
||||
* the host Node floor at 18 instead of 22.5+.
|
||||
*/
|
||||
import { fork, spawnSync } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { arch, platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
ORCAD_VERSION,
|
||||
ORCAD_VERSION_FILENAME,
|
||||
orcadArtifactFilenames
|
||||
} from '../../src/shared/orcad-artifacts.ts'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const OUT_DIR = join(ROOT, 'out', 'orcad')
|
||||
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
|
||||
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
|
||||
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
|
||||
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
|
||||
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
|
||||
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
|
||||
// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
|
||||
// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
|
||||
// orcad restart would SIGKILL every running terminal.
|
||||
const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
|
||||
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
|
||||
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
|
||||
const OUT_FILE = join(OUT_DIR, 'orcad.js')
|
||||
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
|
||||
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
|
||||
|
||||
// Native addons must exist on the host; they cannot be bundled.
|
||||
// `electron` is external so a residual import fails loudly at require() time rather
|
||||
// than silently bundling the npm package's installer shim, which is what happened the
|
||||
// first time and made the bundle look clean while it was not.
|
||||
// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
|
||||
// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
|
||||
// server boots without it but every watch install fails. `fsevents` is macOS-only and
|
||||
// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
|
||||
// defensively and appear nowhere in the graph; listing them implied a shipping burden
|
||||
// that does not exist.
|
||||
const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
|
||||
|
||||
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
|
||||
const jsoncParserEsm = {
|
||||
name: 'jsonc-parser-esm',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
|
||||
path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Why: optional native deps reference prebuilt .node files that may not exist here. */
|
||||
const externalNativeAddons = {
|
||||
name: 'external-native-addons',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
|
||||
}
|
||||
|
||||
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
|
||||
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
|
||||
* their runtime resolvers look for them. */
|
||||
function buildForkedChild(entryPoint, outfile) {
|
||||
return build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile,
|
||||
external: EXTERNAL,
|
||||
plugins: [externalNativeAddons],
|
||||
metafile: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
const childResults = await Promise.all([
|
||||
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
|
||||
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
|
||||
])
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: OUT_FILE,
|
||||
external: EXTERNAL,
|
||||
plugins: [jsoncParserEsm, externalNativeAddons],
|
||||
metafile: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'error'
|
||||
})
|
||||
|
||||
const output = Object.values(result.metafile.outputs).find(
|
||||
(o) => o.entryPoint === 'src/main/orcad/main.ts'
|
||||
)
|
||||
// Why check `original` and not just `path`: when electron is bundleable, esbuild
|
||||
// rewrites `path` to the resolved file under node_modules and the naive check passes
|
||||
// while the package is very much in the bundle.
|
||||
// Why both metafiles: the forked children ship in the same deployment and run under the
|
||||
// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
|
||||
// path whose whole point is that terminals survive.
|
||||
function collectImporters(metafiles, matches) {
|
||||
const importers = new Set()
|
||||
for (const metafile of metafiles) {
|
||||
for (const [file, info] of Object.entries(metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
if (matches(imported.original ?? imported.path)) {
|
||||
importers.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return importers
|
||||
}
|
||||
|
||||
const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
|
||||
const electronImporters = collectImporters(
|
||||
metafiles,
|
||||
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
|
||||
)
|
||||
const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite')
|
||||
|
||||
const graphErrors = []
|
||||
if (electronImporters.size > 0) {
|
||||
graphErrors.push(
|
||||
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
if (sqliteImporters.size > 0) {
|
||||
graphErrors.push(
|
||||
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (graphErrors.length > 0) {
|
||||
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
|
||||
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
|
||||
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
|
||||
// install the PTY controller. Once orcad ships, it should become a ratchet entry
|
||||
// point so the two numbers cannot drift.
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
// Why smoke-load and not just read the metafile: the import scan proves no module
|
||||
// *names* electron, but a graph can still fail to resolve under plain Node — a
|
||||
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
|
||||
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
|
||||
// because it is an esbuild artifact rather than a rollup input.
|
||||
// Why an exit code and not a message match: these bundles are minified onto one line, so
|
||||
// Node's uncaught-exception report echoes that whole line — which contains every string
|
||||
// literal in the bundle. A crash therefore "matches" any expected message, and a textual
|
||||
// assertion passes against a bundle that never loaded.
|
||||
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000
|
||||
})
|
||||
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
|
||||
if (smoke.error || smoke.signal || smoke.status !== 0) {
|
||||
console.error(
|
||||
`[build-orcad] the bundle did not load under plain Node.\n` +
|
||||
`Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
|
||||
`signal=${smoke.signal ?? 'none'} ` +
|
||||
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
// Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
|
||||
// top-level import, and calling its exported argv parser proves the entry's own code is
|
||||
// there rather than a graph that merely resolved. Booting one would need a socket, a
|
||||
// token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad.
|
||||
// The verdict is carried by the exit code for the same minification reason as above.
|
||||
const daemonSmoke = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` +
|
||||
`if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` +
|
||||
`try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }`
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' }
|
||||
}
|
||||
)
|
||||
const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
|
||||
if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
|
||||
console.error(
|
||||
`[build-orcad] the daemon child did not load under plain Node.\n` +
|
||||
`Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
|
||||
`signal=${daemonSmoke.signal ?? 'none'} ` +
|
||||
`error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
const watcherFailure = await smokeLoadWatcherChild()
|
||||
if (watcherFailure) {
|
||||
console.error(
|
||||
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
|
||||
)
|
||||
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
|
||||
// bytes while reporting the new version.
|
||||
if (process.exitCode !== 1) {
|
||||
const hash = createHash('sha256')
|
||||
for (const filename of orcadArtifactFilenames()) {
|
||||
const artifactPath = join(OUT_DIR, filename)
|
||||
if (!existsSync(artifactPath)) {
|
||||
throw new Error(
|
||||
`orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` +
|
||||
'step, or drop it from src/shared/orcad-artifacts.ts.'
|
||||
)
|
||||
}
|
||||
hash.update(readFileSync(artifactPath))
|
||||
}
|
||||
const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
|
||||
writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
|
||||
console.log(
|
||||
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the shipped watcher child and drive one message through it.
|
||||
*
|
||||
* Why a real fork and not existsSync: the file being present says nothing about whether
|
||||
* its graph resolves under plain Node, and this child is only ever reached through
|
||||
* `fork()` at runtime — a broken one degrades silently to in-process watching.
|
||||
* `subscribe-started` is acked before the native module is touched, so this passes on a
|
||||
* build machine with no compiled @parcel/watcher.
|
||||
*/
|
||||
async function smokeLoadWatcherChild() {
|
||||
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
|
||||
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
|
||||
}, 30_000)
|
||||
const settle = (failure) => {
|
||||
clearTimeout(timer)
|
||||
resolve(failure)
|
||||
}
|
||||
child.on('message', (message) => {
|
||||
if (message?.op === 'subscribe-started') {
|
||||
child.disconnect()
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => settle(`fork failed: ${error.message}`))
|
||||
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
|
||||
// or a signal here is a load failure rather than a clean teardown.
|
||||
child.on('exit', (code, signal) =>
|
||||
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
|
||||
)
|
||||
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
|
||||
})
|
||||
} finally {
|
||||
rmSync(probeDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
+146
-30
@@ -10,8 +10,24 @@
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
RELAY_BUILD_PLATFORMS,
|
||||
RELAY_VERSION_FILENAME,
|
||||
RELAY_WINDOWS_PROCESS_TREE_FILENAME,
|
||||
relayOptionalArtifactFilenames,
|
||||
isWindowsRelayPlatform,
|
||||
relayArtifactFilenames
|
||||
} from '../../src/shared/relay-artifacts.ts'
|
||||
|
||||
const __dirname = import.meta.dirname
|
||||
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
|
||||
@@ -19,6 +35,13 @@ const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
|
||||
const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
'main',
|
||||
'native-chat',
|
||||
'wsl-transcript-fs-process-entry.ts'
|
||||
)
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
@@ -34,20 +57,50 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
|
||||
'relay-assets',
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
|
||||
)
|
||||
// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a
|
||||
// Windows machine.
|
||||
const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'win32-x64',
|
||||
'win32-arm64'
|
||||
]
|
||||
// Which Windows arches must have the addon, as a comma-separated list ('all' for
|
||||
// every arch). Per-arch rather than a flag because arm64 needs the MSVC ARM64
|
||||
// cross toolset, an optional VS component: where it is absent that relay should
|
||||
// fall back to the scan, not fail the release the x64 relay is riding on.
|
||||
const REQUIRED_ADDON_ARCHES = (process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
function stageWindowsProcessTreeAddon(platform, outDir) {
|
||||
if (!isWindowsRelayPlatform(platform)) {
|
||||
return
|
||||
}
|
||||
const arch = platform.slice('win32-'.length)
|
||||
const source = join(WINDOWS_PROCESS_TREE_BUILD_DIR, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
|
||||
if (!existsSync(source)) {
|
||||
if (REQUIRED_ADDON_ARCHES.includes(arch) || REQUIRED_ADDON_ARCHES.includes('all')) {
|
||||
throw new Error(
|
||||
`Relay ${platform} needs ${source}. Run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch} (Windows only).`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`Relay ${platform}: no ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}; relay will use the PowerShell scan.`
|
||||
)
|
||||
return
|
||||
}
|
||||
copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME))
|
||||
}
|
||||
|
||||
// Why: lets the packaging contract test build into a temp tree instead of
|
||||
// clobbering a developer's out/relay or racing tests that read it.
|
||||
const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay')
|
||||
|
||||
const RELAY_VERSION = '0.1.0'
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const outDir = join(ROOT, 'out', 'relay', platform)
|
||||
for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
const outDir = join(OUT_ROOT, platform)
|
||||
// Why: a stale companion left by an earlier build would otherwise satisfy the
|
||||
// manifest check and be hashed into .version, shipping mixed-generation bytes.
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
await build({
|
||||
@@ -67,12 +120,13 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
if (platform.startsWith('win32-')) {
|
||||
if (isWindowsRelayPlatform(platform)) {
|
||||
copyFileSync(
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
|
||||
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
|
||||
)
|
||||
}
|
||||
stageWindowsProcessTreeAddon(platform, outDir)
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
@@ -104,6 +158,23 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
// Why beside the service: the spawn resolves this child next to its own
|
||||
// bundle, and a relay host has no desktop out/main to fall back to.
|
||||
await build({
|
||||
entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'),
|
||||
external: ['electron'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
@@ -121,24 +192,47 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: include a content hash so the deploy check detects code changes
|
||||
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
// Why: include a content hash so the deploy check detects code changes even
|
||||
// when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a
|
||||
// companion-only change still selects a fresh immutable relay directory.
|
||||
const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(aiVaultServiceContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
|
||||
for (const filename of expected) {
|
||||
const artifactPath = join(outDir, filename)
|
||||
if (!existsSync(artifactPath)) {
|
||||
throw new Error(
|
||||
`Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` +
|
||||
'Add the build step, or drop it from src/shared/relay-artifacts.ts.'
|
||||
)
|
||||
}
|
||||
hash.update(readFileSync(artifactPath))
|
||||
}
|
||||
// Why hashed only when present: a relay carrying the native addon answers
|
||||
// differently from one that falls back to the scan, so the two must not share
|
||||
// an immutable directory -- but a build without it is still valid.
|
||||
for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) {
|
||||
const artifactPath = join(outDir, filename)
|
||||
if (existsSync(artifactPath)) {
|
||||
hash.update(readFileSync(artifactPath))
|
||||
}
|
||||
}
|
||||
const contentHash = hash.digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
|
||||
|
||||
// Close the loop: an artifact emitted here but absent from the manifest would
|
||||
// ship unhashed and unprobed — exactly how the WSL helper went missing.
|
||||
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
|
||||
const declared = [
|
||||
...expected,
|
||||
...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))
|
||||
]
|
||||
const undeclared = emitted.filter((name) => !declared.includes(name))
|
||||
if (undeclared.length > 0) {
|
||||
throw new Error(
|
||||
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
|
||||
'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.'
|
||||
)
|
||||
}
|
||||
writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`)
|
||||
|
||||
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
|
||||
}
|
||||
@@ -148,11 +242,12 @@ for (const platform of PLATFORMS) {
|
||||
// so a single platform-independent bundle suffices; it ships inside the
|
||||
// Windows app via the same out/relay extraResources mapping.
|
||||
{
|
||||
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
|
||||
const outDir = join(ROOT, 'out', 'relay', 'wsl')
|
||||
const wslHookEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
|
||||
const wslBrowserNetworkEntry = join(ROOT, 'src', 'relay', 'wsl-browser-network-relay.ts')
|
||||
const outDir = join(OUT_ROOT, 'wsl')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
await build({
|
||||
entryPoints: [wslEntry],
|
||||
entryPoints: [wslHookEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
@@ -168,6 +263,27 @@ for (const platform of PLATFORMS) {
|
||||
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
|
||||
|
||||
await build({
|
||||
entryPoints: [wslBrowserNetworkEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'wsl-browser-network-relay.js'),
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js'))
|
||||
const browserNetworkHash = createHash('sha256')
|
||||
.update(browserNetworkContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`)
|
||||
console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`)
|
||||
}
|
||||
|
||||
console.log('Relay build complete.')
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
export function shouldReuseCompiledWindowsCliLauncher(
|
||||
outputPath,
|
||||
sourcePath,
|
||||
{ reuseCached = false } = {}
|
||||
) {
|
||||
if (!existsSync(outputPath)) {
|
||||
return false
|
||||
}
|
||||
// Why reuseCached: Actions cache keys already hash the C# source, but restore
|
||||
// does not preserve mtimes, so a hit would look stale and recompile anyway.
|
||||
if (reuseCached) {
|
||||
return true
|
||||
}
|
||||
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
|
||||
}
|
||||
|
||||
function defaultOutputPath(projectRoot) {
|
||||
@@ -56,5 +39,49 @@ function findFrameworkCompiler(env) {
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index >= 0 ? process.argv[index + 1] : undefined
|
||||
return index !== -1 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
if (
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
|
||||
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
|
||||
})
|
||||
) {
|
||||
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
@@ -37,6 +40,33 @@ function itWindows(name, test) {
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
it('reuses a compiled launcher that is at least as new as the C# source', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
|
||||
try {
|
||||
const sourcePath = join(root, 'OrcaCliLauncher.cs')
|
||||
const outputPath = join(root, '.build', 'orca.exe')
|
||||
mkdirSync(join(root, '.build'))
|
||||
writeFileSync(sourcePath, 'source\n')
|
||||
writeFileSync(outputPath, 'binary\n')
|
||||
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
|
||||
utimesSync(outputPath, later, later)
|
||||
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
|
||||
writeFileSync(sourcePath, 'changed\n')
|
||||
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
|
||||
utimesSync(sourcePath, sourceLater, sourceLater)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
|
||||
expect(
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
|
||||
).toBe(true)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
|
||||
false
|
||||
)
|
||||
} finally {
|
||||
removeFixtureTree(root)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
||||
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compile `@vscode/windows-process-tree` for a relay host.
|
||||
*
|
||||
* The relay is deployed to machines with no compiler, and this addon cannot be
|
||||
* npm-installed there: it carries a binding.gyp, so npm rebuilds from source and
|
||||
* the build wants Spectre-mitigated libraries even where MSVC is present. The
|
||||
* binary inside the published tarball loads, but predates our patch and still
|
||||
* caps enumeration at 1024 processes -- a busy host then gets a truncated table
|
||||
* missing its own pid, which reads as "unavailable" only under load.
|
||||
*
|
||||
* So we compile it here, from the patched source pnpm already materialized, and
|
||||
* ship the result as a relay artifact. Windows arm64 cross-compiles from an x64
|
||||
* runner, so both arches come off one Windows job.
|
||||
*
|
||||
* Node headers, not Electron: the relay runs under the host's own `node`. The
|
||||
* addon is N-API, so one build serves every Node the remote might have.
|
||||
*
|
||||
* node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
import {
|
||||
nodeGypRebuildInvocation,
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders,
|
||||
WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
const SUPPORTED_ARCHES = ['x64', 'arm64']
|
||||
|
||||
/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
|
||||
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 }
|
||||
|
||||
function parseArgs(argv) {
|
||||
const arch = argv.find((a) => a.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch
|
||||
const outDir = argv.find((a) => a.startsWith('--out='))?.slice('--out='.length)
|
||||
if (!SUPPORTED_ARCHES.includes(arch)) {
|
||||
throw new Error(`--arch must be one of ${SUPPORTED_ARCHES.join(', ')}; got ${arch}`)
|
||||
}
|
||||
return {
|
||||
arch,
|
||||
outDir: outDir ? resolve(outDir) : join(ROOT, '.build', 'windows-process-tree', arch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to build unpatched source.
|
||||
*
|
||||
* Each hunk fails differently: Spectre dies outright, the 1024-process cap
|
||||
* succeeds and lies, and `.targets` is cwd-relative so pnpm's nested layout
|
||||
* makes node-gyp miss node_addon_api.gyp on Windows. Checking the source
|
||||
* rather than trusting the install is what stops a silently unpatched tree
|
||||
* from being shipped as if it were patched.
|
||||
*/
|
||||
function assertPatchApplied() {
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
if (bindingGyp.includes('SpectreMitigation')) {
|
||||
throw new Error(
|
||||
'binding.gyp still requests SpectreMitigation. pnpm did not apply ' +
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (bindingGyp.includes('node_addon_api.gyp')) {
|
||||
throw new Error(
|
||||
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
|
||||
'project path incorrectly on Windows. ' +
|
||||
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
|
||||
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
|
||||
}
|
||||
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
|
||||
if (processCc.includes('process_count < 1024')) {
|
||||
throw new Error(
|
||||
'src/process.cc still caps enumeration at 1024 processes. pnpm did not apply ' +
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
// load-bearing build settings before node-gyp so the release build stays safe.
|
||||
function applyWindowsProcessTreeBuildFixes() {
|
||||
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
|
||||
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
|
||||
let bindingGyp = readFileSync(bindingPath, 'utf8')
|
||||
let processCc = readFileSync(processPath, 'utf8')
|
||||
const originalBinding = bindingGyp
|
||||
const originalProcess = processCc
|
||||
|
||||
for (const dynamicDependency of [
|
||||
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
|
||||
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
|
||||
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
|
||||
]) {
|
||||
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"include_dirs": []',
|
||||
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
|
||||
)
|
||||
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"VCCLCompilerTool": {',
|
||||
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
|
||||
)
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
}
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
try {
|
||||
const header = Buffer.alloc(4)
|
||||
readSync(fd, header, 0, 4, 0x3c)
|
||||
const peOffset = header.readUInt32LE(0)
|
||||
const machine = Buffer.alloc(2)
|
||||
readSync(fd, machine, 0, 2, peOffset + 4)
|
||||
return machine.readUInt16LE(0)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { arch, outDir } = parseArgs(process.argv.slice(2))
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error(
|
||||
`This addon only builds on Windows; running on ${process.platform}. ` +
|
||||
'Relay builds elsewhere simply omit it and fall back to the CIM scan.'
|
||||
)
|
||||
}
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
const gyp = nodeGypRebuildInvocation(arch)
|
||||
console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`)
|
||||
execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' })
|
||||
|
||||
const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node')
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`node-gyp reported success but ${built} is missing.`)
|
||||
}
|
||||
const machine = readPeMachine(built)
|
||||
if (machine !== PE_MACHINE[arch]) {
|
||||
throw new Error(
|
||||
`Built binary is machine 0x${machine.toString(16)}, expected 0x${PE_MACHINE[arch].toString(16)} for ${arch}. ` +
|
||||
'node-gyp ignored --arch; a relay would get a binary its host cannot load.'
|
||||
)
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
const staged = join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
|
||||
copyFileSync(built, staged)
|
||||
console.log(`[windows-process-tree] ${arch} -> ${staged}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(`[windows-process-tree] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -23,6 +23,15 @@ export const OXLINT_SCANS = [
|
||||
}
|
||||
]
|
||||
|
||||
const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
|
||||
[
|
||||
'react-doctor(no-derived-state-effect)',
|
||||
new Set([
|
||||
'src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts'
|
||||
])
|
||||
]
|
||||
])
|
||||
|
||||
export function parseAddedLineRanges(diff) {
|
||||
const ranges = []
|
||||
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
||||
@@ -139,7 +148,99 @@ function diagnosticLineRange(root, filename, span) {
|
||||
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
|
||||
// Why: a file-splitting refactor makes every line of the new module an "added"
|
||||
// line, so pre-existing lint debt in code that merely MOVED starts failing the
|
||||
// changed-lines gate. The only way to satisfy it is to edit the moved code,
|
||||
// which is exactly what a behavior-preserving refactor must not do. So a
|
||||
// diagnostic is exempt when its highlighted lines already existed, verbatim and
|
||||
// contiguous, somewhere in the base revision of the files this change touches.
|
||||
function normalizeSourceLine(line) {
|
||||
return line.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function collectBaseLineBlocks(root, comparisonBase, files = null) {
|
||||
// Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
|
||||
// often deleted or renamed away. Deleted paths never reach the changed-file list
|
||||
// (it filters to ACMRTUB), so read every path the diff touches, deletions included.
|
||||
const paths =
|
||||
files ??
|
||||
splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter(
|
||||
(file) => SOURCE_FILE_PATTERN.test(file)
|
||||
)
|
||||
const blocks = []
|
||||
for (const file of paths) {
|
||||
const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
||||
continue
|
||||
}
|
||||
blocks.push(
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeSourceLine)
|
||||
.filter((line) => line !== '')
|
||||
)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function isMovedCode(highlightedLines, baseBlocks) {
|
||||
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
if (needle.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Why a near-match rather than an exact contiguous one: a split moves a block
|
||||
// verbatim but a diagnostic's span often reaches past it — most commonly to a
|
||||
// hook dependency array, which legitimately grows when closure variables become
|
||||
// props. Requiring every line to match would report the moved body as new. So:
|
||||
// the block must still start at the same line in the base and appear IN ORDER,
|
||||
// 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 !== '')
|
||||
for (let start = 0; start < haystack.length; start += 1) {
|
||||
if (haystack[start] !== needle[0]) {
|
||||
continue
|
||||
}
|
||||
let matched = 1
|
||||
let cursor = start + 1
|
||||
for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
|
||||
while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
|
||||
cursor += 1
|
||||
}
|
||||
if (cursor < haystack.length) {
|
||||
matched += 1
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
if (matched / needle.length >= MIN_COVERAGE) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function diagnosticHighlightedLines(root, filename, span) {
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
|
||||
const range = diagnosticLineRange(root, filename, span)
|
||||
if (range === null) {
|
||||
return []
|
||||
}
|
||||
return source.slice(range.start - 1, range.end)
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(
|
||||
diagnostic,
|
||||
rangesByFile,
|
||||
root = process.cwd(),
|
||||
baseBlocks = []
|
||||
) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
if (!ranges) {
|
||||
@@ -147,7 +248,13 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) => {
|
||||
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
||||
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
|
||||
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
|
||||
return false
|
||||
}
|
||||
return !isMovedCode(
|
||||
diagnosticHighlightedLines(root, diagnostic.filename, label.span),
|
||||
baseBlocks
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +272,11 @@ function printDiagnostic(diagnostic, root) {
|
||||
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
|
||||
}
|
||||
|
||||
function isSuppressedDiagnostic(diagnostic, root) {
|
||||
const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code)
|
||||
return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false
|
||||
}
|
||||
|
||||
function runOxlintScan(root, scan, files) {
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
|
||||
@@ -193,10 +305,14 @@ export function main(
|
||||
return 0
|
||||
}
|
||||
|
||||
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter(
|
||||
(diagnostic) =>
|
||||
!isSuppressedDiagnostic(diagnostic, root) &&
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
isMovedCode,
|
||||
overlapsAddedLines,
|
||||
parseAddedLineRanges
|
||||
} from './check-changed-code-quality.mjs'
|
||||
@@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => {
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('moved-code exemption', () => {
|
||||
it('treats a verbatim contiguous block from the base as moved', () => {
|
||||
const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores indentation and whitespace changes from the move', () => {
|
||||
const base = [[' items.map((item, index) => (', ' key={index}']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt a genuinely new violation', () => {
|
||||
const base = [['const a = 1', 'const b = 2']]
|
||||
expect(isMovedCode(['rows.map((row, i) => <td key={i} />)'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt a block that is only partly present in the base', () => {
|
||||
const base = [['doThing()', 'unrelated()']]
|
||||
expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a few lines appended inside the moved block', () => {
|
||||
// A split commonly grows a hook dependency array when closure variables
|
||||
// become props; the moved body around it is still moved.
|
||||
const body = Array.from({ length: 20 }, (_, i) => `line${i}()`)
|
||||
const base = [body]
|
||||
const moved = [...body.slice(0, 19), 'newDep,', body[19]]
|
||||
expect(isMovedCode(moved, base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt when the anchor line is absent from the base', () => {
|
||||
const base = [['doThing()', 'filler()', 'other()']]
|
||||
expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt when most of the block is absent from the base', () => {
|
||||
const base = [['keep0()', 'keep1()', 'unrelated()']]
|
||||
const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)]
|
||||
expect(isMovedCode(mostlyNew, base)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores blank lines when matching', () => {
|
||||
const base = [['a()', 'b()']]
|
||||
expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('never exempts an empty highlight', () => {
|
||||
expect(isMovedCode(['', ' '], [['a()']])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ratchet gate for Electron imports reachable from the Orca runtime.
|
||||
*
|
||||
* The runtime is meant to become host-agnostic so it can also run on plain Node
|
||||
* (see docs/design/node-only-runtime-backend.html). Nothing enforces that today:
|
||||
* `orca-runtime.ts` reaches ~50 modules that import `electron`, and the number
|
||||
* silently grows whenever someone adds an import several hops away, because no
|
||||
* single reviewer sees the transitive edge.
|
||||
*
|
||||
* This bundles the runtime with esbuild, reads the metafile for every module that
|
||||
* imports `electron`, and compares that set to a checked-in baseline. A NEW module
|
||||
* fails the build; a removed one must be dropped from the baseline. The baseline
|
||||
* may only shrink, so the migration is measurable and cannot regress.
|
||||
*
|
||||
* This is a reachability check, not a lint rule: the point is precisely the edges
|
||||
* that no per-file rule can see.
|
||||
*
|
||||
* Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write]
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
// Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and
|
||||
// editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack
|
||||
// instead of a usable message.
|
||||
const ROOT = path.join(import.meta.dirname, '..', '..')
|
||||
const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
|
||||
|
||||
// The two module graphs a Node backend would have to boot: the runtime service
|
||||
// itself and the RPC server that fronts it.
|
||||
const ENTRY_POINTS = [
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
|
||||
// Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
|
||||
// graph is strictly larger than the two runtime entries. Measuring only those let the
|
||||
// two numbers drift — the gate would read zero while the shipped artifact regressed.
|
||||
path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
|
||||
]
|
||||
|
||||
// Native addons and electron cannot be bundled; externalising them is what the
|
||||
// relay build already does (config/scripts/build-relay.mjs).
|
||||
const EXTERNAL = [
|
||||
'electron',
|
||||
'node-pty',
|
||||
'@parcel/watcher',
|
||||
'better-sqlite3',
|
||||
'keytar',
|
||||
'fsevents',
|
||||
'cpu-features'
|
||||
]
|
||||
|
||||
/**
|
||||
* Why: some optional native deps (ssh2's cpu-features) reference a prebuilt `.node`
|
||||
* that only exists where a build toolchain has run. Resolving them made this gate
|
||||
* pass on a developer machine and hard-fail on CI. Nothing here needs the addon —
|
||||
* only the import graph — so mark every `.node` external instead.
|
||||
*/
|
||||
const externalNativeAddons = {
|
||||
name: 'external-native-addons',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectElectronImporters(entryPoints = ENTRY_POINTS) {
|
||||
const result = await build({
|
||||
entryPoints,
|
||||
bundle: true,
|
||||
write: false,
|
||||
// Why outdir with write:false: esbuild refuses multiple entry points without one,
|
||||
// even though nothing is emitted — the metafile is all this reads.
|
||||
outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'),
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'cjs',
|
||||
external: EXTERNAL,
|
||||
metafile: true,
|
||||
absWorkingDir: ROOT,
|
||||
logLevel: 'silent',
|
||||
plugins: [externalNativeAddons]
|
||||
})
|
||||
const importers = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
// Subpaths (electron/main) are as unavailable under plain Node as the bare module.
|
||||
if (imported.path === 'electron' || imported.path.startsWith('electron/')) {
|
||||
importers.add(path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...importers].sort()
|
||||
}
|
||||
|
||||
export function readBaseline(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function diffAgainstBaseline(current, baseline) {
|
||||
const baselineSet = new Set(baseline)
|
||||
const currentSet = new Set(current)
|
||||
return {
|
||||
added: current.filter((file) => !baselineSet.has(file)),
|
||||
removed: baseline.filter((file) => !currentSet.has(file))
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaseline(files) {
|
||||
return [
|
||||
'# Modules reachable from the Orca runtime that import `electron`.',
|
||||
'# Generated by config/scripts/check-runtime-electron-ratchet.mjs.',
|
||||
'# This list is EMPTY and must stay that way: the runtime boots on plain Node',
|
||||
'# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;',
|
||||
'# migrate the module behind a host port instead (src/main/host/).',
|
||||
'',
|
||||
...files
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const write = process.argv.includes('--write')
|
||||
const current = await collectElectronImporters()
|
||||
|
||||
if (write) {
|
||||
writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`)
|
||||
console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`)
|
||||
return
|
||||
}
|
||||
|
||||
const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8'))
|
||||
const { added, removed } = diffAgainstBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
console.error(
|
||||
`[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime now import electron:
|
||||
${added.map((file) => ` + ${file}`).join('\n')}
|
||||
|
||||
The runtime must stay bootable on plain Node. Put the Electron facility behind a port in
|
||||
src/main/host/ and depend on the port, or move the code out of the runtime's import graph.
|
||||
See docs/design/node-only-runtime-backend.html.`
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
console.error(
|
||||
`[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice.
|
||||
Refresh the baseline so the gate keeps its new, tighter floor:
|
||||
${removed.map((file) => ` - ${file}`).join('\n')}
|
||||
|
||||
node config/scripts/check-runtime-electron-ratchet.mjs --write`
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`)
|
||||
}
|
||||
|
||||
// Why pathToFileURL and not a `file://` template: on Windows process.argv[1] is a
|
||||
// native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so the
|
||||
// template never matches and the gate would exit 0 without checking anything — a
|
||||
// lint gate that fails open. Same idiom as check-max-lines-ratchet.mjs:225.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
collectElectronImporters,
|
||||
diffAgainstBaseline,
|
||||
readBaseline
|
||||
} from './check-runtime-electron-ratchet.mjs'
|
||||
|
||||
describe('readBaseline', () => {
|
||||
it('drops comments and blank lines and sorts, so baseline formatting cannot cause a false diff', () => {
|
||||
expect(readBaseline('# header\n\n b/second.ts \na/first.ts\n')).toEqual([
|
||||
'a/first.ts',
|
||||
'b/second.ts'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffAgainstBaseline', () => {
|
||||
it('reports a module that started importing electron', () => {
|
||||
expect(diffAgainstBaseline(['a.ts', 'b.ts'], ['a.ts'])).toEqual({
|
||||
added: ['b.ts'],
|
||||
removed: []
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a module that stopped, so the baseline is forced to tighten rather than drift', () => {
|
||||
expect(diffAgainstBaseline(['a.ts'], ['a.ts', 'b.ts'])).toEqual({
|
||||
added: [],
|
||||
removed: ['b.ts']
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet when the set is unchanged', () => {
|
||||
expect(diffAgainstBaseline(['a.ts'], ['a.ts'])).toEqual({ added: [], removed: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the checked-in baseline', () => {
|
||||
// Why real: the value of this gate is the transitive edges, which a fixture cannot model.
|
||||
// If this is slow enough to hurt, it is still cheaper than shipping a runtime that
|
||||
// cannot boot on Node.
|
||||
it('matches what the runtime actually reaches today', async () => {
|
||||
const current = await collectElectronImporters()
|
||||
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
|
||||
expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] })
|
||||
}, 120_000)
|
||||
|
||||
// Why an exact-empty assertion now: the reachable set reached zero, so "may only
|
||||
// shrink" has no room left and any entry at all is a regression. This is strictly
|
||||
// stronger than the old under-src/ check, which only stopped a node_modules path from
|
||||
// padding a non-empty count.
|
||||
it('stays empty, so nothing reachable from the runtime imports electron', () => {
|
||||
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
|
||||
expect(baseline).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ const BUDGETS = {
|
||||
maxTimerDriftMs: 150,
|
||||
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
|
||||
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
|
||||
maxTimerDriftUnderLoadMs: 2_500,
|
||||
maxTimerDriftUnderLoadMs: 3_500,
|
||||
maxScrollLatencyMs: 150,
|
||||
maxRestoreLatencyMs: 1000,
|
||||
maxRendererQueuedChars: 2 * 1024 * 1024,
|
||||
|
||||
@@ -132,14 +132,14 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
|
||||
it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => {
|
||||
const failPath = writeReport(
|
||||
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join(
|
||||
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=3501.0ms'].join(
|
||||
' '
|
||||
),
|
||||
'opencode-cross-workspace-typing'
|
||||
)
|
||||
const failResult = runChecker(failPath)
|
||||
expect(failResult.status).toBe(1)
|
||||
expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms')
|
||||
expect(failResult.stderr).toContain('timer drift 3501ms exceeded budget 3500ms')
|
||||
})
|
||||
|
||||
it('fails malformed metric values instead of treating them as absent', () => {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Ratchet gate for the `@ts-nocheck` directive.
|
||||
//
|
||||
// TypeScript only honours `@ts-nocheck` in a comment before the first statement, and
|
||||
// once present it disables type checking for the ENTIRE file. PR #17605 split a single
|
||||
// 43,928-line class into ~172 modules whose linear mixin-inheritance chain cannot yet
|
||||
// express forward references, so each carries a grandfathered `@ts-nocheck` header. This
|
||||
// check freezes that set (the baseline) and fails CI when a NEW file adds the directive —
|
||||
// the existing files are grandfathered; new ones must fix their types instead. The
|
||||
// baseline may only shrink.
|
||||
|
||||
const BASELINE_PATH = 'config/ts-nocheck-baseline.txt'
|
||||
// These two files legitimately contain the directive text as data (regex, fixtures),
|
||||
// so scanning them would self-flag. The ratchet does not police itself.
|
||||
const SELF_FILES = new Set([
|
||||
'config/scripts/check-ts-nocheck-ratchet.mjs',
|
||||
'config/scripts/check-ts-nocheck-ratchet.test.mjs'
|
||||
])
|
||||
|
||||
// True if `@ts-nocheck` appears in a comment before the first statement, matching the
|
||||
// TypeScript rule. Limitation: only the leading run of blank lines / line comments /
|
||||
// block comments at the top of the file is scanned, so a directive-looking string deeper
|
||||
// in a block comment that itself starts at the top is still checked — but anything after
|
||||
// real code (or inside a string literal, which never opens the leading comment run) is not.
|
||||
export function hasTsNoCheck(sourceText) {
|
||||
let i = 0
|
||||
const n = sourceText.length
|
||||
while (i < n) {
|
||||
const rest = sourceText.slice(i)
|
||||
const blank = /^[ \t]*\r?\n/.exec(rest)
|
||||
if (blank) {
|
||||
i += blank[0].length
|
||||
continue
|
||||
}
|
||||
if (rest.startsWith('//')) {
|
||||
const end = sourceText.indexOf('\n', i)
|
||||
const line = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end)
|
||||
if (/^\/\/\s*@ts-nocheck\b/.test(line)) {
|
||||
return true
|
||||
}
|
||||
i = end === -1 ? n : end + 1
|
||||
continue
|
||||
}
|
||||
if (rest.startsWith('/*')) {
|
||||
const end = sourceText.indexOf('*/', i + 2)
|
||||
const block = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end + 2)
|
||||
if (/^\/\*\s*@ts-nocheck\b/.test(block)) {
|
||||
return true
|
||||
}
|
||||
i = end === -1 ? n : end + 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function parseBaseline(text) {
|
||||
return new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
)
|
||||
}
|
||||
|
||||
export function diffBaseline(current, baseline) {
|
||||
const cur = new Set(current)
|
||||
const base = baseline instanceof Set ? baseline : new Set(baseline)
|
||||
const added = [...cur].filter((e) => !base.has(e)).sort()
|
||||
const stale = [...base].filter((e) => !cur.has(e)).sort()
|
||||
return { added, stale }
|
||||
}
|
||||
|
||||
// Collect every currently tracked file that carries a `@ts-nocheck` header.
|
||||
export function collectCurrentTsNoCheckFiles(root = process.cwd()) {
|
||||
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter((f) => !SELF_FILES.has(f))
|
||||
|
||||
const entries = []
|
||||
for (const rel of tracked) {
|
||||
let src
|
||||
try {
|
||||
src = fs.readFileSync(path.join(root, rel), 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (hasTsNoCheck(src)) {
|
||||
entries.push(rel)
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort()
|
||||
}
|
||||
|
||||
function printAddedFailure(added) {
|
||||
for (const entry of added) {
|
||||
console.error(`::error::New @ts-nocheck not allowed: ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ❌ ts-nocheck ratchet failed — a NEW file adds a @ts-nocheck directive. │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${added.length} file(s) newly add a \`@ts-nocheck\` header:`)
|
||||
console.error('')
|
||||
for (const entry of added) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' `@ts-nocheck` disables ALL type checking for the whole file, not just one line.')
|
||||
console.error(
|
||||
' The grandfathered entries exist only because the split runtime mixin chain cannot'
|
||||
)
|
||||
console.error(' express forward references yet — that is not a general license to suppress.')
|
||||
console.error('')
|
||||
console.error(' ✅ Fix it: fix the types instead of suppressing the whole file.')
|
||||
console.error('')
|
||||
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
|
||||
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
function printStaleFailure(stale) {
|
||||
for (const entry of stale) {
|
||||
console.error(`::error::Stale ts-nocheck baseline entry (prune it): ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ⚠️ ts-nocheck baseline is out of date — nice work removing a suppression! │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${stale.length} baseline entr(y/ies) no longer have a @ts-nocheck directive.`)
|
||||
console.error(
|
||||
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
|
||||
)
|
||||
console.error('')
|
||||
for (const entry of stale) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(` ✅ Fix it (one command): pnpm check:ts-nocheck-ratchet --prune`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
export function main(root = process.cwd()) {
|
||||
const baselineFile = path.join(root, BASELINE_PATH)
|
||||
if (!fs.existsSync(baselineFile)) {
|
||||
console.error(
|
||||
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-ts-nocheck-ratchet.mjs --init`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
|
||||
const current = collectCurrentTsNoCheckFiles(root)
|
||||
const { added, stale } = diffBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
printAddedFailure(added)
|
||||
if (stale.length > 0) {
|
||||
console.error(
|
||||
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
|
||||
)
|
||||
printStaleFailure(stale)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
printStaleFailure(stale)
|
||||
return 1
|
||||
}
|
||||
console.log(
|
||||
`ts-nocheck ratchet OK — ${current.length} grandfathered file(s), no new suppressions.`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
function writeBaseline(root, entries) {
|
||||
const header = [
|
||||
'# Files currently allowed to carry a `@ts-nocheck` header.',
|
||||
'# This is a RATCHET: the list may only SHRINK. These exist only because the split',
|
||||
'# runtime mixin chain cannot express forward references yet — do NOT add entries to',
|
||||
'# get CI green; fix the types instead.',
|
||||
'# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)',
|
||||
''
|
||||
].join('\n')
|
||||
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const root = process.cwd()
|
||||
const arg = process.argv[2]
|
||||
if (arg === '--init') {
|
||||
// One-time bootstrap: capture the current @ts-nocheck set as the baseline.
|
||||
const entries = collectCurrentTsNoCheckFiles(root)
|
||||
writeBaseline(root, entries)
|
||||
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--prune') {
|
||||
// Remove baseline entries whose @ts-nocheck is gone (shrink only; never adds).
|
||||
const current = new Set(collectCurrentTsNoCheckFiles(root))
|
||||
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
|
||||
const kept = [...baseline].filter((e) => current.has(e)).sort()
|
||||
const newlyAdded = [...current].filter((e) => !baseline.has(e))
|
||||
writeBaseline(root, kept)
|
||||
console.log(
|
||||
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
|
||||
)
|
||||
if (newlyAdded.length > 0) {
|
||||
console.error(
|
||||
`::error::--prune does not add entries; ${newlyAdded.length} new suppression(s) remain — fix those files' types.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.exit(main(root))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { diffBaseline, hasTsNoCheck, parseBaseline } from './check-ts-nocheck-ratchet.mjs'
|
||||
|
||||
describe('hasTsNoCheck', () => {
|
||||
it('detects a line-comment form', () => {
|
||||
expect(hasTsNoCheck('// @ts-nocheck\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a block-comment form', () => {
|
||||
expect(hasTsNoCheck('/* @ts-nocheck */\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the no-space form', () => {
|
||||
expect(hasTsNoCheck('//@ts-nocheck\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a directive with a -- Why reason', () => {
|
||||
expect(
|
||||
hasTsNoCheck(
|
||||
'// @ts-nocheck -- Why: mechanically split, covered by AST tests.\nimport x from "y"\n'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows blank lines and other leading comments before the directive', () => {
|
||||
const src =
|
||||
'\n// Copyright notice.\n\n/* another leading comment */\n// @ts-nocheck\nexport const a = 1\n'
|
||||
expect(hasTsNoCheck(src)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match once a statement has started', () => {
|
||||
const src = 'export const a = 1\n// @ts-nocheck\n'
|
||||
expect(hasTsNoCheck(src)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match inside a string literal', () => {
|
||||
const src = 'export const a = "// @ts-nocheck"\n'
|
||||
expect(hasTsNoCheck(src)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for ordinary source', () => {
|
||||
expect(hasTsNoCheck('export function f() {\n return 42\n}\n')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBaseline', () => {
|
||||
it('drops comments and blank lines', () => {
|
||||
const b = parseBaseline('# header\n\nsrc/a.ts\nsrc/b.ts\n')
|
||||
expect(b).toEqual(new Set(['src/a.ts', 'src/b.ts']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBaseline', () => {
|
||||
it('reports added and stale entries', () => {
|
||||
const { added, stale } = diffBaseline(
|
||||
['src/b.ts', 'src/c.ts'],
|
||||
new Set(['src/a.ts', 'src/b.ts'])
|
||||
)
|
||||
expect(added).toEqual(['src/c.ts']) // new suppression
|
||||
expect(stale).toEqual(['src/a.ts']) // suppression removed
|
||||
})
|
||||
|
||||
it('is clean when current matches baseline', () => {
|
||||
const { added, stale } = diffBaseline(['src/a.ts'], new Set(['src/a.ts']))
|
||||
expect(added).toEqual([])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('client-hosted browser package coverage', () => {
|
||||
it('bundles the WSL browser-network relay with its version stamp', () => {
|
||||
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
|
||||
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'wsl-browser-network-relay.js')")
|
||||
expect(relayBuild).toContain("join(outDir, '.browser-network-version')")
|
||||
})
|
||||
|
||||
it('runs client-hosted Electron lifecycle coverage on native package hosts', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const linuxStep = parsedWorkflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Test Linux Electron lifecycle boundary'
|
||||
)
|
||||
const windowsStep = parsedWorkflow.jobs.package_windows.steps.find(
|
||||
(step) => step.name === 'Test Windows-specific boundaries'
|
||||
)
|
||||
|
||||
const required = [
|
||||
'browser-client-page-renderer-lifecycle',
|
||||
'browser-route-webrtc-egress',
|
||||
'browser-route-tcp-egress',
|
||||
'browser-route-h3-egress',
|
||||
'browser-route-dns-prefetch'
|
||||
].map((name) => `src/main/browser/${name}.electron.test.ts`)
|
||||
|
||||
expect(linuxStep.run).toContain('xvfb-run --auto-servernum')
|
||||
for (const file of required) {
|
||||
expect(linuxStep.run).toContain(file)
|
||||
expect(windowsStep.run).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
// Why pinned: each of those files launches a full Electron stack twice under its own in-process
|
||||
// deadline. Letting the runner interleave four of them starved the probes past those deadlines,
|
||||
// which is the only way this step has ever failed.
|
||||
it('gives each Linux Electron probe the runner to itself', () => {
|
||||
const parsedWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const linuxStep = parsedWorkflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Test Linux Electron lifecycle boundary'
|
||||
)
|
||||
|
||||
expect(linuxStep.run).toContain('--no-file-parallelism')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Codex index-heal contract PR gate', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const job = workflow.jobs.codex_index_heal_contract
|
||||
|
||||
it('installs and verifies against one pinned Codex version', () => {
|
||||
const install = job.steps.find((step) => step.name === 'Install pinned Codex CLI')
|
||||
const verify = job.steps.find((step) => step.name === 'Verify Codex index-heal contract')
|
||||
|
||||
// Why one source: the install and the runtime version assertion drifting apart is
|
||||
// the failure that would leave this job verifying a Codex nobody declared.
|
||||
expect(job.env.CODEX_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
expect(install.run).toContain('"@openai/codex@$CODEX_CLI_VERSION"')
|
||||
expect(verify.env.ORCA_CODEX_CONTRACT_VERSION).toBe('${{ env.CODEX_CLI_VERSION }}')
|
||||
|
||||
// The install prefix and the binary the test is pointed at must be the same tree.
|
||||
expect(install.run).toContain('--prefix "$RUNNER_TEMP/codex-cli"')
|
||||
expect(verify.run).toContain(
|
||||
'ORCA_CODEX_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli/node_modules/.bin/codex"'
|
||||
)
|
||||
expect(verify.run).toContain('src/main/codex/codex-index-heal-binary-contract.test.ts')
|
||||
})
|
||||
|
||||
it('fails rather than skipping when the Codex binary is missing', () => {
|
||||
const verify = job.steps.find((step) => step.name === 'Verify Codex index-heal contract')
|
||||
|
||||
// Why asserted: the contract skips itself without a binary, so a failed install
|
||||
// would otherwise turn this job into a green no-op that verifies nothing.
|
||||
expect(verify.env.ORCA_CODEX_CONTRACT_REQUIRED).toBe('1')
|
||||
expect(job.steps.find((step) => step.name === 'Install pinned Codex CLI').run).toContain(
|
||||
'set -euo pipefail'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -74,6 +74,9 @@ export function readBenchmarkArtifact(path) {
|
||||
}
|
||||
|
||||
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
|
||||
if (artifact?.valid === false || artifact?.status === 'failed') {
|
||||
throw new Error(`${path}: benchmark artifact is marked invalid`)
|
||||
}
|
||||
if (artifact?.summaryMedianMs != null) {
|
||||
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
|
||||
}
|
||||
@@ -82,6 +85,15 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
|
||||
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
|
||||
)
|
||||
}
|
||||
if (artifact?.headlineMs != null) {
|
||||
return normalizeNumericObject(
|
||||
path,
|
||||
artifact,
|
||||
'terminal-split-activation',
|
||||
artifact.headlineMs,
|
||||
() => 'ms'
|
||||
)
|
||||
}
|
||||
if (artifact?.suites != null) {
|
||||
return normalizePlaywrightArtifact(path, artifact)
|
||||
}
|
||||
@@ -89,7 +101,7 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
|
||||
return normalizeSummaryArtifact(path, artifact)
|
||||
}
|
||||
throw new Error(
|
||||
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
|
||||
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, headlineMs, Playwright suites, or top-level summary`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,24 @@ import { parse } from 'yaml'
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('computer-use e2e workflow', () => {
|
||||
it('cancels superseded pull request runs without cancelling scheduled runs', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: 'computer-e2e-${{ github.event.pull_request.number || github.ref }}',
|
||||
'cancel-in-progress': "${{ github.event_name == 'pull_request' }}"
|
||||
})
|
||||
})
|
||||
|
||||
it('runs computer-use e2e files serially because they share desktop focus', () => {
|
||||
const config = readFileSync(join(projectDir, 'tests/e2e/vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).toContain('fileParallelism: false')
|
||||
})
|
||||
|
||||
it('guards e2e source against fragile fixed waits and stale element indexes', () => {
|
||||
it('guards e2e source against fragile waits and Windows Calculator drift', () => {
|
||||
const driver = readFileSync(join(projectDir, 'tests/e2e/helpers/computer-driver.ts'), 'utf8')
|
||||
const cliDriver = readFileSync(
|
||||
join(projectDir, 'tests/e2e/helpers/computer-cli-driver.ts'),
|
||||
@@ -31,11 +42,14 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(cliDriver).toContain('Could not read Orca runtime metadata')
|
||||
expect(cliDriver).toContain("'serve', '--no-pairing', '--json'")
|
||||
|
||||
expect(windowsStoreE2e).toMatch(
|
||||
/for \(const buttonName of \['One', 'Plus', 'Two', 'Equals'\]\) \{[\s\S]*findRoleIndex\(state\.result\.snapshot\.treeText, `button \$\{buttonName\}`\)[\s\S]*state = parseJsonOutput/
|
||||
expect(windowsStoreE2e).toContain("app.bundleId === 'ApplicationFrameHost'")
|
||||
expect(windowsStoreE2e).toContain("app.bundleId === 'win32calc'")
|
||||
expect(windowsStoreE2e).toContain('buttonIndex >= 0')
|
||||
expect(windowsStoreE2e).toContain('pane(?:\\s|$)/m')
|
||||
expect(windowsStoreE2e).toContain('String(clickIndex)')
|
||||
expect(windowsStoreE2e).not.toContain(
|
||||
"for (const buttonName of ['One', 'Plus', 'Two', 'Equals'])"
|
||||
)
|
||||
expect(windowsStoreE2e).not.toMatch(/const one = findRoleIndex/)
|
||||
expect(windowsStoreE2e).not.toMatch(/for \(const index of \[one, plus, two, equals\]\)/)
|
||||
})
|
||||
|
||||
it('triggers on computer-use shared contracts, scripts, and agent skill changes', () => {
|
||||
@@ -76,7 +90,6 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
@@ -119,17 +132,44 @@ describe('computer-use e2e workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
|
||||
it('keeps Linux native imports available without installing the GUI-only stack in PR smoke', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const nativeSmokeInstall = workflow.jobs['native-smoke'].steps.find(
|
||||
(step) => step.if === "runner.os == 'Linux'"
|
||||
)
|
||||
const scheduledLinuxInstall = workflow.jobs.linux.steps.find((step) =>
|
||||
step.run?.includes('apt-get install')
|
||||
)
|
||||
|
||||
expect(nativeSmokeInstall.run).toContain('python3')
|
||||
expect(nativeSmokeInstall.run).toContain('python3-gi')
|
||||
expect(nativeSmokeInstall.run).toContain('gir1.2-atspi-2.0')
|
||||
expect(nativeSmokeInstall.run).toContain('at-spi2-core')
|
||||
expect(nativeSmokeInstall.run).not.toContain('gedit')
|
||||
expect(nativeSmokeInstall.run).not.toContain('xvfb')
|
||||
expect(nativeSmokeInstall.run).not.toContain('xdotool')
|
||||
expect(scheduledLinuxInstall.run).toContain('gedit')
|
||||
expect(scheduledLinuxInstall.run).toContain('xvfb')
|
||||
expect(scheduledLinuxInstall.run).toContain('xdotool')
|
||||
})
|
||||
|
||||
it('builds and tests the macOS helper on every trigger without hosted TCC e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job.if).toBeUndefined()
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(install.with['native-runtime']).toBe('electron')
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
@@ -139,6 +179,7 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
expect(runs).toContain('pnpm verify:computer-native')
|
||||
expect(runs.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(workflow.jobs.mac).toBeUndefined()
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
|
||||
@@ -151,6 +192,29 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the cached Electron dependency path for scheduled Linux and Windows e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
for (const jobName of ['linux', 'windows']) {
|
||||
const job = workflow.jobs[jobName]
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(checkout.with['persist-credentials'], jobName).toBe(false)
|
||||
expect(install.with['native-runtime'], jobName).toBe('electron')
|
||||
expect(
|
||||
job.steps.some((step) => step.uses === 'pnpm/setup@v2'),
|
||||
jobName
|
||||
).toBe(false)
|
||||
expect(
|
||||
job.steps.some((step) => step.run === 'pnpm install --frozen-lockfile'),
|
||||
jobName
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
|
||||
const benchmark = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
|
||||
@@ -180,7 +244,7 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
const steps = workflow.jobs['native-smoke'].steps
|
||||
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const buildIndex = runs.indexOf('pnpm build:electron-vite')
|
||||
const buildIndex = runs.indexOf('pnpm run build:electron-vite:parallel')
|
||||
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
|
||||
|
||||
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
|
||||
@@ -196,7 +260,9 @@ describe('computer-use e2e workflow', () => {
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const steps = workflow.jobs['native-smoke'].steps
|
||||
const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite')
|
||||
const buildIndex = steps.findIndex(
|
||||
(step) => step.run === 'pnpm run build:electron-vite:parallel'
|
||||
)
|
||||
const reproIndex = steps.findIndex(
|
||||
(step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
)
|
||||
@@ -241,11 +307,11 @@ describe('computer-use e2e workflow', () => {
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
for (const jobName of ['native-smoke', 'mac', 'linux', 'windows']) {
|
||||
for (const jobName of ['native-smoke', 'linux', 'windows']) {
|
||||
const runs = workflow.jobs[jobName].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const buildIndex = runs.indexOf('pnpm build:electron-vite')
|
||||
const buildIndex = runs.indexOf('pnpm run build:electron-vite:parallel')
|
||||
const e2eIndexes = runs
|
||||
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
@@ -271,7 +337,6 @@ describe('computer-use e2e workflow', () => {
|
||||
.filter((run) => typeof run === 'string')
|
||||
const allRuns = [
|
||||
...nativeSmokeRuns,
|
||||
...workflow.jobs.mac.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
|
||||
...workflow.jobs.linux.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
|
||||
...workflow.jobs.windows.steps
|
||||
.map((step) => step.run)
|
||||
@@ -283,30 +348,25 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
|
||||
})
|
||||
|
||||
it('runs macOS and Linux computer-use e2e files in scheduled jobs', () => {
|
||||
it('runs Linux e2e on schedule without advertising hosted macOS TCC coverage', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
const macRuns = workflow.jobs.mac.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const linuxRuns = workflow.jobs.linux.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tests/e2e/computer-mac.e2e.ts',
|
||||
'tests/e2e/computer-mac-safari.e2e.ts',
|
||||
'tests/e2e/computer-linux.e2e.ts',
|
||||
'tests/e2e/helpers/computer-cli-driver.ts',
|
||||
'tests/e2e/helpers/computer-driver.ts'
|
||||
])
|
||||
)
|
||||
expect(macRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts'
|
||||
)
|
||||
expect(triggerPaths).not.toContain('tests/e2e/computer-mac.e2e.ts')
|
||||
expect(triggerPaths).not.toContain('tests/e2e/computer-mac-safari.e2e.ts')
|
||||
expect(workflow.jobs.mac).toBeUndefined()
|
||||
expect(linuxRuns).toContain(
|
||||
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ function source(path) {
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start < 0 || end < 0) {
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('computer-use mouse button routing', () => {
|
||||
it('maps the macOS middle button onto the otherMouse event family', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const mapping = sourceBetween(
|
||||
macOS,
|
||||
'extension MouseButtonSelection {',
|
||||
'private func mouseButton('
|
||||
)
|
||||
|
||||
expect(mapping).toContain('return .center')
|
||||
expect(mapping).toContain('return .otherMouseDown')
|
||||
expect(mapping).toContain('return .otherMouseUp')
|
||||
// A middle press posted as a left event type would silently left-click.
|
||||
expect(mapping).not.toContain('case .middle:\n return .leftMouseDown')
|
||||
})
|
||||
|
||||
it('validates the macOS mouse button before any accessibility shortcut runs', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const click = sourceBetween(
|
||||
macOS,
|
||||
'private func click(params:',
|
||||
'private func performClickAction('
|
||||
)
|
||||
|
||||
expect(click).toContain('let button = try mouseButton(params["mouseButton"]?.string)')
|
||||
expect(click).toContain('button.hasAccessibilityAction')
|
||||
// An unvalidated raw string reaches AXPress and reports a left click as success.
|
||||
expect(click).not.toContain('params["mouseButton"]?.string ?? "left"')
|
||||
})
|
||||
|
||||
it('keeps every platform from resolving a middle click through its accessibility path', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const windowsClick = sourceBetween(
|
||||
windows,
|
||||
'$handledByPattern = $false',
|
||||
'if (-not $handledByPattern)'
|
||||
)
|
||||
|
||||
expect(windowsClick).toContain('$Operation.mouse_button -ne "middle"')
|
||||
|
||||
const linux = source('native/computer-use-linux/runtime.py')
|
||||
const linuxClick = sourceBetween(linux, 'has_modifiers = bool(', 'if not handled:')
|
||||
|
||||
expect(linuxClick).toContain('operation.get("mouse_button", "left") == "left"')
|
||||
})
|
||||
})
|
||||
@@ -12,11 +12,33 @@ const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps discovery scoped to desktop control and out of the embedded browser', () => {
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(readFileSync(guidePath, 'utf8'))?.[1] ?? ''
|
||||
const description = frontmatter.replace(/\s+/gu, ' ')
|
||||
|
||||
expect(description).toContain('OS/window-level inspection and input')
|
||||
expect(description).toContain('external browser window')
|
||||
expect(description).toContain("Do not use for Orca's embedded browser")
|
||||
expect(description).toContain('page-only browser automation')
|
||||
expect(description).toContain("`orca-cli` for Orca's embedded pages")
|
||||
expect(description).toContain(
|
||||
'page-automation tool such as Playwright or CDP for external pages'
|
||||
)
|
||||
expect(description).not.toContain('read Slack')
|
||||
expect(description).not.toContain('get app state')
|
||||
|
||||
const orcaCli = readFileSync(join(projectDir, 'skill-guides', 'orca-cli.md'), 'utf8').replace(
|
||||
/\s+/gu,
|
||||
' '
|
||||
)
|
||||
expect(orcaCli).toContain('browser embedded inside the Orca app')
|
||||
})
|
||||
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
const skill = readFileSync(guidePath, 'utf8')
|
||||
|
||||
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
|
||||
expect(skill).toContain('operate the desktop browser app/window that contains the page')
|
||||
expect(skill).toContain('external desktop browser window that needs desktop-level control')
|
||||
expect(skill).not.toContain('orca goto')
|
||||
expect(skill).not.toContain('orca snapshot')
|
||||
expect(skill).not.toContain('orca click')
|
||||
@@ -47,6 +69,18 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
|
||||
it('explains how JSON and pretty output handle screenshots', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
|
||||
expect(skill).toContain('request screenshots by default unless `--no-screenshot`')
|
||||
expect(skill).toContain('A successful `--json` capture')
|
||||
expect(skill).toContain('`result.screenshot.path`')
|
||||
expect(skill).toContain('inline base64 `result.screenshot.data`')
|
||||
expect(skill).toContain('Pretty output does not save')
|
||||
}
|
||||
})
|
||||
|
||||
it('requires atomic modifier-click actions in the source and bundled guide', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('Windows computer-use horizontal scroll', () => {
|
||||
it('routes left and right through the horizontal wheel with native signs', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const mouseEvents = sourceBetween(windows, '$MouseEvents = @{', 'function Write-OrcaJson')
|
||||
const scroll = sourceBetween(windows, ' "scroll" {', ' "drag" {')
|
||||
const left = sourceBetween(
|
||||
scroll,
|
||||
'} elseif ($Operation.direction -eq "left") {',
|
||||
'} elseif ($Operation.direction -eq "right") {'
|
||||
)
|
||||
const right = sourceBetween(
|
||||
scroll,
|
||||
'} elseif ($Operation.direction -eq "right") {',
|
||||
'} elseif ($Operation.direction -ne "up") {'
|
||||
)
|
||||
|
||||
expect(mouseEvents).toContain('HorizontalWheel = 0x01000')
|
||||
expect(scroll).toContain('$mouseEvent = $MouseEvents.Wheel')
|
||||
expect(left).toContain('$mouseEvent = $MouseEvents.HorizontalWheel')
|
||||
expect(left).toContain('$delta = -1 * $delta')
|
||||
expect(right).toContain('$mouseEvent = $MouseEvents.HorizontalWheel')
|
||||
expect(right).not.toContain('$delta = -1 * $delta')
|
||||
expect(scroll).toContain(
|
||||
'[OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero)'
|
||||
)
|
||||
expect(scroll).not.toContain('mouse_event($MouseEvents.Wheel')
|
||||
expect(scroll).toContain('throw "unsupported scroll direction: $($Operation.direction)"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
export function buildCounterbalancedSchedule(pairCount, firstArm, secondArm) {
|
||||
if (!Number.isInteger(pairCount) || pairCount <= 0 || pairCount % 2 !== 0) {
|
||||
throw new Error('Counterbalanced schedules require a positive even pair count')
|
||||
}
|
||||
if (!firstArm || !secondArm || firstArm === secondArm) {
|
||||
throw new Error('Counterbalanced schedules require two distinct arms')
|
||||
}
|
||||
|
||||
return Array.from({ length: pairCount }, (_, index) =>
|
||||
index % 2 === 0 ? [firstArm, secondArm] : [secondArm, firstArm]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
BENCHMARK_SAMPLE_AGGREGATION,
|
||||
summarizeBenchmarkSamples
|
||||
} from './benchmark-sample-summary.mjs'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function linearDriftSamples(schedule, trueDuration, driftPerLaunch) {
|
||||
const samples = { login: [], fast: [] }
|
||||
schedule.flat().forEach((arm, launchIndex) => {
|
||||
samples[arm].push(trueDuration[arm] + launchIndex * driftPerLaunch)
|
||||
})
|
||||
return samples
|
||||
}
|
||||
|
||||
describe('counterbalanced benchmark schedule', () => {
|
||||
it('builds complete ABBA blocks', () => {
|
||||
expect(buildCounterbalancedSchedule(4, 'login', 'fast')).toEqual([
|
||||
['login', 'fast'],
|
||||
['fast', 'login'],
|
||||
['login', 'fast'],
|
||||
['fast', 'login']
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects counts that cannot balance launch positions', () => {
|
||||
for (const pairCount of [0, 1, 3, 4.5]) {
|
||||
expect(() => buildCounterbalancedSchedule(pairCount, 'login', 'fast')).toThrow(
|
||||
'positive even pair count'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('requires distinct arms', () => {
|
||||
expect(() => buildCounterbalancedSchedule(2, 'login', 'login')).toThrow('two distinct arms')
|
||||
})
|
||||
|
||||
it('gives each arm the same mean launch position', () => {
|
||||
const launches = buildCounterbalancedSchedule(20, 'login', 'fast').flat()
|
||||
const positions = { login: [], fast: [] }
|
||||
launches.forEach((arm, index) => positions[arm].push(index))
|
||||
|
||||
expect(mean(positions.login)).toBe(mean(positions.fast))
|
||||
})
|
||||
|
||||
it('cancels linear drift in the reported median difference', () => {
|
||||
const schedule = buildCounterbalancedSchedule(20, 'login', 'fast')
|
||||
const trueDuration = { login: 100, fast: 80 }
|
||||
const driftPerLaunch = 7
|
||||
const samples = linearDriftSamples(schedule, trueDuration, driftPerLaunch)
|
||||
|
||||
const login = summarizeBenchmarkSamples(samples.login)
|
||||
const fast = summarizeBenchmarkSamples(samples.fast)
|
||||
expect(fast.medianMs - login.medianMs).toBe(trueDuration.fast - trueDuration.login)
|
||||
|
||||
const lowerMedian = (values) => [...values].sort((left, right) => left - right)[9]
|
||||
expect(lowerMedian(samples.fast) - lowerMedian(samples.login)).toBe(
|
||||
trueDuration.fast - trueDuration.login - driftPerLaunch
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds the descriptive p95 bias to one linear-drift launch slot', () => {
|
||||
const schedule = buildCounterbalancedSchedule(20, 'login', 'fast')
|
||||
const trueDuration = { login: 100, fast: 80 }
|
||||
const driftPerLaunch = 7
|
||||
const samples = linearDriftSamples(schedule, trueDuration, driftPerLaunch)
|
||||
const login = summarizeBenchmarkSamples(samples.login)
|
||||
const fast = summarizeBenchmarkSamples(samples.fast)
|
||||
|
||||
expect(fast.p95Ms - login.p95Ms).toBe(trueDuration.fast - trueDuration.login + driftPerLaunch)
|
||||
expect(BENCHMARK_SAMPLE_AGGREGATION).toEqual({
|
||||
version: 2,
|
||||
median: 'average-middle',
|
||||
p95: 'nearest-rank',
|
||||
p95Role: 'descriptive',
|
||||
p95LinearDriftBoundLaunchSlots: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -238,7 +238,11 @@ async function main() {
|
||||
throw new Error('daemon readiness did not publish the expected PID ownership record')
|
||||
}
|
||||
log('PID ownership record matches the ready daemon')
|
||||
if (!existsSync(socketPath)) {
|
||||
const endpointPublished =
|
||||
process.platform === 'win32'
|
||||
? (await probeEndpoint(socketPath)) === 'connected'
|
||||
: existsSync(socketPath)
|
||||
if (!endpointPublished) {
|
||||
throw new Error('daemon did not publish its endpoint at the canonical socket path')
|
||||
}
|
||||
log('endpoint published at the canonical socket path')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user