mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Merge branch 'ra/max-lines-bottom-19-local-pty-provider' into ra/max-lines-bottom-20-cdp-bridge
# Conflicts: # src/main/browser/cdp-bridge.ts
This commit is contained in:
@@ -44,9 +44,20 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
|
||||
[[ "${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -58,6 +69,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -82,9 +82,20 @@ __orca_deferred_init() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
|
||||
[[ "${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -96,6 +107,27 @@ __orca_deferred_init() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -47,9 +47,20 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
|
||||
[[ "${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -61,6 +72,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -82,9 +82,20 @@ __orca_deferred_init() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
|
||||
[[ "${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -96,6 +107,27 @@ __orca_deferred_init() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -36,9 +36,20 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
|
||||
[[ "${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -50,6 +61,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -56,9 +56,20 @@ __orca_deferred_init() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
|
||||
[[ "${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -70,6 +81,27 @@ __orca_deferred_init() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -85,23 +85,21 @@ export async function scanAiVaultSessions(
|
||||
const candidates = dedupeCodexRolloutFileAliases(
|
||||
discoveries
|
||||
.flatMap((discovery) =>
|
||||
discovery.files.map(
|
||||
(file): SessionFileCandidate => ({
|
||||
agent: discovery.agent,
|
||||
file,
|
||||
codexHome:
|
||||
discovery.agent === 'codex'
|
||||
? codexHomeForSessionsDir(
|
||||
discovery.rootDir,
|
||||
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
|
||||
)
|
||||
: null,
|
||||
antigravityHistoryPath:
|
||||
discovery.agent === 'antigravity'
|
||||
? antigravityHistoryPathForBrainDir(discovery.rootDir)
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
discovery.files.map((file): SessionFileCandidate => ({
|
||||
agent: discovery.agent,
|
||||
file,
|
||||
codexHome:
|
||||
discovery.agent === 'codex'
|
||||
? codexHomeForSessionsDir(
|
||||
discovery.rootDir,
|
||||
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
|
||||
)
|
||||
: null,
|
||||
antigravityHistoryPath:
|
||||
discovery.agent === 'antigravity'
|
||||
? antigravityHistoryPathForBrainDir(discovery.rootDir)
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
|
||||
{
|
||||
@@ -203,9 +201,11 @@ async function scanInScopeSessions(args: {
|
||||
excludedFilePaths: args.alreadyParsedFilePaths,
|
||||
issues: args.issues
|
||||
})
|
||||
const candidates = files.map(
|
||||
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
|
||||
)
|
||||
const candidates = files.map((file): SessionFileCandidate => ({
|
||||
agent: 'claude',
|
||||
file,
|
||||
codexHome: null
|
||||
}))
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
@@ -276,11 +277,12 @@ async function runFixture(): Promise<FixtureResult> {
|
||||
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 60_000 })
|
||||
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
|
||||
@@ -160,7 +160,9 @@ export function forwardGuestShortcutInput(
|
||||
if (isFloatingGuest) {
|
||||
renderer.send('ui:closeFloatingItem', { sourceId: browserTabId })
|
||||
} else {
|
||||
renderer.send('ui:closeActiveTab')
|
||||
// Why: carry the guest's page id — activeTabType/activeBrowserTabId can be stale in split
|
||||
// layouts (guest focus doesn't reach the group's focus-capture), silently dropping the close.
|
||||
renderer.send('ui:closeActiveTab', { sourceId: browserTabId })
|
||||
}
|
||||
} else if (keybindingMatchesAction('tab.nextSameType', input, process.platform, keybindings)) {
|
||||
renderer.send('ui:switchTab', 1)
|
||||
|
||||
@@ -872,7 +872,9 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeFloatingItem', {
|
||||
sourceId: browserTabId
|
||||
})
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeActiveTab')
|
||||
expect(rendererSendMock.mock.calls.some(([channel]) => channel === 'ui:closeActiveTab')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('routes workspace/tab index chords to ui:selectFloatingIndex for a floating guest', () => {
|
||||
@@ -903,7 +905,7 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
triggerBeforeInput(closeInput)
|
||||
triggerBeforeInput(workspaceIndexInput)
|
||||
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab')
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab', { sourceId: browserTabId })
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:jumpToWorktreeIndex', 0)
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything())
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:selectFloatingIndex', expect.anything())
|
||||
@@ -918,7 +920,7 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
|
||||
triggerBeforeInput(closeInput)
|
||||
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab')
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab', { sourceId: browserTabId })
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything())
|
||||
})
|
||||
|
||||
|
||||
@@ -297,7 +297,9 @@ describe('browserManager', () => {
|
||||
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:newBrowserTab')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newTerminalTab')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab', {
|
||||
sourceId: 'browser-1'
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:switchTabAcrossAllTypes', 1)
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:switchTab', 1)
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:switchTerminalTab', 1)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
|
||||
@@ -20,12 +21,13 @@ export async function runBrowserRouteEgressElectron(
|
||||
`--user-data-dir=${join(root, 'profile')}`,
|
||||
...extraElectronArgs
|
||||
]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const child = spawn(executable, args, {
|
||||
detached: true,
|
||||
env,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
|
||||
@@ -20,12 +21,13 @@ export async function runPersistedWorkerElectron(
|
||||
const resultPath = join(root, 'result.json')
|
||||
rmSync(resultPath, { force: true })
|
||||
const electronArgs = [mainPath, configPath, mode, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const child = spawn(executable, args, {
|
||||
detached: true,
|
||||
env,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
@@ -138,11 +139,12 @@ function runProbe(protectedGuest: boolean): ProbeResult {
|
||||
writeFileSync(mainPath, probeMain(resultPath, protectedGuest))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 30_000 })
|
||||
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
|
||||
@@ -76,7 +76,7 @@ export class CdpTabCommands extends CdpBridgeCommandModule {
|
||||
|
||||
tabSwitch(index: number): BrowserTabSwitchResult {
|
||||
// Why: filter to live tabs so indices match tabList(), skipping destroyed-but-uncleaned entries.
|
||||
const liveEntries = [...this.getRegisteredTabs()].filter(([_, wcId]) => {
|
||||
const liveEntries = [...this.getRegisteredTabs()].filter(([_tabId, wcId]) => {
|
||||
const guest = webContents.fromId(wcId)
|
||||
return guest && !guest.isDestroyed()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = '/tmp/electron'
|
||||
const electronArgs = ['/tmp/main.cjs', '--user-data-dir=/tmp/profile']
|
||||
|
||||
describe('electron probe launch resolution', () => {
|
||||
it('reuses an inherited X display instead of nesting another xvfb-run server', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: 'linux',
|
||||
display: ':99'
|
||||
})
|
||||
).toEqual({
|
||||
executable: electronBinary,
|
||||
args: ['/tmp/main.cjs', '--user-data-dir=/tmp/profile', '--no-sandbox']
|
||||
})
|
||||
})
|
||||
|
||||
it('owns a display when Linux hands the probe none', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: 'linux',
|
||||
display: undefined
|
||||
})
|
||||
).toEqual({
|
||||
executable: 'xvfb-run',
|
||||
args: [
|
||||
'--auto-servernum',
|
||||
electronBinary,
|
||||
'/tmp/main.cjs',
|
||||
'--user-data-dir=/tmp/profile',
|
||||
'--no-sandbox'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an empty DISPLAY as no display', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({ electronBinary, electronArgs, platform: 'linux', display: '' })
|
||||
.executable
|
||||
).toBe('xvfb-run')
|
||||
})
|
||||
|
||||
it('leaves non-Linux launches on the plain Electron binary', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: 'darwin',
|
||||
display: undefined
|
||||
})
|
||||
).toEqual({ executable: electronBinary, args: electronArgs })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
export type ElectronProbeLaunch = {
|
||||
executable: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
export type ElectronProbeLaunchInput = {
|
||||
electronBinary: string
|
||||
electronArgs: readonly string[]
|
||||
platform: NodeJS.Platform
|
||||
display: string | undefined
|
||||
}
|
||||
|
||||
// Why: these probes each start a full Electron stack, and CI already runs the whole vitest
|
||||
// invocation under one `xvfb-run`. Nesting a private `--auto-servernum` server per probe adds an
|
||||
// X server per concurrent file and races the sibling probes for a free display number, so reuse
|
||||
// the display we were handed and only own one when there is none.
|
||||
export function resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform,
|
||||
display
|
||||
}: ElectronProbeLaunchInput): ElectronProbeLaunch {
|
||||
if (platform !== 'linux') {
|
||||
return { executable: electronBinary, args: [...electronArgs] }
|
||||
}
|
||||
const linuxArgs = [...electronArgs, '--no-sandbox']
|
||||
if (display) {
|
||||
return { executable: electronBinary, args: linuxArgs }
|
||||
}
|
||||
return { executable: 'xvfb-run', args: ['--auto-servernum', electronBinary, ...linuxArgs] }
|
||||
}
|
||||
@@ -33,8 +33,8 @@ describe('estimateCostUsd cache-write TTL rates', () => {
|
||||
})
|
||||
|
||||
it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => {
|
||||
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map(
|
||||
(write1h) => estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
|
||||
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map((write1h) =>
|
||||
estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
|
||||
)
|
||||
for (let index = 1; index < costs.length; index++) {
|
||||
expect(costs[index]).toBeGreaterThan(costs[index - 1])
|
||||
|
||||
@@ -205,9 +205,10 @@ export function createRuntimeHome(): RuntimeHomeStub {
|
||||
return {
|
||||
syncForCurrentSelection: vi.fn(),
|
||||
clearLastWrittenAuthJson: vi.fn(),
|
||||
prepareForRateLimitFetch: vi.fn(
|
||||
(): CodexRateLimitHomeResolution => ({ kind: 'ready', codexHomePath: null })
|
||||
)
|
||||
prepareForRateLimitFetch: vi.fn((): CodexRateLimitHomeResolution => ({
|
||||
kind: 'ready',
|
||||
codexHomePath: null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,13 +191,11 @@ describe('Codex state DB backfill recovery', () => {
|
||||
|
||||
const summary = await runCodexStateDbBackfillRecovery('/managed-home', controller.signal, {
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus: vi.fn(
|
||||
(): CodexStateDbBackfillStatus => ({
|
||||
kind: 'incomplete',
|
||||
stateDbPath: '/state.sqlite',
|
||||
status: 'running'
|
||||
})
|
||||
),
|
||||
readStatus: vi.fn((): CodexStateDbBackfillStatus => ({
|
||||
kind: 'incomplete',
|
||||
stateDbPath: '/state.sqlite',
|
||||
status: 'running'
|
||||
})),
|
||||
terminate,
|
||||
sleep,
|
||||
now: () => now
|
||||
@@ -270,15 +268,14 @@ describe('Codex state DB backfill recovery', () => {
|
||||
new AbortController().signal,
|
||||
{
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus: vi.fn(
|
||||
(): CodexStateDbBackfillStatus =>
|
||||
spawnProcess.mock.calls.length >= 3
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: {
|
||||
kind: 'incomplete',
|
||||
stateDbPath: '/state.sqlite',
|
||||
status: 'running'
|
||||
}
|
||||
readStatus: vi.fn((): CodexStateDbBackfillStatus =>
|
||||
spawnProcess.mock.calls.length >= 3
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: {
|
||||
kind: 'incomplete',
|
||||
stateDbPath: '/state.sqlite',
|
||||
status: 'running'
|
||||
}
|
||||
),
|
||||
terminate: terminate as never,
|
||||
sleep,
|
||||
@@ -328,11 +325,10 @@ describe('Codex state DB backfill recovery', () => {
|
||||
await expect(
|
||||
runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, {
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus: vi.fn(
|
||||
(): CodexStateDbBackfillStatus =>
|
||||
spawnCount >= 2
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
|
||||
readStatus: vi.fn((): CodexStateDbBackfillStatus =>
|
||||
spawnCount >= 2
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
|
||||
),
|
||||
terminate,
|
||||
sleep: vi.fn(async (ms: number) => {
|
||||
@@ -427,11 +423,10 @@ describe('Codex state DB backfill recovery', () => {
|
||||
}
|
||||
return child
|
||||
})
|
||||
const readStatus = vi.fn(
|
||||
(): CodexStateDbBackfillStatus =>
|
||||
spawnCount >= 2
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
|
||||
const readStatus = vi.fn((): CodexStateDbBackfillStatus =>
|
||||
spawnCount >= 2
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
|
||||
)
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildGpuCrashDiagnostics, GpuCrashDiagnosticsRecorder } from './gpu-crash-diagnostics'
|
||||
|
||||
const FEATURE_STATUS = {
|
||||
gpu_compositing: 'enabled',
|
||||
rasterization: 'enabled',
|
||||
webgl: 'enabled',
|
||||
webgl2: 'enabled',
|
||||
video_decode: 'enabled'
|
||||
}
|
||||
|
||||
const BASIC_INFO = {
|
||||
gpuDevice: [
|
||||
{
|
||||
active: false,
|
||||
vendorId: 0x8086,
|
||||
deviceId: 0x9a49,
|
||||
vendorString: 'Intel',
|
||||
deviceString: 'Integrated GPU'
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
vendorId: 0x10de,
|
||||
deviceId: 0x2684,
|
||||
vendorString: 'NVIDIA',
|
||||
deviceString: 'Discrete GPU',
|
||||
driverVendor: 'NVIDIA',
|
||||
driverVersion: '32.0.15.6094'
|
||||
}
|
||||
],
|
||||
auxAttributes: {
|
||||
glVendor: 'Google Inc.',
|
||||
glRenderer: 'ANGLE (NVIDIA, D3D11)',
|
||||
glVersion: 'OpenGL ES 3.0'
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolvePromise: ((value: T) => void) | undefined
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: (value) => resolvePromise?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildGpuCrashDiagnostics', () => {
|
||||
it('keeps only the active GPU identity, driver, rendering backend, and feature status', () => {
|
||||
expect(
|
||||
buildGpuCrashDiagnostics({ info: BASIC_INFO, level: 'complete' }, FEATURE_STATUS)
|
||||
).toEqual({
|
||||
gpuInfoLevel: 'complete',
|
||||
gpuDeviceCount: 2,
|
||||
gpuVendorId: 0x10de,
|
||||
gpuDeviceId: 0x2684,
|
||||
gpuVendor: 'NVIDIA',
|
||||
gpuDevice: 'Discrete GPU',
|
||||
gpuDriverVendor: 'NVIDIA',
|
||||
gpuDriverVersion: '32.0.15.6094',
|
||||
gpuGlVendor: 'Google Inc.',
|
||||
gpuGlRenderer: 'ANGLE (NVIDIA, D3D11)',
|
||||
gpuGlVersion: 'OpenGL ES 3.0',
|
||||
gpuCompositingStatus: 'enabled',
|
||||
gpuRasterizationStatus: 'enabled',
|
||||
gpuWebglStatus: 'enabled',
|
||||
gpuWebgl2Status: 'enabled',
|
||||
gpuVideoDecodeStatus: 'enabled'
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades malformed or unavailable GPU info without copying arbitrary fields', () => {
|
||||
expect(
|
||||
buildGpuCrashDiagnostics(
|
||||
{
|
||||
level: 'basic',
|
||||
info: {
|
||||
gpuDevice: [{ active: true, vendorId: Number.NaN, secret: 'do not copy' }],
|
||||
machineModelName: 'do not copy'
|
||||
}
|
||||
},
|
||||
{ webgl: 'unavailable', unexpected: 'do not copy' }
|
||||
)
|
||||
).toEqual({
|
||||
gpuInfoLevel: 'basic',
|
||||
gpuDeviceCount: 1,
|
||||
gpuWebglStatus: 'unavailable'
|
||||
})
|
||||
expect(buildGpuCrashDiagnostics(null, null)).toEqual({ gpuInfoLevel: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GpuCrashDiagnosticsRecorder', () => {
|
||||
it('warms only complete info and records one breadcrumb across a crash burst', async () => {
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn(async (level: 'basic' | 'complete') => ({
|
||||
...BASIC_INFO,
|
||||
gpuDevice: BASIC_INFO.gpuDevice.map((device) => ({
|
||||
...device,
|
||||
...(level === 'complete' ? { driverVersion: 'complete-driver' } : {})
|
||||
}))
|
||||
})),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
await vi.waitFor(() => {
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
await Promise.resolve()
|
||||
await recorder.record()
|
||||
await recorder.record()
|
||||
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledOnce()
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledWith('complete')
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gpuInfoLevel: 'complete',
|
||||
gpuVendorId: 0x10de,
|
||||
gpuDeviceId: 0x2684,
|
||||
gpuDriverVersion: 'complete-driver'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses promptly available basic info when complete collection is still pending', async () => {
|
||||
const complete = deferred<unknown>()
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn((level: 'basic' | 'complete') =>
|
||||
level === 'basic' ? Promise.resolve(BASIC_INFO) : complete.promise
|
||||
),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledOnce()
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledWith('complete')
|
||||
await recorder.record()
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gpuInfoLevel: 'basic',
|
||||
gpuVendorId: 0x10de,
|
||||
gpuDriverVersion: '32.0.15.6094'
|
||||
})
|
||||
)
|
||||
complete.resolve(BASIC_INFO)
|
||||
})
|
||||
|
||||
it('shares pending capture work and releases it when complete info arrives first', async () => {
|
||||
const complete = deferred<unknown>()
|
||||
const basic = deferred<unknown>()
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn((level: 'basic' | 'complete') =>
|
||||
level === 'basic' ? basic.promise : complete.promise
|
||||
),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
const first = recorder.record()
|
||||
const second = recorder.record()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(recordBreadcrumb).not.toHaveBeenCalled()
|
||||
complete.resolve(BASIC_INFO)
|
||||
await first
|
||||
expect(recordBreadcrumb).toHaveBeenCalledOnce()
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuInfoLevel: 'complete' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let stalled GPU info block crash recovery', async () => {
|
||||
const never = Promise.withResolvers<unknown>().promise
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn(() => never),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({
|
||||
provider,
|
||||
recordBreadcrumb,
|
||||
recordTimeoutMs: 0
|
||||
})
|
||||
|
||||
await recorder.record()
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuInfoLevel: 'unavailable' })
|
||||
)
|
||||
})
|
||||
|
||||
it('still records collection status when Electron throws during both GPU info calls', async () => {
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn(() => {
|
||||
throw new Error('GPU access disabled')
|
||||
}),
|
||||
getGPUFeatureStatus: vi.fn(() => {
|
||||
throw new Error('GPU teardown')
|
||||
})
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
await recorder.record()
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith({ gpuInfoLevel: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GPU crash diagnostics production wiring', () => {
|
||||
it('starts diagnostics without delaying safe-graphics fallback', () => {
|
||||
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const listenerStart = source.indexOf("app.on('child-process-gone'")
|
||||
expect(listenerStart).toBeGreaterThan(0)
|
||||
const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart))
|
||||
expect(source).toMatch(
|
||||
/recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/
|
||||
)
|
||||
expect(listener).toMatch(
|
||||
/const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/
|
||||
)
|
||||
expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting'
|
||||
|
||||
type GpuInfoLevel = 'basic' | 'complete'
|
||||
const DEFAULT_GPU_CRASH_DIAGNOSTICS_WAIT_MS = 1_000
|
||||
|
||||
type GpuInfoProvider = {
|
||||
getGPUInfo(infoType: GpuInfoLevel): Promise<unknown>
|
||||
getGPUFeatureStatus(): unknown
|
||||
}
|
||||
|
||||
type GpuCrashDiagnosticsRecorderOptions = {
|
||||
provider: GpuInfoProvider
|
||||
recordBreadcrumb: (data: CrashReportBreadcrumbData) => void
|
||||
recordTimeoutMs?: number
|
||||
}
|
||||
|
||||
type GpuInfoSnapshot = {
|
||||
info: unknown
|
||||
level: GpuInfoLevel
|
||||
}
|
||||
|
||||
async function waitAtMost(promise: Promise<void>, timeoutMs: number): Promise<void> {
|
||||
const timeoutGate = Promise.withResolvers<void>()
|
||||
const timeout = setTimeout(timeoutGate.resolve, timeoutMs)
|
||||
try {
|
||||
await Promise.race([promise, timeoutGate.promise])
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function waitForFirstAvailable(promises: Promise<boolean>[]): Promise<void> {
|
||||
const availableGate = Promise.withResolvers<void>()
|
||||
let remaining = promises.length
|
||||
for (const promise of promises) {
|
||||
void promise.then((available) => {
|
||||
remaining -= 1
|
||||
if (available || remaining === 0) {
|
||||
availableGate.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
return availableGate.promise
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function addString(target: CrashReportBreadcrumbData, key: string, value: unknown): void {
|
||||
const safe = nonEmptyString(value)
|
||||
if (safe !== undefined) {
|
||||
target[key] = safe
|
||||
}
|
||||
}
|
||||
|
||||
function addNumber(target: CrashReportBreadcrumbData, key: string, value: unknown): void {
|
||||
const safe = finiteNumber(value)
|
||||
if (safe !== undefined) {
|
||||
target[key] = safe
|
||||
}
|
||||
}
|
||||
|
||||
function activeGpuDevice(info: Record<string, unknown>): {
|
||||
device: Record<string, unknown> | null
|
||||
count: number
|
||||
} {
|
||||
const devices = Array.isArray(info.gpuDevice)
|
||||
? info.gpuDevice.map(recordValue).filter((device) => device !== null)
|
||||
: []
|
||||
return {
|
||||
device: devices.find((device) => device.active === true) ?? devices[0] ?? null,
|
||||
count: devices.length
|
||||
}
|
||||
}
|
||||
|
||||
function addFeatureStatuses(details: CrashReportBreadcrumbData, featureStatus: unknown): void {
|
||||
const status = recordValue(featureStatus)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
addString(details, 'gpuCompositingStatus', status.gpu_compositing)
|
||||
addString(details, 'gpuRasterizationStatus', status.rasterization)
|
||||
addString(details, 'gpuWebglStatus', status.webgl)
|
||||
addString(details, 'gpuWebgl2Status', status.webgl2)
|
||||
addString(details, 'gpuVideoDecodeStatus', status.video_decode)
|
||||
}
|
||||
|
||||
export function buildGpuCrashDiagnostics(
|
||||
snapshot: GpuInfoSnapshot | null,
|
||||
featureStatus: unknown
|
||||
): CrashReportBreadcrumbData {
|
||||
const details: CrashReportBreadcrumbData = {
|
||||
gpuInfoLevel: snapshot?.level ?? 'unavailable'
|
||||
}
|
||||
addFeatureStatuses(details, featureStatus)
|
||||
const info = recordValue(snapshot?.info)
|
||||
if (!info) {
|
||||
return details
|
||||
}
|
||||
|
||||
const { device, count } = activeGpuDevice(info)
|
||||
details.gpuDeviceCount = count
|
||||
if (device) {
|
||||
addNumber(details, 'gpuVendorId', device.vendorId)
|
||||
addNumber(details, 'gpuDeviceId', device.deviceId)
|
||||
addString(details, 'gpuVendor', device.vendorString)
|
||||
addString(details, 'gpuDevice', device.deviceString)
|
||||
addString(details, 'gpuDriverVendor', device.driverVendor)
|
||||
addString(details, 'gpuDriverVersion', device.driverVersion)
|
||||
}
|
||||
|
||||
const aux = recordValue(info.auxAttributes)
|
||||
if (aux) {
|
||||
addString(details, 'gpuGlVendor', aux.glVendor)
|
||||
addString(details, 'gpuGlRenderer', aux.glRenderer)
|
||||
addString(details, 'gpuGlVersion', aux.glVersion)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
/** Captures GPU identity before a crash and emits it once when a Windows GPU burst starts. */
|
||||
export class GpuCrashDiagnosticsRecorder {
|
||||
private readonly provider: GpuInfoProvider
|
||||
private readonly recordBreadcrumb: (data: CrashReportBreadcrumbData) => void
|
||||
private readonly recordTimeoutMs: number
|
||||
private basicInfoPromise: Promise<boolean> | null = null
|
||||
private completeInfoPromise: Promise<boolean> | null = null
|
||||
private recordingPromise: Promise<void> | null = null
|
||||
private basicInfo: unknown = null
|
||||
private completeInfo: unknown = null
|
||||
|
||||
constructor(options: GpuCrashDiagnosticsRecorderOptions) {
|
||||
this.provider = options.provider
|
||||
this.recordBreadcrumb = options.recordBreadcrumb
|
||||
this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_GPU_CRASH_DIAGNOSTICS_WAIT_MS
|
||||
}
|
||||
|
||||
warm(): void {
|
||||
void this.ensureCompleteInfo()
|
||||
}
|
||||
|
||||
record(): Promise<void> {
|
||||
this.recordingPromise ??= this.recordOnce()
|
||||
return this.recordingPromise
|
||||
}
|
||||
|
||||
private async recordOnce(): Promise<void> {
|
||||
let featureStatus: unknown = null
|
||||
try {
|
||||
featureStatus = this.provider.getGPUFeatureStatus()
|
||||
} catch {
|
||||
// GPU teardown can race this read; device identity is still useful.
|
||||
}
|
||||
if (this.completeInfo === null) {
|
||||
await waitAtMost(
|
||||
waitForFirstAvailable([this.ensureBasicInfo(), this.ensureCompleteInfo()]),
|
||||
this.recordTimeoutMs
|
||||
)
|
||||
}
|
||||
const snapshot = this.preferredSnapshot()
|
||||
try {
|
||||
this.recordBreadcrumb(buildGpuCrashDiagnostics(snapshot, featureStatus))
|
||||
} catch {
|
||||
// Diagnostics must never block safe-graphics recovery.
|
||||
}
|
||||
}
|
||||
|
||||
private ensureBasicInfo(): Promise<boolean> {
|
||||
if (this.basicInfoPromise === null) {
|
||||
try {
|
||||
this.basicInfoPromise = this.provider.getGPUInfo('basic').then(
|
||||
(info) => {
|
||||
this.basicInfo = info
|
||||
return true
|
||||
},
|
||||
() => false
|
||||
)
|
||||
} catch {
|
||||
this.basicInfoPromise = Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
return this.basicInfoPromise
|
||||
}
|
||||
|
||||
private ensureCompleteInfo(): Promise<boolean> {
|
||||
if (this.completeInfoPromise === null) {
|
||||
try {
|
||||
this.completeInfoPromise = this.provider.getGPUInfo('complete').then(
|
||||
(info) => {
|
||||
this.completeInfo = info
|
||||
return true
|
||||
},
|
||||
() => false
|
||||
)
|
||||
} catch {
|
||||
this.completeInfoPromise = Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
return this.completeInfoPromise
|
||||
}
|
||||
|
||||
private preferredSnapshot(): GpuInfoSnapshot | null {
|
||||
if (this.completeInfo !== null) {
|
||||
return { info: this.completeInfo, level: 'complete' }
|
||||
}
|
||||
if (this.basicInfo !== null) {
|
||||
return { info: this.basicInfo, level: 'basic' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
|
||||
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
||||
GpuCrashFallbackTracker,
|
||||
isGpuFallbackCrashCandidate
|
||||
} from './gpu-crash-fallback-decision'
|
||||
import { shouldRecordProcessGoneCrash } from './process-gone-classification'
|
||||
|
||||
/**
|
||||
* Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster
|
||||
* through the production decision path, so the reason no host was ever offered
|
||||
* Safe Graphics Mode is pinned rather than argued about.
|
||||
*
|
||||
* Each session below is one crash report's `Recent activity`; a breadcrumb's
|
||||
* `suppressedSinceLast=N` means N further identical GPU deaths were coalesced
|
||||
* into it, so the session saw N+1 GPU crashes.
|
||||
*/
|
||||
|
||||
type FieldSession = {
|
||||
/** Crash-report id from the field bundle. */
|
||||
report: string
|
||||
/** GPU child crash times, ms since main-process start. */
|
||||
gpuCrashesMsSinceLaunch: number[]
|
||||
}
|
||||
|
||||
// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts -
|
||||
// mainProcessStartedAt); coalesced repeats are placed inside the same second,
|
||||
// which is the only interval the emitted breadcrumb pins them to.
|
||||
const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [
|
||||
// 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes
|
||||
{ report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] },
|
||||
// 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash
|
||||
{ report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] },
|
||||
// 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes
|
||||
{ report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] },
|
||||
// 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash
|
||||
{ report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] }
|
||||
]
|
||||
|
||||
/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */
|
||||
function readChildProcessGoneListener(): string {
|
||||
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const start = source.indexOf("app.on('child-process-gone'")
|
||||
expect(start).toBeGreaterThan(0)
|
||||
return source.slice(start, source.indexOf('\n })', start))
|
||||
}
|
||||
|
||||
function newTracker(): GpuCrashFallbackTracker {
|
||||
return new GpuCrashFallbackTracker({
|
||||
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
||||
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
|
||||
})
|
||||
}
|
||||
|
||||
const FIELD_GPU_EVENT = {
|
||||
source: 'child',
|
||||
processType: 'GPU',
|
||||
serviceName: 'GPU',
|
||||
reason: 'crashed',
|
||||
expectedTeardown: 'none'
|
||||
} as const
|
||||
|
||||
describe('1.4.190 win32 GPU-child crash cluster', () => {
|
||||
it('does not let process_gone_suppressed gate the fallback candidate check', () => {
|
||||
// The GPU death is suppressed as recoverable churn (no user-facing report)...
|
||||
expect(shouldRecordProcessGoneCrash({ ...FIELD_GPU_EVENT, exitCode: -2147483645 })).toBe(false)
|
||||
// ...but the fallback path reads the raw child-process-gone event, so the
|
||||
// suppression cannot hide a broken driver from recovery.
|
||||
expect(
|
||||
isGpuFallbackCrashCandidate({
|
||||
platform: 'win32',
|
||||
processType: 'GPU',
|
||||
reason: 'crashed'
|
||||
})
|
||||
).toBe(true)
|
||||
// Both assertions above still pass if the candidate check is moved behind the
|
||||
// suppressed-report path, so pin that nothing branches ahead of it in index.ts.
|
||||
const listener = readChildProcessGoneListener()
|
||||
const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(')
|
||||
expect(guardStart).toBeGreaterThan(0)
|
||||
expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1)
|
||||
expect(listener).toMatch(
|
||||
/isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/
|
||||
)
|
||||
// The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which
|
||||
// re-couples recovery to the suppression decision, so pin the guard to that check alone.
|
||||
const recoveryGuard = listener.slice(
|
||||
listener.lastIndexOf('if (', guardStart),
|
||||
listener.indexOf('handleGpuChildCrash(')
|
||||
)
|
||||
expect(recoveryGuard).not.toMatch(/&&|\|\|/)
|
||||
})
|
||||
|
||||
it('never reaches the burst threshold in any observed cluster session', () => {
|
||||
const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => {
|
||||
// Each launch constructs a fresh tracker (src/main/index.ts), so evidence
|
||||
// does not survive the relaunch these users performed after every crash.
|
||||
const tracker = newTracker()
|
||||
const engaged = session.gpuCrashesMsSinceLaunch.some(
|
||||
(at) => tracker.recordGpuCrash(at).shouldEngageFallback
|
||||
)
|
||||
return {
|
||||
report: session.report,
|
||||
gpuCrashes: session.gpuCrashesMsSinceLaunch.length,
|
||||
engagedSafeGraphicsPrompt: engaged
|
||||
}
|
||||
})
|
||||
expect(outcomes).toEqual([
|
||||
{ report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false },
|
||||
{ report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false },
|
||||
{ report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false },
|
||||
{ report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => {
|
||||
// oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then
|
||||
// `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`.
|
||||
const tracker = newTracker()
|
||||
expect(tracker.recordGpuCrash(3_600_000).shouldEngageFallback).toBe(false)
|
||||
expect(tracker.recordGpuCrash(3_601_000).shouldEngageFallback).toBe(false)
|
||||
expect(tracker.recordGpuCrash(3_601_890)).toEqual({
|
||||
shouldEngageFallback: true,
|
||||
crashesInWindow: 3
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
engageGpuFallbackAfterCrashBurst,
|
||||
type GpuFallbackEngagement,
|
||||
type GpuFallbackEngagementHandlers
|
||||
} from './gpu-fallback-engagement'
|
||||
import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt'
|
||||
|
||||
const ENGAGEMENT: GpuFallbackEngagement = {
|
||||
reason: 'crashed',
|
||||
// 0x80000003 STATUS_BREAKPOINT — the production Windows signature.
|
||||
exitCode: -2147483645,
|
||||
crashesInWindow: 3,
|
||||
engagedAt: 1_700_000_000_000
|
||||
}
|
||||
|
||||
function createHandlers(overrides: Partial<GpuFallbackEngagementHandlers> = {}): {
|
||||
handlers: GpuFallbackEngagementHandlers
|
||||
order: string[]
|
||||
} {
|
||||
const order: string[] = []
|
||||
const handlers: GpuFallbackEngagementHandlers = {
|
||||
isQuitting: () => false,
|
||||
persistMarker: vi.fn(() => {
|
||||
order.push('persistMarker')
|
||||
return true
|
||||
}),
|
||||
confirmMarker: vi.fn(() => order.push('confirmMarker')),
|
||||
clearMarker: vi.fn(() => order.push('clearMarker')),
|
||||
promptForRestart: vi.fn(async () => {
|
||||
order.push('prompt')
|
||||
return 'restart' as GpuFallbackRestartDecision
|
||||
}),
|
||||
onPromptFailed: vi.fn(),
|
||||
onEngaged: vi.fn(),
|
||||
onRestartDeferred: vi.fn(() => order.push('restartDeferred')),
|
||||
restartIntoSafeGraphics: vi.fn(() => order.push('restart')),
|
||||
...overrides
|
||||
}
|
||||
return { handlers, order }
|
||||
}
|
||||
|
||||
describe('engageGpuFallbackAfterCrashBurst', () => {
|
||||
// Repro for the Windows startup GPU cluster (v1.4.190, exit -2147483645).
|
||||
// Measured on Windows 11 26200 / Electron 43.1.0: Chromium's own ladder aborts
|
||||
// the whole browser ("GPU process isn't usable. Goodbye.") on the 6th GPU
|
||||
// crash, 1.285s after the 3rd — the crash that trips this threshold. If the
|
||||
// marker is only written after the user answers the modal, the app dies first
|
||||
// and the next launch retries hardware acceleration, looping forever.
|
||||
it('persists the safe-graphics marker before the restart prompt is answered', async () => {
|
||||
let resolvePrompt: ((decision: GpuFallbackRestartDecision) => void) | undefined
|
||||
const { handlers } = createHandlers({
|
||||
promptForRestart: vi.fn(
|
||||
() =>
|
||||
new Promise<GpuFallbackRestartDecision>((resolve) => {
|
||||
resolvePrompt = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const engaging = engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
await Promise.resolve()
|
||||
|
||||
// Chromium kills the process here; whatever is on disk now is all the next launch gets.
|
||||
expect(handlers.persistMarker).toHaveBeenCalledWith(ENGAGEMENT)
|
||||
|
||||
resolvePrompt?.('restart')
|
||||
await engaging
|
||||
})
|
||||
|
||||
it('relaunches into safe graphics when the user accepts', async () => {
|
||||
const { handlers, order } = createHandlers()
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(order).toEqual(['persistMarker', 'prompt', 'confirmMarker', 'restart'])
|
||||
expect(handlers.clearMarker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undoes the marker when the user chooses Keep Running', async () => {
|
||||
const { handlers, order } = createHandlers({
|
||||
promptForRestart: vi.fn(async () => 'continue' as GpuFallbackRestartDecision)
|
||||
})
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(order).toEqual(['persistMarker', 'clearMarker', 'restartDeferred'])
|
||||
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the marker when the prompt itself fails, so the next launch is still safe', async () => {
|
||||
const error = new Error('no display')
|
||||
const { handlers } = createHandlers({
|
||||
promptForRestart: vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
})
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(handlers.persistMarker).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.clearMarker).not.toHaveBeenCalled()
|
||||
expect(handlers.onPromptFailed).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('does not relaunch when a quit began while the prompt was open', async () => {
|
||||
const { handlers } = createHandlers({ isQuitting: () => true })
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
|
||||
// The marker stays: a quit is not the user declining safe graphics.
|
||||
expect(handlers.clearMarker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim a restart when the marker could not be written', async () => {
|
||||
const { handlers } = createHandlers({ persistMarker: vi.fn(() => false) })
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt'
|
||||
|
||||
/** The GPU-crash burst that tripped the software-rendering fallback. */
|
||||
export type GpuFallbackEngagement = {
|
||||
reason: string
|
||||
exitCode: number | null
|
||||
crashesInWindow: number
|
||||
/** Wall-clock ms stamped into the persisted marker. */
|
||||
engagedAt: number
|
||||
}
|
||||
|
||||
export type GpuFallbackEngagementHandlers = {
|
||||
isQuitting: () => boolean
|
||||
/** Writes the build-scoped safe-graphics marker; false when it could not be persisted. */
|
||||
persistMarker: (engagement: GpuFallbackEngagement) => boolean
|
||||
/** Marks that the user explicitly accepted safe graphics before relaunching. */
|
||||
confirmMarker: (engagement: GpuFallbackEngagement) => void
|
||||
clearMarker: () => void
|
||||
promptForRestart: () => Promise<GpuFallbackRestartDecision>
|
||||
onPromptFailed: (error: unknown) => void
|
||||
onEngaged: (engagement: GpuFallbackEngagement) => void
|
||||
onRestartDeferred: (engagement: GpuFallbackEngagement) => void
|
||||
restartIntoSafeGraphics: (engagement: GpuFallbackEngagement) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the safe-graphics handover once a GPU crash burst trips the threshold.
|
||||
*
|
||||
* Why the marker is written before the prompt: measured on Windows 11 26200 /
|
||||
* Electron 43.1.0, Chromium's own GPU ladder aborts the whole browser process
|
||||
* (`FATAL gpu_data_manager_impl_private.cc: GPU process isn't usable. Goodbye.`)
|
||||
* on the 6th GPU crash — 1.285s after the 3rd, which is the crash that trips
|
||||
* this threshold. Persisting only after the user answers a modal means the app
|
||||
* dies first, no marker survives, and the next launch retries hardware
|
||||
* acceleration: the crash loop never ends. An explicit "Keep Running" undoes it.
|
||||
*/
|
||||
export async function engageGpuFallbackAfterCrashBurst(
|
||||
engagement: GpuFallbackEngagement,
|
||||
handlers: GpuFallbackEngagementHandlers
|
||||
): Promise<void> {
|
||||
handlers.onEngaged(engagement)
|
||||
const persisted = handlers.persistMarker(engagement)
|
||||
let decision: GpuFallbackRestartDecision
|
||||
try {
|
||||
decision = await handlers.promptForRestart()
|
||||
} catch (error) {
|
||||
// Marker stays: the next launch is safe even though the user was never asked.
|
||||
handlers.onPromptFailed(error)
|
||||
return
|
||||
}
|
||||
if (handlers.isQuitting()) {
|
||||
return
|
||||
}
|
||||
if (decision !== 'restart') {
|
||||
if (persisted) {
|
||||
handlers.clearMarker()
|
||||
}
|
||||
handlers.onRestartDeferred(engagement)
|
||||
return
|
||||
}
|
||||
if (!persisted) {
|
||||
return
|
||||
}
|
||||
handlers.confirmMarker(engagement)
|
||||
handlers.restartIntoSafeGraphics(engagement)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { showMessageBoxMock } = vi.hoisted(() => ({
|
||||
showMessageBoxMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
dialog: { showMessageBox: showMessageBoxMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
handleGpuFallbackRecoveredLaunch,
|
||||
promptForGpuFallbackRecoveredLaunch,
|
||||
type GpuFallbackRecoveredLaunchDecision,
|
||||
type GpuFallbackRecoveredLaunchHandlers
|
||||
} from './gpu-fallback-recovered-launch'
|
||||
|
||||
beforeEach(() => {
|
||||
showMessageBoxMock.mockReset()
|
||||
})
|
||||
|
||||
function createHandlers(
|
||||
decision: GpuFallbackRecoveredLaunchDecision = 'keep-safe',
|
||||
overrides: Partial<GpuFallbackRecoveredLaunchHandlers> = {}
|
||||
): { handlers: GpuFallbackRecoveredLaunchHandlers; order: string[] } {
|
||||
const order: string[] = []
|
||||
const handlers: GpuFallbackRecoveredLaunchHandlers = {
|
||||
isQuitting: () => false,
|
||||
prompt: vi.fn(async () => {
|
||||
order.push('prompt')
|
||||
return decision
|
||||
}),
|
||||
confirmSafeGraphics: vi.fn(() => order.push('confirm')),
|
||||
clearSafeGraphics: vi.fn(() => order.push('clear')),
|
||||
onPromptFailed: vi.fn(),
|
||||
onSafeGraphicsKept: vi.fn(() => order.push('kept')),
|
||||
restartWithHardware: vi.fn(() => order.push('restart')),
|
||||
...overrides
|
||||
}
|
||||
return { handlers, order }
|
||||
}
|
||||
|
||||
describe('promptForGpuFallbackRecoveredLaunch', () => {
|
||||
it('defaults dismissal to the stable safe-graphics choice', async () => {
|
||||
const parentWindow = { id: 1 }
|
||||
showMessageBoxMock.mockResolvedValue({ response: 0 })
|
||||
|
||||
await expect(promptForGpuFallbackRecoveredLaunch(parentWindow as never)).resolves.toBe(
|
||||
'keep-safe'
|
||||
)
|
||||
expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, {
|
||||
type: 'info',
|
||||
buttons: ['Keep Safe Graphics Mode', 'Try Hardware Acceleration'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: 'Safe Graphics Mode is Active',
|
||||
message: 'Orca recovered in Safe Graphics Mode.',
|
||||
detail:
|
||||
'Safe Graphics Mode was enabled after repeated graphics crashes. Keep it for stability, or restart and try hardware acceleration again.'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the explicit hardware retry choice', async () => {
|
||||
showMessageBoxMock.mockResolvedValue({ response: 1 })
|
||||
await expect(promptForGpuFallbackRecoveredLaunch()).resolves.toBe('retry-hardware')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleGpuFallbackRecoveredLaunch', () => {
|
||||
it('confirms safe graphics so later launches do not prompt again', async () => {
|
||||
const { handlers, order } = createHandlers()
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(order).toEqual(['prompt', 'confirm', 'kept'])
|
||||
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.restartWithHardware).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the marker before restarting with hardware acceleration', async () => {
|
||||
const { handlers, order } = createHandlers('retry-hardware')
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(order).toEqual(['prompt', 'clear', 'restart'])
|
||||
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the unconfirmed marker intact when the prompt fails', async () => {
|
||||
const error = new Error('dialog failed')
|
||||
const { handlers } = createHandlers('keep-safe', {
|
||||
prompt: vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
})
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(handlers.onPromptFailed).toHaveBeenCalledWith(error)
|
||||
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mutate the marker when shutdown starts while the prompt is open', async () => {
|
||||
const { handlers } = createHandlers('retry-hardware', { isQuitting: () => true })
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.restartWithHardware).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recovered safe-graphics production wiring', () => {
|
||||
it('prompts only after the recovered window is shown and persists both consent states', () => {
|
||||
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
expect(source).toMatch(
|
||||
/window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { dialog, type BrowserWindow, type MessageBoxOptions } from 'electron'
|
||||
|
||||
export type GpuFallbackRecoveredLaunchDecision = 'keep-safe' | 'retry-hardware'
|
||||
|
||||
const GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS: MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['Keep Safe Graphics Mode', 'Try Hardware Acceleration'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: 'Safe Graphics Mode is Active',
|
||||
message: 'Orca recovered in Safe Graphics Mode.',
|
||||
detail:
|
||||
'Safe Graphics Mode was enabled after repeated graphics crashes. Keep it for stability, or restart and try hardware acceleration again.'
|
||||
}
|
||||
|
||||
export async function promptForGpuFallbackRecoveredLaunch(
|
||||
parentWindow?: BrowserWindow
|
||||
): Promise<GpuFallbackRecoveredLaunchDecision> {
|
||||
const { response } = parentWindow
|
||||
? await dialog.showMessageBox(parentWindow, GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS)
|
||||
: await dialog.showMessageBox(GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS)
|
||||
return response === 1 ? 'retry-hardware' : 'keep-safe'
|
||||
}
|
||||
|
||||
export type GpuFallbackRecoveredLaunchHandlers = {
|
||||
isQuitting: () => boolean
|
||||
prompt: () => Promise<GpuFallbackRecoveredLaunchDecision>
|
||||
confirmSafeGraphics: () => void
|
||||
clearSafeGraphics: () => void
|
||||
onPromptFailed: (error: unknown) => void
|
||||
onSafeGraphicsKept: () => void
|
||||
restartWithHardware: () => void
|
||||
}
|
||||
|
||||
/** Resolves consent after an unanswered crash-time prompt recovered into safe graphics. */
|
||||
export async function handleGpuFallbackRecoveredLaunch(
|
||||
handlers: GpuFallbackRecoveredLaunchHandlers
|
||||
): Promise<void> {
|
||||
let decision: GpuFallbackRecoveredLaunchDecision
|
||||
try {
|
||||
decision = await handlers.prompt()
|
||||
} catch (error) {
|
||||
handlers.onPromptFailed(error)
|
||||
return
|
||||
}
|
||||
if (handlers.isQuitting()) {
|
||||
return
|
||||
}
|
||||
if (decision === 'retry-hardware') {
|
||||
handlers.clearSafeGraphics()
|
||||
handlers.restartWithHardware()
|
||||
return
|
||||
}
|
||||
handlers.confirmSafeGraphics()
|
||||
handlers.onSafeGraphicsKept()
|
||||
}
|
||||
@@ -13,15 +13,13 @@ function makeBackend(): {
|
||||
const resolveDeviceId = vi.fn(async () => {
|
||||
throw new Error('shutdown AVD cannot resolve before boot')
|
||||
})
|
||||
const startSession = vi.fn(
|
||||
async (): Promise<EmulatorSessionInfo> => ({
|
||||
deviceUdid: 'emulator-5554',
|
||||
streamUrl: 'scrcpy://emulator-5554',
|
||||
wsUrl: '',
|
||||
streamCodec: 'h264',
|
||||
backend: 'android'
|
||||
})
|
||||
)
|
||||
const startSession = vi.fn(async (): Promise<EmulatorSessionInfo> => ({
|
||||
deviceUdid: 'emulator-5554',
|
||||
streamUrl: 'scrcpy://emulator-5554',
|
||||
wsUrl: '',
|
||||
streamCodec: 'h264',
|
||||
backend: 'android'
|
||||
}))
|
||||
const stopHelperForDevice = vi.fn(async () => {})
|
||||
const shutdownDevice = vi.fn(async () => {})
|
||||
const backend: EmulatorBackend = {
|
||||
|
||||
@@ -218,6 +218,21 @@ export function parseAndFilterSearchRefDetails(
|
||||
.slice(0, Math.max(0, limit))
|
||||
}
|
||||
|
||||
export function resolveConfiguredRemoteBranchName(
|
||||
fullRef: string,
|
||||
longestFirstRemoteNames: readonly string[]
|
||||
): string | null {
|
||||
const remoteRefPrefix = 'refs/remotes/'
|
||||
if (!fullRef.startsWith(remoteRefPrefix)) {
|
||||
return null
|
||||
}
|
||||
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
|
||||
const remote = longestFirstRemoteNames.find((candidate) =>
|
||||
remoteAndBranch.startsWith(`${candidate}/`)
|
||||
)
|
||||
return remote ? remoteAndBranch.slice(remote.length + 1) || null : null
|
||||
}
|
||||
|
||||
export function resolveLocalBranchName(
|
||||
fullRef: string,
|
||||
shortRef: string,
|
||||
@@ -227,11 +242,11 @@ export function resolveLocalBranchName(
|
||||
if (!fullRef.startsWith(remoteRefPrefix)) {
|
||||
return shortRef
|
||||
}
|
||||
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
|
||||
const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
|
||||
if (remote) {
|
||||
return remoteAndBranch.slice(remote.length + 1)
|
||||
const configuredBranch = resolveConfiguredRemoteBranchName(fullRef, remotes)
|
||||
if (configuredBranch) {
|
||||
return configuredBranch
|
||||
}
|
||||
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
|
||||
return remoteAndBranch.split('/').slice(1).join('/') || shortRef
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
import { listRemoteNames, resolveLocalBranchName } from './repo-base-ref-search'
|
||||
import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref'
|
||||
import { resolveConfiguredRemoteBranchName } from './repo-base-ref-search'
|
||||
import { gitExecOptions, type GitExec, type LocalGitExecOptions } from './repo-default-base-ref'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
export type BranchConflictKind = 'local' | 'remote'
|
||||
|
||||
async function hasGitRefAsync(
|
||||
path: string,
|
||||
ref: string,
|
||||
options: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
async function hasGitRefAsync(exec: GitExec, ref: string): Promise<boolean> {
|
||||
try {
|
||||
await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options))
|
||||
return true
|
||||
const { stdout } = await exec(['rev-parse', '--verify', ref])
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBranchConflictKind(
|
||||
path: string,
|
||||
async function listRemoteNamesViaExec(exec: GitExec): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await exec(['remote'])
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Run branch-conflict policy through the host that owns Git execution. */
|
||||
export async function getBranchConflictKindViaExec(
|
||||
exec: GitExec,
|
||||
branchName: string,
|
||||
allowedBaseRef?: string,
|
||||
options: LocalGitExecOptions = {}
|
||||
allowedBaseRef?: string
|
||||
): Promise<BranchConflictKind | null> {
|
||||
if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) {
|
||||
if (await hasGitRefAsync(exec, `refs/heads/${branchName}`)) {
|
||||
return 'local'
|
||||
}
|
||||
|
||||
try {
|
||||
const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length)
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['for-each-ref', '--format=%(refname)', 'refs/remotes'],
|
||||
gitExecOptions(path, options)
|
||||
)
|
||||
const hasRemoteConflict = stdout.split('\n').some((ref) => {
|
||||
const remoteNames = await listRemoteNamesViaExec(exec)
|
||||
const { stdout } = await exec(['for-each-ref', '--format=%(refname)', 'refs/remotes'])
|
||||
const hasRemoteConflict = stdout.split(/\r?\n/).some((ref) => {
|
||||
const trimmed = ref.trim()
|
||||
if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) {
|
||||
return false
|
||||
}
|
||||
const shortRef = trimmed.replace(/^refs\/remotes\//, '')
|
||||
return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName
|
||||
return resolveConfiguredRemoteBranchName(trimmed, remoteNames) === branchName
|
||||
})
|
||||
|
||||
return hasRemoteConflict ? 'remote' : null
|
||||
@@ -48,6 +53,20 @@ export async function getBranchConflictKind(
|
||||
}
|
||||
}
|
||||
|
||||
export function getBranchConflictKind(
|
||||
path: string,
|
||||
branchName: string,
|
||||
allowedBaseRef?: string,
|
||||
options: LocalGitExecOptions = {}
|
||||
): Promise<BranchConflictKind | null> {
|
||||
const execOptions = gitExecOptions(path, options)
|
||||
return getBranchConflictKindViaExec(
|
||||
(argv) => gitExecFileAsync(argv, execOptions),
|
||||
branchName,
|
||||
allowedBaseRef
|
||||
)
|
||||
}
|
||||
|
||||
function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean {
|
||||
if (!allowedBaseRef) {
|
||||
return false
|
||||
|
||||
@@ -215,6 +215,7 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
|
||||
it('allows creating a local branch from the selected matching remote base ref', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, 'origin/feature/something', sha)
|
||||
|
||||
const result = await getBranchConflictKind(
|
||||
@@ -228,6 +229,10 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
|
||||
it('still reports a remote conflict for a different tracking ref with the same branch name', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
// Why register both: a ref only tracks a branch when a remote actually owns its
|
||||
// prefix, and this case is about a second *tracking* ref, not an orphan.
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
|
||||
createRemoteRef(tmpDir, 'origin/feature/something', sha)
|
||||
createRemoteRef(tmpDir, 'upstream/feature/something', sha)
|
||||
|
||||
@@ -236,6 +241,38 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
).toBe('remote')
|
||||
})
|
||||
|
||||
it('ignores a ref whose prefix matches no configured remote', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, 'mimic-fork/my-task', sha)
|
||||
|
||||
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull()
|
||||
})
|
||||
|
||||
it('stops treating leftover refs from a removed remote as conflicts', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
git(tmpDir, ['remote', 'add', 'fork', 'https://example.invalid/fork.git'])
|
||||
createRemoteRef(tmpDir, 'fork/my-task', sha)
|
||||
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('remote')
|
||||
|
||||
// Why not `remote remove`: that prunes the tracking refs too, which would pass
|
||||
// with or without the ownership guard. Dropping only the config leaves the
|
||||
// orphan ref behind, which is the state this guard exists for.
|
||||
git(tmpDir, ['config', '--remove-section', 'remote.fork'])
|
||||
|
||||
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull()
|
||||
})
|
||||
|
||||
it('still reports a local conflict when an unrelated ref shares the name', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, 'mimic-fork/my-task', sha)
|
||||
git(tmpDir, ['branch', 'my-task'])
|
||||
|
||||
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('local')
|
||||
})
|
||||
|
||||
it('reports remote conflicts when the remote name contains a slash', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git'])
|
||||
|
||||
@@ -149,19 +149,17 @@ export async function getPRComments(
|
||||
if (!pullRequest) {
|
||||
console.warn('Review threads response missing pullRequest; keeping REST results')
|
||||
}
|
||||
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map(
|
||||
(c): PRComment => ({
|
||||
id: c.databaseId,
|
||||
author: c.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: c.author?.avatarUrl ?? '',
|
||||
body: c.body ?? '',
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups)
|
||||
})
|
||||
)
|
||||
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map((c): PRComment => ({
|
||||
id: c.databaseId,
|
||||
author: c.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: c.author?.avatarUrl ?? '',
|
||||
body: c.body ?? '',
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups)
|
||||
}))
|
||||
if (graphQLIssueComments.length > 0) {
|
||||
issueComments = graphQLIssueComments
|
||||
}
|
||||
@@ -169,19 +167,17 @@ export async function getPRComments(
|
||||
graphQLReviewSummaries = pullRequest
|
||||
? (pullRequest.reviews?.nodes ?? [])
|
||||
.filter((review) => review.body?.trim())
|
||||
.map(
|
||||
(review): PRComment => ({
|
||||
id: review.databaseId,
|
||||
author: review.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: review.author?.avatarUrl ?? '',
|
||||
body: review.body,
|
||||
createdAt: review.createdAt,
|
||||
url: review.url,
|
||||
isBot: review.author?.__typename === 'Bot',
|
||||
reactionSubjectId: review.id,
|
||||
reactions: mapGraphQLReactionGroups(review.reactionGroups)
|
||||
})
|
||||
)
|
||||
.map((review): PRComment => ({
|
||||
id: review.databaseId,
|
||||
author: review.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: review.author?.avatarUrl ?? '',
|
||||
body: review.body,
|
||||
createdAt: review.createdAt,
|
||||
url: review.url,
|
||||
isBot: review.author?.__typename === 'Bot',
|
||||
reactionSubjectId: review.id,
|
||||
reactions: mapGraphQLReactionGroups(review.reactionGroups)
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const threads = pullRequest?.reviewThreads?.nodes ?? []
|
||||
@@ -228,17 +224,15 @@ export async function getPRComments(
|
||||
} else if (reviewsResult.status === 'fulfilled') {
|
||||
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
|
||||
.filter((r) => r.body?.trim())
|
||||
.map(
|
||||
(r): PRComment => ({
|
||||
id: r.id,
|
||||
author: r.user?.login ?? 'ghost',
|
||||
authorAvatarUrl: r.user?.avatar_url ?? '',
|
||||
body: r.body,
|
||||
createdAt: r.submitted_at,
|
||||
url: r.html_url,
|
||||
isBot: r.user?.type === 'Bot'
|
||||
})
|
||||
)
|
||||
.map((r): PRComment => ({
|
||||
id: r.id,
|
||||
author: r.user?.login ?? 'ghost',
|
||||
authorAvatarUrl: r.user?.avatar_url ?? '',
|
||||
body: r.body,
|
||||
createdAt: r.submitted_at,
|
||||
url: r.html_url,
|
||||
isBot: r.user?.type === 'Bot'
|
||||
}))
|
||||
} else {
|
||||
console.warn('Failed to fetch review summaries:', reviewsResult.reason)
|
||||
}
|
||||
|
||||
@@ -238,16 +238,14 @@ export async function getIssueBodyAndComments(
|
||||
}[]
|
||||
assignees?: { login: string }[]
|
||||
}
|
||||
const comments = (data.comments ?? []).map(
|
||||
(comment, index): PRComment => ({
|
||||
id: index,
|
||||
author: comment.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: '',
|
||||
body: comment.body ?? '',
|
||||
createdAt: comment.createdAt,
|
||||
url: comment.url ?? ''
|
||||
})
|
||||
)
|
||||
const comments = (data.comments ?? []).map((comment, index): PRComment => ({
|
||||
id: index,
|
||||
author: comment.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: '',
|
||||
body: comment.body ?? '',
|
||||
createdAt: comment.createdAt,
|
||||
url: comment.url ?? ''
|
||||
}))
|
||||
return {
|
||||
body: data.body ?? '',
|
||||
comments,
|
||||
|
||||
@@ -22,6 +22,19 @@ describe('parseOrcaYaml', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a project requirement to finish setup before agent startup', () => {
|
||||
const yaml = [
|
||||
'setupAgentStartupPolicy: wait-for-setup',
|
||||
'scripts:',
|
||||
' setup: node install-project-skills.mjs'
|
||||
].join('\n')
|
||||
|
||||
expect(parseOrcaYaml(yaml)).toEqual({
|
||||
scripts: { setup: 'node install-project-skills.mjs' },
|
||||
setupAgentStartupPolicy: 'wait-for-setup'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses YAML with archive script only', () => {
|
||||
const yaml = `scripts:\n archive: |\n echo "archiving"\n`
|
||||
const result = parseOrcaYaml(yaml)
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('createSetupRunnerScript', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
|
||||
it('waits when either local or project policy requires completed setup', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
|
||||
const { createSetupRunnerScript } = await import('./worktree-runner-script')
|
||||
@@ -318,6 +318,26 @@ describe('createSetupRunnerScript', () => {
|
||||
createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
|
||||
.waitForAgentStartup
|
||||
).toBe(true)
|
||||
expect(
|
||||
createSetupRunnerScript(
|
||||
makeRepo('start-immediately'),
|
||||
'/test/worktree',
|
||||
'echo setup',
|
||||
undefined,
|
||||
undefined,
|
||||
'wait-for-setup'
|
||||
).waitForAgentStartup
|
||||
).toBe(true)
|
||||
expect(
|
||||
createSetupRunnerScript(
|
||||
makeRepo('wait-for-setup'),
|
||||
'/test/worktree',
|
||||
'echo setup',
|
||||
undefined,
|
||||
undefined,
|
||||
'start-immediately'
|
||||
).waitForAgentStartup
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('marks setup-runner terminals for the always-on credential guard', async () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export function hasHooksFile(repoPath: string): boolean {
|
||||
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
|
||||
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
|
||||
'scripts',
|
||||
'setupAgentStartupPolicy',
|
||||
'issueCommand',
|
||||
'defaultTabs',
|
||||
'environmentRecipes',
|
||||
|
||||
+131
-49
@@ -154,6 +154,7 @@ import {
|
||||
configureDevUserDataPath,
|
||||
configureOrcaUserDataPathEnv,
|
||||
disableUnsupportedChromiumFeatures,
|
||||
optOutOfHiddenPageWakeUpThrottling,
|
||||
enableMainProcessGpuFeatures,
|
||||
installDevParentDisconnectQuit,
|
||||
installDevParentSignalQuit,
|
||||
@@ -170,8 +171,10 @@ import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
|
||||
import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv'
|
||||
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
|
||||
import {
|
||||
clearGpuFallbackMarker,
|
||||
readActiveGpuFallbackMarker,
|
||||
writeGpuFallbackMarker,
|
||||
type GpuFallbackMarker,
|
||||
type GpuFallbackEnvironment,
|
||||
type WindowsGpuFallbackEnvironment
|
||||
} from './startup/gpu-fallback-marker'
|
||||
@@ -182,10 +185,13 @@ import {
|
||||
GpuCrashFallbackTracker,
|
||||
isGpuFallbackCrashCandidate
|
||||
} from './crash-reporting/gpu-crash-fallback-decision'
|
||||
import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt'
|
||||
import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement'
|
||||
import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics'
|
||||
import {
|
||||
promptForGpuFallbackRestart,
|
||||
type GpuFallbackRestartDecision
|
||||
} from './crash-reporting/gpu-fallback-restart-prompt'
|
||||
handleGpuFallbackRecoveredLaunch,
|
||||
promptForGpuFallbackRecoveredLaunch
|
||||
} from './crash-reporting/gpu-fallback-recovered-launch'
|
||||
import {
|
||||
shouldSuppressDevEducation,
|
||||
suppressDevEducationForStore
|
||||
@@ -462,8 +468,19 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
|
||||
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
||||
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
|
||||
})
|
||||
let activeGpuFallbackMarker: GpuFallbackMarker | null = null
|
||||
let gpuFallbackActiveThisLaunch = false
|
||||
let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
|
||||
const gpuCrashDiagnostics =
|
||||
process.platform === 'win32'
|
||||
? new GpuCrashDiagnosticsRecorder({
|
||||
provider: {
|
||||
getGPUInfo: (infoType) => app.getGPUInfo(infoType),
|
||||
getGPUFeatureStatus: () => app.getGPUFeatureStatus()
|
||||
},
|
||||
recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data)
|
||||
})
|
||||
: null
|
||||
let localPtyStartupReady: Promise<void> = Promise.resolve()
|
||||
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
|
||||
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
@@ -550,6 +567,7 @@ function updateGpuAccelerationAboutPanel(): void {
|
||||
|
||||
app.on('gpu-info-update', () => {
|
||||
gpuFeatureStatus = app.getGPUFeatureStatus()
|
||||
gpuCrashDiagnostics?.warm()
|
||||
if (app.isReady()) {
|
||||
updateGpuAccelerationAboutPanel()
|
||||
}
|
||||
@@ -982,6 +1000,8 @@ if (hasSingleInstanceLock) {
|
||||
...getMainProcessLifecycleIdentity()
|
||||
})
|
||||
disableUnsupportedChromiumFeatures()
|
||||
// Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below.
|
||||
optOutOfHiddenPageWakeUpThrottling()
|
||||
configureElectronNetworkCompatibility()
|
||||
enableRendererHeapHeadroom()
|
||||
maybeApplyGpuFallbackForThisLaunch()
|
||||
@@ -1573,6 +1593,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
|
||||
})
|
||||
window.once('show', () => {
|
||||
logStartupMilestone('window-shown')
|
||||
void presentGpuFallbackRecoveredLaunchPrompt(window)
|
||||
})
|
||||
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
|
||||
trayCreateFallback.unref?.()
|
||||
@@ -1859,7 +1880,25 @@ function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | nul
|
||||
return { ...environment, platform: 'win32' }
|
||||
}
|
||||
|
||||
// Why: read the GPU-fallback marker before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
|
||||
// Writes both crash-time and post-recovery consent states through one build-scoped path.
|
||||
function persistGpuFallbackMarker(
|
||||
userDataPath: string,
|
||||
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean }
|
||||
): boolean {
|
||||
const environment = getWindowsGpuFallbackEnvironment()
|
||||
if (!environment) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
writeGpuFallbackMarker(userDataPath, info, environment)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[gpu-fallback] failed to persist marker:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
|
||||
function maybeApplyGpuFallbackForThisLaunch(): void {
|
||||
if (isServeMode || process.platform !== 'win32') {
|
||||
return
|
||||
@@ -1868,6 +1907,7 @@ function maybeApplyGpuFallbackForThisLaunch(): void {
|
||||
if (!marker) {
|
||||
return
|
||||
}
|
||||
activeGpuFallbackMarker = marker
|
||||
app.disableHardwareAcceleration()
|
||||
const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform)
|
||||
gpuFallbackActiveThisLaunch = true
|
||||
@@ -1879,64 +1919,104 @@ function maybeApplyGpuFallbackForThisLaunch(): void {
|
||||
})
|
||||
}
|
||||
|
||||
async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise<void> {
|
||||
const marker = activeGpuFallbackMarker
|
||||
if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) {
|
||||
return
|
||||
}
|
||||
// One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries.
|
||||
activeGpuFallbackMarker = null
|
||||
const userDataPath = app.getPath('userData')
|
||||
await handleGpuFallbackRecoveredLaunch({
|
||||
isQuitting: () => isQuitting,
|
||||
prompt: () => promptForGpuFallbackRecoveredLaunch(window),
|
||||
confirmSafeGraphics: () => {
|
||||
persistGpuFallbackMarker(userDataPath, {
|
||||
engagedAt: marker.engagedAt,
|
||||
crashesInWindow: marker.crashesInWindow,
|
||||
userConfirmed: true
|
||||
})
|
||||
},
|
||||
clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath),
|
||||
onPromptFailed: (error) =>
|
||||
console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error),
|
||||
onSafeGraphicsKept: () =>
|
||||
recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', {
|
||||
crashesInWindow: marker.crashesInWindow
|
||||
}),
|
||||
restartWithHardware: () => {
|
||||
isQuitting = true
|
||||
relaunchApp('gpu-fallback', {
|
||||
mode: 'hardware-retry',
|
||||
crashesInWindow: marker.crashesInWindow
|
||||
})
|
||||
destroySystemTray()
|
||||
app.exit(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering.
|
||||
async function handleGpuChildCrash(reason: string, exitCode: number | null): Promise<void> {
|
||||
async function handleGpuChildCrash(
|
||||
reason: string,
|
||||
exitCode: number | null,
|
||||
crashedAt: number
|
||||
): Promise<void> {
|
||||
// Software rendering already active or shutting down: nothing more to do.
|
||||
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
|
||||
return
|
||||
}
|
||||
const result = gpuCrashFallbackTracker.recordGpuCrash(performance.now())
|
||||
const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt)
|
||||
if (!result.shouldEngageFallback) {
|
||||
return
|
||||
}
|
||||
recordCrashBreadcrumb('gpu_fallback_engaged', {
|
||||
reason,
|
||||
exitCode,
|
||||
crashesInWindow: result.crashesInWindow
|
||||
})
|
||||
const engagedAt = Date.now()
|
||||
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
||||
let restartDecision: GpuFallbackRestartDecision
|
||||
try {
|
||||
restartDecision = await promptForGpuFallbackRestart(window)
|
||||
} catch (error) {
|
||||
console.warn('[gpu-fallback] failed to show restart prompt:', error)
|
||||
return
|
||||
}
|
||||
const fallbackData = {
|
||||
processReason: reason,
|
||||
exitCode,
|
||||
crashesInWindow: result.crashesInWindow
|
||||
}
|
||||
if (isQuitting) {
|
||||
return
|
||||
}
|
||||
if (restartDecision !== 'restart') {
|
||||
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData)
|
||||
return
|
||||
}
|
||||
const environment = getWindowsGpuFallbackEnvironment()
|
||||
if (!environment) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
writeGpuFallbackMarker(
|
||||
app.getPath('userData'),
|
||||
{
|
||||
engagedAt,
|
||||
crashesInWindow: result.crashesInWindow
|
||||
const userDataPath = app.getPath('userData')
|
||||
await engageGpuFallbackAfterCrashBurst(
|
||||
{ reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() },
|
||||
{
|
||||
isQuitting: () => isQuitting,
|
||||
onEngaged: (engagement) =>
|
||||
recordCrashBreadcrumb('gpu_fallback_engaged', {
|
||||
reason: engagement.reason,
|
||||
exitCode: engagement.exitCode,
|
||||
crashesInWindow: engagement.crashesInWindow
|
||||
}),
|
||||
persistMarker: (engagement) =>
|
||||
persistGpuFallbackMarker(userDataPath, {
|
||||
engagedAt: engagement.engagedAt,
|
||||
crashesInWindow: engagement.crashesInWindow,
|
||||
userConfirmed: false
|
||||
}),
|
||||
confirmMarker: (engagement) => {
|
||||
persistGpuFallbackMarker(userDataPath, {
|
||||
engagedAt: engagement.engagedAt,
|
||||
crashesInWindow: engagement.crashesInWindow,
|
||||
userConfirmed: true
|
||||
})
|
||||
},
|
||||
environment
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[gpu-fallback] failed to persist marker:', error)
|
||||
return
|
||||
}
|
||||
isQuitting = true
|
||||
relaunchApp('gpu-fallback', fallbackData)
|
||||
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
|
||||
destroySystemTray()
|
||||
app.exit(0)
|
||||
clearMarker: () => clearGpuFallbackMarker(userDataPath),
|
||||
promptForRestart: () =>
|
||||
promptForGpuFallbackRestart(
|
||||
mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
||||
),
|
||||
onPromptFailed: (error) =>
|
||||
console.warn('[gpu-fallback] failed to show restart prompt:', error),
|
||||
onRestartDeferred: () =>
|
||||
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData),
|
||||
restartIntoSafeGraphics: () => {
|
||||
isQuitting = true
|
||||
relaunchApp('gpu-fallback', fallbackData)
|
||||
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
|
||||
destroySystemTray()
|
||||
app.exit(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordProcessGoneCrash(
|
||||
@@ -3155,7 +3235,9 @@ void app.whenReady().then(async () => {
|
||||
reason: details.reason
|
||||
})
|
||||
) {
|
||||
void handleGpuChildCrash(details.reason, details.exitCode ?? null)
|
||||
const crashedAt = performance.now()
|
||||
void gpuCrashDiagnostics?.record()
|
||||
void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
|
||||
describe('queueWatcherEvents', () => {
|
||||
function events(count: number): WatcherEvent[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index): WatcherEvent => ({ type: 'update', path: `/repo/file-${index}.ts` })
|
||||
)
|
||||
return Array.from({ length: count }, (_, index): WatcherEvent => ({
|
||||
type: 'update',
|
||||
path: `/repo/file-${index}.ts`
|
||||
}))
|
||||
}
|
||||
|
||||
it('keeps precise events while the batch remains under the overflow limit', () => {
|
||||
|
||||
@@ -73,10 +73,10 @@ describe('local filesystem watcher large batches', () => {
|
||||
{ worktreePath }
|
||||
)
|
||||
|
||||
const events = Array.from(
|
||||
{ length: 200_000 },
|
||||
(_, index): WatcherEvent => ({ type: 'delete', path: join(worktreePath, `file-${index}`) })
|
||||
)
|
||||
const events = Array.from({ length: 200_000 }, (_, index): WatcherEvent => ({
|
||||
type: 'delete',
|
||||
path: join(worktreePath, `file-${index}`)
|
||||
}))
|
||||
|
||||
expect(() => watcherCallback?.(null, events)).not.toThrow()
|
||||
await closeAllWatchers()
|
||||
@@ -97,13 +97,10 @@ describe('local filesystem watcher large batches', () => {
|
||||
await handlers['fs:watchWorktree']({ sender }, { worktreePath })
|
||||
watcherCallback?.(
|
||||
null,
|
||||
Array.from(
|
||||
{ length: 5_001 },
|
||||
(_, index): WatcherEvent => ({
|
||||
type: 'update',
|
||||
path: join(worktreePath, `file-${index}.txt`)
|
||||
})
|
||||
)
|
||||
Array.from({ length: 5_001 }, (_, index): WatcherEvent => ({
|
||||
type: 'update',
|
||||
path: join(worktreePath, `file-${index}.txt`)
|
||||
}))
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
@@ -107,13 +107,10 @@ describe('remote filesystem watcher batching', () => {
|
||||
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
|
||||
|
||||
watchCallbacks[0](
|
||||
Array.from(
|
||||
{ length: 6_000 },
|
||||
(_unused, index): FsChangeEvent => ({
|
||||
kind: 'update',
|
||||
absolutePath: `${WORKTREE_PATH}/file-${index}.ts`
|
||||
})
|
||||
)
|
||||
Array.from({ length: 6_000 }, (_unused, index): FsChangeEvent => ({
|
||||
kind: 'update',
|
||||
absolutePath: `${WORKTREE_PATH}/file-${index}.ts`
|
||||
}))
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
|
||||
@@ -169,17 +169,13 @@ export function registerOrcaProfileHandlers(
|
||||
store: Store,
|
||||
options: RegisterOrcaProfileHandlersOptions = {}
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:list',
|
||||
(): OrcaProfileListResult => ({
|
||||
...getOrcaProfileListState(),
|
||||
multiProfileUi: isMultiProfileUiEnabled()
|
||||
})
|
||||
)
|
||||
ipcMain.handle('orcaProfiles:list', (): OrcaProfileListResult => ({
|
||||
...getOrcaProfileListState(),
|
||||
multiProfileUi: isMultiProfileUiEnabled()
|
||||
}))
|
||||
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:authStatus',
|
||||
(): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
|
||||
ipcMain.handle('orcaProfiles:authStatus', (): OrcaProfileAuthStatus =>
|
||||
getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
+2
-3
@@ -81,9 +81,8 @@ export function registerPetHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'pet:importPetBundle',
|
||||
async (event): Promise<CustomPet | null> => importPetBundle(event)
|
||||
ipcMain.handle('pet:importPetBundle', async (event): Promise<CustomPet | null> =>
|
||||
importPetBundle(event)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -182,13 +182,10 @@ describe('createRemoteWatcherEventBatch', () => {
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
batch.push(
|
||||
Array.from(
|
||||
{ length: 50 },
|
||||
(_unused, index): FsChangeEvent => ({
|
||||
kind: 'update',
|
||||
absolutePath: `${ROOT}/wave-${i}-${index}.ts`
|
||||
})
|
||||
)
|
||||
Array.from({ length: 50 }, (_unused, index): FsChangeEvent => ({
|
||||
kind: 'update',
|
||||
absolutePath: `${ROOT}/wave-${i}-${index}.ts`
|
||||
}))
|
||||
)
|
||||
}
|
||||
vi.advanceTimersByTime(150)
|
||||
|
||||
@@ -107,7 +107,7 @@ export class SshPtyModelAdmissionPressure {
|
||||
if (options.disposed) {
|
||||
return
|
||||
}
|
||||
for (let index = 0; index < this.entries.length; ) {
|
||||
for (let index = 0; index < this.entries.length;) {
|
||||
const entry = this.entries[index]!
|
||||
const hasEarlierEntryForPty = this.entries
|
||||
.slice(0, index)
|
||||
|
||||
@@ -447,16 +447,13 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
it('batches fleet progress instead of emitting one IPC payload per row', async () => {
|
||||
const worktrees = [
|
||||
GIT_WORKTREES[0],
|
||||
...Array.from(
|
||||
{ length: 200 },
|
||||
(_, index): GitWorktreeInfo => ({
|
||||
path: `/repo-batch-${index}`,
|
||||
head: `head-${index}`,
|
||||
branch: `refs/heads/batch-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
})
|
||||
)
|
||||
...Array.from({ length: 200 }, (_, index): GitWorktreeInfo => ({
|
||||
path: `/repo-batch-${index}`,
|
||||
head: `head-${index}`,
|
||||
branch: `refs/heads/batch-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}))
|
||||
]
|
||||
listRepoWorktreesMock.mockResolvedValue(worktrees)
|
||||
const onProgress = vi.fn()
|
||||
|
||||
@@ -767,18 +767,15 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
|
||||
it('summarizes large diff-note lists without hitting argument limits', async () => {
|
||||
const diffComments = Array.from(
|
||||
{ length: 150_000 },
|
||||
(_, index): DiffComment => ({
|
||||
id: `comment-${index}`,
|
||||
worktreeId: 'repo-1::/repo-feature',
|
||||
filePath: 'src/file.ts',
|
||||
lineNumber: 12,
|
||||
body: 'Follow up before deleting',
|
||||
createdAt: NOW - index,
|
||||
side: 'modified'
|
||||
})
|
||||
)
|
||||
const diffComments = Array.from({ length: 150_000 }, (_, index): DiffComment => ({
|
||||
id: `comment-${index}`,
|
||||
worktreeId: 'repo-1::/repo-feature',
|
||||
filePath: 'src/file.ts',
|
||||
lineNumber: 12,
|
||||
body: 'Follow up before deleting',
|
||||
createdAt: NOW - index,
|
||||
side: 'modified'
|
||||
}))
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore({
|
||||
|
||||
@@ -127,10 +127,8 @@ export function registerWorkspaceCleanupHandlers(
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:getCachedScan',
|
||||
(): Promise<WorkspaceCleanupScanResult | null> =>
|
||||
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
ipcMain.handle('workspaceCleanup:getCachedScan', (): Promise<WorkspaceCleanupScanResult | null> =>
|
||||
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
|
||||
|
||||
@@ -114,10 +114,8 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
return inFlightScan.promise
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceSpace:getCachedAnalysis',
|
||||
(): Promise<WorkspaceSpaceAnalysis | null> =>
|
||||
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
ipcMain.handle('workspaceSpace:getCachedAnalysis', (): Promise<WorkspaceSpaceAnalysis | null> =>
|
||||
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceSpace:cancel', async (): Promise<boolean> => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSshBranchConflictKind } from './worktree-remote'
|
||||
|
||||
type ConflictProvider = Parameters<typeof getSshBranchConflictKind>[0]
|
||||
|
||||
function providerAnswering(answers: {
|
||||
remotes: string[]
|
||||
localBranchHead?: string
|
||||
remoteRefs: string[]
|
||||
}): ConflictProvider {
|
||||
const exec: ConflictProvider['exec'] = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: `${answers.remotes.join('\n')}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
if (!answers.localBranchHead) {
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
return { stdout: `${answers.localBranchHead}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'for-each-ref') {
|
||||
return { stdout: `${answers.remoteRefs.join('\n')}\n`, stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git ${args.join(' ')}`)
|
||||
})
|
||||
return { exec }
|
||||
}
|
||||
|
||||
describe('getSshBranchConflictKind remote ownership', () => {
|
||||
it('reports a local branch before scanning remote refs', async () => {
|
||||
const provider = providerAnswering({
|
||||
localBranchHead: 'abc123',
|
||||
remotes: ['origin'],
|
||||
remoteRefs: []
|
||||
})
|
||||
|
||||
await expect(
|
||||
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
|
||||
).resolves.toBe('local')
|
||||
})
|
||||
|
||||
it('ignores a ref whose prefix matches no configured remote', async () => {
|
||||
const provider = providerAnswering({
|
||||
remotes: ['origin'],
|
||||
remoteRefs: ['refs/remotes/origin/main', 'refs/remotes/deleted-fork/my-task']
|
||||
})
|
||||
|
||||
await expect(
|
||||
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('still reports a ref owned by a configured remote', async () => {
|
||||
const provider = providerAnswering({
|
||||
remotes: ['origin'],
|
||||
remoteRefs: ['refs/remotes/origin/main', 'refs/remotes/origin/my-task']
|
||||
})
|
||||
|
||||
await expect(
|
||||
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
|
||||
).resolves.toBe('remote')
|
||||
})
|
||||
|
||||
it('prefers the longest configured remote name', async () => {
|
||||
const provider = providerAnswering({
|
||||
remotes: ['foo', 'foo/bar'],
|
||||
remoteRefs: ['refs/remotes/foo/bar/my-task']
|
||||
})
|
||||
|
||||
await expect(
|
||||
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
|
||||
).resolves.toBe('remote')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SetupAgentStartupPolicy } from '../../shared/orca-yaml-hook-types'
|
||||
import type {
|
||||
LocalBaseRefRefreshResult,
|
||||
LocalBaseRefUpdateSuggestion
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
resolveDefaultBaseRefViaExec,
|
||||
resolveDefaultBaseRefWithLocalGit
|
||||
} from '../git/repo'
|
||||
import { getBranchConflictKindViaExec } from '../git/repo-branch-conflict'
|
||||
import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username'
|
||||
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
|
||||
import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe'
|
||||
@@ -838,98 +840,19 @@ async function canCheckoutExistingLocalBranchSsh(
|
||||
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
|
||||
}
|
||||
|
||||
async function listSshRemoteNames(provider: SshGitProvider, repoPath: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await provider.exec(['remote'], repoPath)
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
type SshGitExecutor = Pick<SshGitProvider, 'exec'>
|
||||
|
||||
function isAllowedSshRemoteBaseRef(refName: string, allowedBaseRef: string): boolean {
|
||||
if (!allowedBaseRef) {
|
||||
return false
|
||||
}
|
||||
const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/')
|
||||
? allowedBaseRef
|
||||
: `refs/remotes/${allowedBaseRef}`
|
||||
return refName === normalizedAllowedRef
|
||||
}
|
||||
|
||||
function resolveSshRemoteBranchName(refName: string, remoteNames: string[]): string {
|
||||
const remotePrefix = 'refs/remotes/'
|
||||
if (!refName.startsWith(remotePrefix)) {
|
||||
return refName
|
||||
}
|
||||
const remoteAndBranch = refName.slice(remotePrefix.length)
|
||||
const remote = remoteNames.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
|
||||
if (remote) {
|
||||
return remoteAndBranch.slice(remote.length + 1)
|
||||
}
|
||||
return remoteAndBranch.split('/').slice(1).join('/') || remoteAndBranch
|
||||
}
|
||||
|
||||
async function hasSshRemoteBranchConflict(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
allowedBaseRef: string
|
||||
): Promise<boolean> {
|
||||
const remoteNames = await listSshRemoteNames(provider, repoPath)
|
||||
try {
|
||||
const { stdout } = await provider.exec(
|
||||
['for-each-ref', '--format=%(refname)', 'refs/remotes'],
|
||||
repoPath
|
||||
)
|
||||
return stdout.split(/\r?\n/).some((line) => {
|
||||
const refName = line.trim()
|
||||
if (!refName || /^refs\/remotes\/.+\/HEAD$/.test(refName)) {
|
||||
return false
|
||||
}
|
||||
if (isAllowedSshRemoteBaseRef(refName, allowedBaseRef)) {
|
||||
return false
|
||||
}
|
||||
// Why: `git branch --all --list feature/x` doesn't match `remotes/origin/feature/x`; parse remote refs directly.
|
||||
return resolveSshRemoteBranchName(refName, remoteNames) === branchName
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function hasSshLocalBranchConflict(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await provider.exec(
|
||||
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
|
||||
repoPath
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getSshBranchConflictKind(
|
||||
provider: SshGitProvider,
|
||||
export function getSshBranchConflictKind(
|
||||
provider: SshGitExecutor,
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
allowedBaseRef: string
|
||||
): Promise<'local' | 'remote' | null> {
|
||||
if (await hasSshLocalBranchConflict(provider, repoPath, branchName)) {
|
||||
return 'local'
|
||||
}
|
||||
return (await hasSshRemoteBranchConflict(provider, repoPath, branchName, allowedBaseRef))
|
||||
? 'remote'
|
||||
: null
|
||||
return getBranchConflictKindViaExec(
|
||||
(argv) => provider.exec(argv, repoPath),
|
||||
branchName,
|
||||
allowedBaseRef
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedReviewBranchInput = Pick<
|
||||
@@ -1259,7 +1182,8 @@ async function createRemoteSetupRunnerScript(
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
gitProvider: SshGitProvider,
|
||||
fsProvider: IFilesystemProvider
|
||||
fsProvider: IFilesystemProvider,
|
||||
projectStartupPolicy?: SetupAgentStartupPolicy
|
||||
): Promise<CreateWorktreeResult['setup']> {
|
||||
const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath)
|
||||
// Why: SSH terminals choose their shell on the remote host; local Windows
|
||||
@@ -1281,7 +1205,10 @@ async function createRemoteSetupRunnerScript(
|
||||
return {
|
||||
runnerScriptPath,
|
||||
envVars: getSetupRunnerEnvVars(repo, worktreePath),
|
||||
...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
|
||||
...(shouldWaitForSetupBeforeAgentStartup(
|
||||
repo.hookSettings?.setupAgentStartupPolicy,
|
||||
projectStartupPolicy
|
||||
)
|
||||
? { waitForAgentStartup: true }
|
||||
: {})
|
||||
}
|
||||
@@ -2030,7 +1957,8 @@ export async function createRemoteWorktree(
|
||||
created.path,
|
||||
setupScript,
|
||||
provider,
|
||||
fsProvider
|
||||
fsProvider,
|
||||
yamlHooks?.setupAgentStartupPolicy
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error)
|
||||
@@ -2721,13 +2649,13 @@ export async function createLocalWorktree(
|
||||
try {
|
||||
// Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior.
|
||||
// Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation.
|
||||
// Why: both trailing args are optional — the shell is undefined off Windows.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
setupScript,
|
||||
localWorktreeGitOptionArgs[0],
|
||||
resolveSetupRunnerShell(settings)
|
||||
resolveSetupRunnerShell(settings),
|
||||
createdYamlHooks?.setupAgentStartupPolicy
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
||||
|
||||
@@ -514,10 +514,14 @@ describe('registerWorktreeHandlers', () => {
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
loadHooksMock.mockReturnValue({
|
||||
scripts: { setup: 'pnpm install' },
|
||||
setupAgentStartupPolicy: 'wait-for-setup'
|
||||
})
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
expect(createSetupRunnerScriptMock).not.toHaveBeenCalled()
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
@@ -538,6 +542,14 @@ describe('registerWorktreeHandlers', () => {
|
||||
startupTerminal?: { spawned: boolean; surface?: string }
|
||||
timing?: { phases: { phase: string }[] }
|
||||
}
|
||||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
undefined,
|
||||
'wait-for-setup'
|
||||
)
|
||||
|
||||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
||||
@@ -118,6 +118,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
@@ -169,6 +170,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup # worktree',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(result).toEqual(
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
content: 'scripts:\n setup: pnpm install\n',
|
||||
content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n',
|
||||
isBinary: false
|
||||
}),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -153,7 +153,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
parseOrcaYamlMock.mockReturnValue({
|
||||
scripts: { setup: 'pnpm install' },
|
||||
setupAgentStartupPolicy: 'wait-for-setup'
|
||||
})
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
|
||||
@@ -184,7 +187,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
envVars: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/remote/repo',
|
||||
ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard'
|
||||
})
|
||||
}),
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -467,7 +467,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
'C:\\workspaces\\improve-dashboard',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
setupShell
|
||||
setupShell,
|
||||
undefined
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
setup: {
|
||||
|
||||
@@ -369,6 +369,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup',
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -25,7 +25,7 @@ export function escapeMarkdownLinkDestination(url: string): string | null {
|
||||
}
|
||||
|
||||
let encoded = ''
|
||||
for (let i = 0; i < url.length; ) {
|
||||
for (let i = 0; i < url.length;) {
|
||||
const char = url[i] ?? ''
|
||||
if (char === '%') {
|
||||
const hex = url.slice(i + 1, i + 3)
|
||||
|
||||
@@ -161,7 +161,9 @@ describe('readNativeChatTranscriptCached', () => {
|
||||
await utimes(fileA, when, when)
|
||||
await utimes(fileC, when, when)
|
||||
|
||||
const readText = (result: Awaited<ReturnType<typeof readNativeChatTranscriptCached>>): string =>
|
||||
const readText = (
|
||||
result: Awaited<ReturnType<typeof readNativeChatTranscriptCached>>
|
||||
): string =>
|
||||
'messages' in result
|
||||
? result.messages
|
||||
.flatMap((message) => message.blocks)
|
||||
|
||||
@@ -60,7 +60,8 @@ function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): str
|
||||
})}\n`
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||||
// fs.watch and filesystem mutations use the platform clock; poll observable callbacks to a deadline.
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
@@ -166,13 +167,14 @@ describe('native chat transcript watcher liveness', () => {
|
||||
onReplace: replacements,
|
||||
onAppend: () => {},
|
||||
debounceMs: 0,
|
||||
reconciliationIntervalMs: 20
|
||||
reconciliationIntervalMs: 10_000
|
||||
})
|
||||
await waitFor(() => snapshots.mock.calls.length === 1)
|
||||
|
||||
await writeFile(filePath, prefixAfter + stableTail)
|
||||
const future = new Date(Date.now() + 10_000)
|
||||
await utimes(filePath, future, future)
|
||||
watchCallbacks[0]!('change', 'transcript.jsonl')
|
||||
await waitFor(() =>
|
||||
replacements.mock.calls.flat(2).some((message) => message.id === 'prefix-new')
|
||||
)
|
||||
|
||||
@@ -150,25 +150,23 @@ export function buildOpenCodeUsageRecentSessions(
|
||||
filteredSessions: OpenCodeUsageSession[],
|
||||
limit = 10
|
||||
): OpenCodeUsageSessionRow[] {
|
||||
return filteredSessions.slice(0, limit).map(
|
||||
(session): OpenCodeUsageSessionRow => ({
|
||||
sessionId: session.sessionId,
|
||||
lastActiveAt: session.lastTimestamp,
|
||||
durationMinutes: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
|
||||
60_000
|
||||
)
|
||||
),
|
||||
projectLabel: session.primaryProjectLabel,
|
||||
model: session.primaryModel,
|
||||
events: session.eventCount,
|
||||
inputTokens: session.totalInputTokens,
|
||||
cachedInputTokens: session.totalCachedInputTokens,
|
||||
outputTokens: session.totalOutputTokens,
|
||||
reasoningOutputTokens: session.totalReasoningOutputTokens,
|
||||
totalTokens: session.totalTokens
|
||||
})
|
||||
)
|
||||
return filteredSessions.slice(0, limit).map((session): OpenCodeUsageSessionRow => ({
|
||||
sessionId: session.sessionId,
|
||||
lastActiveAt: session.lastTimestamp,
|
||||
durationMinutes: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
|
||||
60_000
|
||||
)
|
||||
),
|
||||
projectLabel: session.primaryProjectLabel,
|
||||
model: session.primaryModel,
|
||||
events: session.eventCount,
|
||||
inputTokens: session.totalInputTokens,
|
||||
cachedInputTokens: session.totalCachedInputTokens,
|
||||
outputTokens: session.totalOutputTokens,
|
||||
reasoningOutputTokens: session.totalReasoningOutputTokens,
|
||||
totalTokens: session.totalTokens
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('getPiAgentStatusExtensionSource', () => {
|
||||
|
||||
await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2))
|
||||
const payloads = harness.fetchMock.mock.calls.map(
|
||||
([_, init]) => JSON.parse(String(init?.body)).payload
|
||||
([_event, init]) => JSON.parse(String(init?.body)).payload
|
||||
)
|
||||
expect(payloads).toEqual([
|
||||
{ hook_event_name: 'session_start' },
|
||||
@@ -391,7 +391,7 @@ describe('getPiAgentStatusExtensionSource', () => {
|
||||
|
||||
await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(3))
|
||||
expect(
|
||||
harness.fetchMock.mock.calls.map(([_, init]) => JSON.parse(String(init?.body)).payload)
|
||||
harness.fetchMock.mock.calls.map(([_event, init]) => JSON.parse(String(init?.body)).payload)
|
||||
).toEqual([
|
||||
{ hook_event_name: 'agent_start', session_id: 'omp-session-8' },
|
||||
{
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
@@ -8,7 +18,11 @@ import { getPosixOmpShellWrapper } from './omp-shell-wrapper'
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
|
||||
const itWithBash = hasBash ? it : it.skip
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
|
||||
type PosixShell = 'bash' | 'zsh'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
@@ -31,33 +45,38 @@ fi
|
||||
{
|
||||
printf 'PI=%s\\n' "$PI_CODING_AGENT_DIR"
|
||||
printf 'EFFECTIVE=%s\\n' "$agent_dir"
|
||||
printf 'CWD=%s\\n' "$(pwd -P)"
|
||||
i=0
|
||||
for arg in "$@"; do
|
||||
i=$((i + 1))
|
||||
printf 'ARG%s=%s\\n' "$i" "$arg"
|
||||
done
|
||||
} > "$ORCA_CAPTURE_FILE"
|
||||
exit "\${ORCA_TEST_FAKE_OMP_EXIT_CODE:-0}"
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
chmodSync(ompPath, 0o755)
|
||||
}
|
||||
|
||||
async function runInteractiveBashPty(args: {
|
||||
async function runInteractivePosixPty(args: {
|
||||
rcfileContent: string
|
||||
env: Record<string, string>
|
||||
input: string
|
||||
cwd: string
|
||||
shell?: PosixShell
|
||||
}): Promise<string> {
|
||||
const rcfile = join(args.cwd, 'rcfile')
|
||||
writeFileSync(rcfile, args.rcfileContent)
|
||||
const shell = args.shell ?? 'bash'
|
||||
const shellArgs = shell === 'bash' ? ['--noprofile', '--rcfile', rcfile, '-i'] : ['-f', '-i']
|
||||
|
||||
const proc = pty.spawn('bash', ['--noprofile', '--rcfile', rcfile, '-i'], {
|
||||
const proc = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: args.cwd,
|
||||
env: args.env
|
||||
env: { ...args.env, ORCA_TEST_RCFILE: rcfile }
|
||||
})
|
||||
|
||||
let output = ''
|
||||
@@ -72,13 +91,14 @@ async function runInteractiveBashPty(args: {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`timed out waiting for bash PTY output:\n${output}`)),
|
||||
() => reject(new Error(`timed out waiting for ${shell} PTY output:\n${output}`)),
|
||||
5000
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
proc.write(args.input.replace(/\n/g, '\r'))
|
||||
const input = shell === 'zsh' ? `source "$ORCA_TEST_RCFILE"\n${args.input}` : args.input
|
||||
proc.write(input.replace(/\n/g, '\r'))
|
||||
const { exitCode } = await Promise.race([exitPromise, timeoutPromise])
|
||||
expect(exitCode).toBe(0)
|
||||
return output
|
||||
@@ -130,7 +150,7 @@ describePosix('OMP shell wrapper node-pty reproduction', () => {
|
||||
|
||||
const unwrappedCapture = join(tempDir, 'unwrapped-capture')
|
||||
const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi')
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: '',
|
||||
env: makeEnv(unwrappedCapture, unwrappedAfterPi),
|
||||
@@ -148,7 +168,7 @@ exit 0
|
||||
|
||||
const wrappedCapture = join(tempDir, 'wrapped-capture')
|
||||
const wrappedAfterPi = join(tempDir, 'wrapped-after-pi')
|
||||
const wrappedOutput = await runInteractiveBashPty({
|
||||
const wrappedOutput = await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: makeEnv(wrappedCapture, wrappedAfterPi),
|
||||
@@ -182,7 +202,7 @@ exit 0
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, 'config-capture')
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
@@ -237,7 +257,7 @@ exit 0
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, `${subcommand}-capture`)
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
@@ -280,7 +300,7 @@ exit 0
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, 'default-config-capture')
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
@@ -308,4 +328,149 @@ exit 0
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
async function expectStaleCwdRecovery(shell: PosixShell): Promise<void> {
|
||||
const tempDir = makeTempDir()
|
||||
const workspaceDir = join(tempDir, 'workspace')
|
||||
const projectDir = join(workspaceDir, 'project')
|
||||
const homeDir = join(tempDir, 'home')
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const extensionDir = join(tempDir, 'extensions')
|
||||
const logicalProjectLink = join(tempDir, 'logical-project')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
mkdirSync(homeDir)
|
||||
mkdirSync(binDir)
|
||||
mkdirSync(extensionDir)
|
||||
symlinkSync(projectDir, logicalProjectLink)
|
||||
const expectedProjectDir = realpathSync(projectDir)
|
||||
const expectedWorkspaceDir = realpathSync(workspaceDir)
|
||||
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
|
||||
writeFileSync(statusExtension, 'export default {}')
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const unsetPwdCaptureFile = join(tempDir, 'unset-pwd-capture')
|
||||
const deletedLinkCaptureFile = join(tempDir, 'deleted-link-capture')
|
||||
const staleCaptureFile = join(tempDir, 'stale-cwd-capture')
|
||||
const skipCaptureFile = join(tempDir, 'skip-capture')
|
||||
const staleUnsetCaptureFile = join(tempDir, 'stale-unset-capture')
|
||||
const noLogicalCaptureFile = join(tempDir, 'no-logical-capture')
|
||||
const resultFile = join(tempDir, 'stale-cwd-result')
|
||||
const scenarioFile = join(tempDir, 'stale-cwd-scenario')
|
||||
writeFileSync(
|
||||
scenarioFile,
|
||||
`ORCA_CAPTURE_FILE="$ORCA_UNSET_PWD_CAPTURE_FILE"
|
||||
unset PWD
|
||||
omp
|
||||
__orca_test_unset_status=$?
|
||||
if [[ -z "\${PWD+x}" ]]; then
|
||||
__orca_test_pwd_state=unset
|
||||
else
|
||||
__orca_test_pwd_state=set
|
||||
fi
|
||||
builtin cd -- "$ORCA_LOGICAL_PROJECT_LINK"
|
||||
/bin/rm -- "$ORCA_LOGICAL_PROJECT_LINK"
|
||||
ORCA_CAPTURE_FILE="$ORCA_DELETED_LINK_CAPTURE_FILE"
|
||||
omp
|
||||
__orca_test_deleted_link_status=$?
|
||||
builtin cd -P -- "$ORCA_STALE_PROJECT_DIR"
|
||||
ORCA_CAPTURE_FILE="$ORCA_STALE_CAPTURE_FILE"
|
||||
/bin/rm -rf -- "$ORCA_STALE_PROJECT_DIR"
|
||||
/bin/mkdir -p -- "$ORCA_STALE_PROJECT_DIR"
|
||||
omp
|
||||
__orca_test_first_status=$?
|
||||
ORCA_CAPTURE_FILE="$ORCA_SKIP_CAPTURE_FILE"
|
||||
omp --version
|
||||
__orca_test_skip_status=$?
|
||||
if [[ "$PWD" -ef . ]]; then
|
||||
__orca_test_parent_state=live
|
||||
else
|
||||
__orca_test_parent_state=stale
|
||||
fi
|
||||
unset PWD
|
||||
ORCA_CAPTURE_FILE="$ORCA_STALE_UNSET_CAPTURE_FILE"
|
||||
omp
|
||||
__orca_test_stale_unset_status=$?
|
||||
unset ORCA_WORKTREE_PATH ORCA_ROOT_PATH
|
||||
ORCA_CAPTURE_FILE="$ORCA_NO_LOGICAL_CAPTURE_FILE"
|
||||
omp
|
||||
__orca_test_no_logical_status=$?
|
||||
PWD="$ORCA_STALE_PROJECT_DIR"
|
||||
/bin/rm -rf -- "$ORCA_STALE_PROJECT_DIR"
|
||||
omp
|
||||
__orca_test_missing_status=$?
|
||||
{
|
||||
echo "UNSET=$__orca_test_unset_status"
|
||||
echo "PWD=$__orca_test_pwd_state"
|
||||
echo "LINK=$__orca_test_deleted_link_status"
|
||||
echo "FIRST=$__orca_test_first_status"
|
||||
echo "SKIP=$__orca_test_skip_status"
|
||||
echo "PARENT=$__orca_test_parent_state"
|
||||
echo "STALE_UNSET=$__orca_test_stale_unset_status"
|
||||
echo "NO_LOGICAL=$__orca_test_no_logical_status"
|
||||
echo "MISSING=$__orca_test_missing_status"
|
||||
} > "$ORCA_RESULT_FILE"
|
||||
exit 0
|
||||
`
|
||||
)
|
||||
const output = await runInteractivePosixPty({
|
||||
shell,
|
||||
cwd: projectDir,
|
||||
rcfileContent: `cd() { return 97; }
|
||||
${getPosixOmpShellWrapper()}`,
|
||||
env: {
|
||||
INPUTRC: '/dev/null',
|
||||
PROMPT_COMMAND: '',
|
||||
ORCA_STALE_PROJECT_DIR: projectDir,
|
||||
ORCA_LOGICAL_PROJECT_LINK: logicalProjectLink,
|
||||
ORCA_UNSET_PWD_CAPTURE_FILE: unsetPwdCaptureFile,
|
||||
ORCA_DELETED_LINK_CAPTURE_FILE: deletedLinkCaptureFile,
|
||||
ORCA_STALE_CAPTURE_FILE: staleCaptureFile,
|
||||
ORCA_SKIP_CAPTURE_FILE: skipCaptureFile,
|
||||
ORCA_STALE_UNSET_CAPTURE_FILE: staleUnsetCaptureFile,
|
||||
ORCA_NO_LOGICAL_CAPTURE_FILE: noLogicalCaptureFile,
|
||||
ORCA_WORKTREE_PATH: workspaceDir,
|
||||
HOME: homeDir,
|
||||
PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
||||
ORCA_OMP_STATUS_EXTENSION: statusExtension,
|
||||
ORCA_CAPTURE_FILE: staleCaptureFile,
|
||||
ORCA_RESULT_FILE: resultFile,
|
||||
ORCA_SCENARIO_FILE: scenarioFile,
|
||||
ORCA_TEST_FAKE_OMP_EXIT_CODE: '23',
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
input: 'source "$ORCA_SCENARIO_FILE"\n'
|
||||
})
|
||||
|
||||
const unsetPwdCapture = readFileSync(unsetPwdCaptureFile, 'utf8')
|
||||
expect(unsetPwdCapture).toContain(`CWD=${expectedProjectDir}`)
|
||||
const deletedLinkCapture = readFileSync(deletedLinkCaptureFile, 'utf8')
|
||||
expect(deletedLinkCapture).toContain(`CWD=${expectedProjectDir}`)
|
||||
const staleCapture = readFileSync(staleCaptureFile, 'utf8')
|
||||
expect(staleCapture).toContain(`CWD=${expectedProjectDir}`)
|
||||
expect(staleCapture.split('\n').filter((line) => line.startsWith('ARG'))).toEqual([
|
||||
'ARG1=--extension',
|
||||
`ARG2=${statusExtension}`
|
||||
])
|
||||
expect(staleCapture).not.toContain('--cwd')
|
||||
const skipCapture = readFileSync(skipCaptureFile, 'utf8')
|
||||
expect(skipCapture).toContain(`CWD=${expectedProjectDir}`)
|
||||
expect(skipCapture).toContain('ARG1=--version')
|
||||
expect(skipCapture).not.toContain('--extension')
|
||||
const staleUnsetCapture = readFileSync(staleUnsetCaptureFile, 'utf8')
|
||||
expect(staleUnsetCapture).toContain(`CWD=${expectedWorkspaceDir}`)
|
||||
expect(existsSync(noLogicalCaptureFile)).toBe(false)
|
||||
expect(readFileSync(resultFile, 'utf8')).toBe(
|
||||
'UNSET=23\nPWD=unset\nLINK=23\nFIRST=23\nSKIP=23\nPARENT=stale\nSTALE_UNSET=23\nNO_LOGICAL=1\nMISSING=1\n'
|
||||
)
|
||||
expect(output).toContain('no terminal working directory is available')
|
||||
expect(output).toContain('Orca: OMP cannot access the terminal working directory')
|
||||
}
|
||||
|
||||
itWithBash('rebinds a stale Bash cwd before launching OMP', async () => {
|
||||
await expectStaleCwdRecovery('bash')
|
||||
})
|
||||
|
||||
itWithZsh('rebinds a stale Zsh cwd before launching OMP', async () => {
|
||||
await expectStaleCwdRecovery('zsh')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,9 +51,20 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
local __orca_physical_cwd
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "\${PWD:-}" && -d "\${PWD:-}" ]]; then
|
||||
[[ "\${PWD}" -ef . ]]
|
||||
else
|
||||
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
|
||||
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
|
||||
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "\${ORCA_OMP_STATUS_EXTENSION:-}" && -f "\${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "\${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -65,6 +76,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
|
||||
if ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="\${PWD:-\${ORCA_WORKTREE_PATH:-\${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an \`alias omp\` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -1938,16 +1938,14 @@ export class RateLimitService {
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
})
|
||||
).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
})
|
||||
)
|
||||
).catch((err): ProviderRateLimits => ({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
}))
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
@@ -2008,16 +2006,14 @@ export class RateLimitService {
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
})
|
||||
)
|
||||
}).catch((err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
}))
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
@@ -2060,16 +2056,14 @@ export class RateLimitService {
|
||||
const grok = await fetchGrokRateLimits({
|
||||
signal,
|
||||
authReadResult: grokAuthReadResult
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
})
|
||||
)
|
||||
}).catch((err): ProviderRateLimits => ({
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 'error'
|
||||
}))
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
|
||||
@@ -45042,6 +45042,7 @@ describe('OrcaRuntimeService', () => {
|
||||
'/tmp/workspaces/runtime-hook-test',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
@@ -45197,7 +45198,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'C:\\workspaces\\runtime-hook-activate',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
{ family: 'posix' },
|
||||
undefined
|
||||
)
|
||||
expect(result.setup).toMatchObject({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
@@ -45277,6 +45279,7 @@ describe('OrcaRuntimeService', () => {
|
||||
'/tmp/workspaces/runtime-hook-skip',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
@@ -45480,7 +45483,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'C:\\workspaces\\runtime-hook-windowless',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
{ family: 'posix' },
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(result.setupReceipt).toMatchObject({ state: 'running' })
|
||||
|
||||
@@ -6067,22 +6067,18 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
const worktrees = (await this.listResolvedWorktrees())
|
||||
.filter((worktree) => repos.has(getRepoIdFromWorktreeId(worktree.id)))
|
||||
.map(
|
||||
(worktree): SkillSshWorkspaceAuthority => ({
|
||||
kind: 'worktree',
|
||||
id: worktree.id,
|
||||
path: worktree.path
|
||||
})
|
||||
)
|
||||
.map((worktree): SkillSshWorkspaceAuthority => ({
|
||||
kind: 'worktree',
|
||||
id: worktree.id,
|
||||
path: worktree.path
|
||||
}))
|
||||
const folders = this.listFolderWorkspaces()
|
||||
.filter((folder) => folder.connectionId === connectionId)
|
||||
.map(
|
||||
(folder): SkillSshWorkspaceAuthority => ({
|
||||
kind: 'folder',
|
||||
id: folder.id,
|
||||
path: folder.folderPath
|
||||
})
|
||||
)
|
||||
.map((folder): SkillSshWorkspaceAuthority => ({
|
||||
kind: 'folder',
|
||||
id: folder.id,
|
||||
path: folder.folderPath
|
||||
}))
|
||||
return [...worktrees, ...folders]
|
||||
}
|
||||
|
||||
@@ -27367,13 +27363,13 @@ export class OrcaRuntimeService {
|
||||
// a renderer window, so the startup shell can wait on setup completion
|
||||
// and windowless creates resolve the same Windows setup shell.
|
||||
const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0]
|
||||
// Why: both trailing args are optional — the shell is undefined off Windows.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
hooks.scripts.setup,
|
||||
runtimeTarget,
|
||||
resolveSetupRunnerShell(settings)
|
||||
resolveSetupRunnerShell(settings),
|
||||
yamlHooks?.setupAgentStartupPolicy
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: the git worktree is already real at this point. If runner
|
||||
|
||||
@@ -34,7 +34,7 @@ export function exceedsTerminalStreamChunkBytes(data: string): boolean {
|
||||
return false
|
||||
}
|
||||
let byteLength = 0
|
||||
for (let start = 0; start < data.length; ) {
|
||||
for (let start = 0; start < data.length;) {
|
||||
let end = Math.min(start + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, data.length)
|
||||
const high = data.charCodeAt(end - 1)
|
||||
const low = data.charCodeAt(end)
|
||||
|
||||
@@ -92,16 +92,14 @@ export const nativeSkillInstallFilesystem: SkillInstallFilesystem = {
|
||||
inspectPaths: async (paths) =>
|
||||
new Map(
|
||||
await runSkillCandidateTasks(
|
||||
paths.map(
|
||||
(path) => async (): Promise<[string, SkillPathInspection]> => [
|
||||
path,
|
||||
{
|
||||
kind: statKind(await lstat(path).catch(() => null)),
|
||||
realpath: await realpath(path).catch(() => null),
|
||||
mtimeMs: (await stat(path).catch(() => null))?.mtimeMs ?? null
|
||||
}
|
||||
]
|
||||
)
|
||||
paths.map((path) => async (): Promise<[string, SkillPathInspection]> => [
|
||||
path,
|
||||
{
|
||||
kind: statKind(await lstat(path).catch(() => null)),
|
||||
realpath: await realpath(path).catch(() => null),
|
||||
mtimeMs: (await stat(path).catch(() => null))?.mtimeMs ?? null
|
||||
}
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ import { startSttDictation } from './stt-session-start'
|
||||
import { createSttSessionState, type SttSessionState } from './stt-session-state'
|
||||
import { prepareSttModelForDeletion, stopSttDictation } from './stt-session-stop'
|
||||
|
||||
export const START_DICTATION_TIMEOUT_MS = 60_000
|
||||
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
|
||||
export { IDLE_WORKER_TEARDOWN_MS, START_DICTATION_TIMEOUT_MS } from './stt-session-timeouts'
|
||||
|
||||
export type SttEvent =
|
||||
| { type: 'ready' }
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
initializeSttWorker,
|
||||
waitForSttWorkerReady
|
||||
} from './stt-worker-startup'
|
||||
|
||||
export const START_DICTATION_TIMEOUT_MS = 60_000
|
||||
import { START_DICTATION_TIMEOUT_MS } from './stt-session-timeouts'
|
||||
|
||||
export async function startSttDictation(
|
||||
state: SttSessionState,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import type { SttSessionState } from './stt-session-state'
|
||||
import { waitForSttWorkerStop, type SttWorkerStopOutcome } from './stt-worker-stop'
|
||||
import { IDLE_WORKER_TEARDOWN_MS } from './stt-session-timeouts'
|
||||
|
||||
const STOP_DICTATION_TIMEOUT_MS = 60_000
|
||||
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
|
||||
|
||||
export async function stopSttDictation(
|
||||
state: SttSessionState,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Single source for the dictation timeouts the start and stop paths share. */
|
||||
export const START_DICTATION_TIMEOUT_MS = 60_000
|
||||
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -508,20 +508,6 @@ describe('enableMainProcessGpuFeatures', () => {
|
||||
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu')
|
||||
})
|
||||
|
||||
it('opts hidden pages out of intensive wake-up throttling', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { enableMainProcessGpuFeatures } = await import('./configure-process')
|
||||
|
||||
delete process.env.ORCA_E2E_USER_DATA_DIR
|
||||
vi.mocked(app.commandLine.appendSwitch).mockClear()
|
||||
enableMainProcessGpuFeatures()
|
||||
|
||||
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
|
||||
'disable-features',
|
||||
'IntensiveWakeUpThrottling'
|
||||
)
|
||||
})
|
||||
|
||||
it('raises the WebGL context budget above the 16-context Blink default', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { enableMainProcessGpuFeatures } = await import('./configure-process')
|
||||
@@ -754,3 +740,77 @@ describe('enableMainProcessGpuFeatures', () => {
|
||||
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-features', 'ExistingFeature')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safe graphics mode startup switches', () => {
|
||||
const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR
|
||||
|
||||
afterEach(() => {
|
||||
if (originalE2EUserDataDir === undefined) {
|
||||
delete process.env.ORCA_E2E_USER_DATA_DIR
|
||||
} else {
|
||||
process.env.ORCA_E2E_USER_DATA_DIR = originalE2EUserDataDir
|
||||
}
|
||||
})
|
||||
|
||||
function disabledFeaturesFrom(appendSwitch: ReturnType<typeof vi.fn>): string[] {
|
||||
return appendSwitch.mock.calls
|
||||
.filter(([name]) => name === 'disable-features')
|
||||
.flatMap(([, value]) => String(value ?? '').split(','))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
it('opts hidden pages out of intensive wake-up throttling', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { optOutOfHiddenPageWakeUpThrottling } = await import('./configure-process')
|
||||
|
||||
vi.mocked(app.commandLine.appendSwitch).mockClear()
|
||||
optOutOfHiddenPageWakeUpThrottling()
|
||||
|
||||
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
|
||||
'disable-features',
|
||||
'IntensiveWakeUpThrottling'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs
|
||||
// `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything
|
||||
// parked inside it, so only an unconditional call site reaches the users a GPU crash already hit.
|
||||
it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => {
|
||||
const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {')
|
||||
expect(gateStart).toBeGreaterThanOrEqual(0)
|
||||
const gateEnd = mainSource.indexOf('\n }', gateStart)
|
||||
expect(gateEnd).toBeGreaterThan(gateStart)
|
||||
|
||||
expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1)
|
||||
expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling')
|
||||
})
|
||||
|
||||
// Why: Chromium consumes the command line at ready, so this must stay in the pre-ready
|
||||
// top-level block and never move into the whenReady callback, where appendSwitch is a silent
|
||||
// no-op — the same invisible failure as parking it behind the GPU gate.
|
||||
it('appends the throttling opt-out before app ready in index.ts', () => {
|
||||
const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const readyStart = mainSource.indexOf('void app.whenReady()')
|
||||
expect(readyStart).toBeGreaterThan(0)
|
||||
|
||||
const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()')
|
||||
expect(callIndex).toBeGreaterThan(0)
|
||||
expect(callIndex).toBeLessThan(readyStart)
|
||||
})
|
||||
|
||||
// Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out
|
||||
// must never become reachable only through the GPU-feature path again.
|
||||
it('does not couple the throttling opt-out to GPU feature setup', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { enableMainProcessGpuFeatures } = await import('./configure-process')
|
||||
|
||||
delete process.env.ORCA_E2E_USER_DATA_DIR
|
||||
vi.mocked(app.commandLine.appendSwitch).mockClear()
|
||||
enableMainProcessGpuFeatures()
|
||||
|
||||
expect(disabledFeaturesFrom(vi.mocked(app.commandLine.appendSwitch))).not.toContain(
|
||||
'IntensiveWakeUpThrottling'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,6 +71,12 @@ export function disableUnsupportedChromiumFeatures(): void {
|
||||
appendDisabledChromiumFeatures([...DISABLED_CHROMIUM_FEATURES])
|
||||
}
|
||||
|
||||
// Why: Chromium clamps hidden-page timers to 1/min after 5min on every desktop platform,
|
||||
// delaying agent-done/bell notifications ~60s. Call site is unconditional (see index.ts).
|
||||
export function optOutOfHiddenPageWakeUpThrottling(): void {
|
||||
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
|
||||
}
|
||||
|
||||
function appendDisabledChromiumFeatures(features: string[]): void {
|
||||
const existingFeatures = app.commandLine
|
||||
.getSwitchValue('disable-features')
|
||||
@@ -314,8 +320,4 @@ export function enableMainProcessGpuFeatures(): void {
|
||||
if (features) {
|
||||
app.commandLine.appendSwitch('enable-features', features)
|
||||
}
|
||||
|
||||
// Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s.
|
||||
// This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path.
|
||||
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
|
||||
}
|
||||
|
||||
@@ -27,24 +27,42 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('round-trips a written marker', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 123, crashesInWindow: 3 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 123, crashesInWindow: 3, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
expect(readGpuFallbackMarker(userDataPath)).toEqual({
|
||||
schemeVersion: 2,
|
||||
schemeVersion: 3,
|
||||
engagedAt: 123,
|
||||
crashesInWindow: 3,
|
||||
userConfirmed: false,
|
||||
appVersion: '1.2.3',
|
||||
electronVersion: '42.3.3',
|
||||
platform: 'win32'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists explicit safe-graphics consent across launches', () => {
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 123, crashesInWindow: 3, userConfirmed: true },
|
||||
environment
|
||||
)
|
||||
expect(readActiveGpuFallbackMarker(userDataPath, environment)?.userConfirmed).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when no marker exists', () => {
|
||||
expect(readGpuFallbackMarker(userDataPath)).toBeNull()
|
||||
expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an active marker for repeated launches on the same build', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(true)
|
||||
|
||||
const firstRead = readActiveGpuFallbackMarker(userDataPath, environment)
|
||||
@@ -56,7 +74,11 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('clears an active marker when the app build changes', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
|
||||
expect(
|
||||
readActiveGpuFallbackMarker(userDataPath, {
|
||||
@@ -68,7 +90,11 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('clears an active marker outside Windows', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
|
||||
expect(
|
||||
readActiveGpuFallbackMarker(userDataPath, {
|
||||
@@ -83,7 +109,11 @@ describe('gpu-fallback-marker', () => {
|
||||
// carries the macOS disable-skia-graphite fix. A marker that survived on darwin would silently
|
||||
// strip the fix from the Macs it targets, so pin the platform gate for darwin specifically.
|
||||
it('clears an active marker on macOS so the Graphite fix is never skipped', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
|
||||
expect(
|
||||
readActiveGpuFallbackMarker(userDataPath, {
|
||||
@@ -110,7 +140,11 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('can explicitly clear the marker', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
clearGpuFallbackMarker(userDataPath)
|
||||
expect(readGpuFallbackMarker(userDataPath)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { join } from 'node:path'
|
||||
*/
|
||||
|
||||
export const GPU_FALLBACK_MARKER_FILE = 'gpu-fallback.json'
|
||||
export const GPU_FALLBACK_SCHEME_VERSION = 2
|
||||
export const GPU_FALLBACK_SCHEME_VERSION = 3
|
||||
|
||||
export type GpuFallbackEnvironment = {
|
||||
appVersion: string
|
||||
@@ -25,6 +25,7 @@ export type GpuFallbackMarker = {
|
||||
schemeVersion: number
|
||||
engagedAt: number
|
||||
crashesInWindow: number
|
||||
userConfirmed: boolean
|
||||
appVersion: string
|
||||
electronVersion: string
|
||||
platform: 'win32'
|
||||
@@ -47,6 +48,7 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
|
||||
!Number.isFinite(parsed.engagedAt) ||
|
||||
typeof parsed.crashesInWindow !== 'number' ||
|
||||
!Number.isFinite(parsed.crashesInWindow) ||
|
||||
typeof parsed.userConfirmed !== 'boolean' ||
|
||||
typeof parsed.appVersion !== 'string' ||
|
||||
typeof parsed.electronVersion !== 'string' ||
|
||||
parsed.platform !== 'win32'
|
||||
@@ -57,6 +59,7 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
|
||||
schemeVersion: GPU_FALLBACK_SCHEME_VERSION,
|
||||
engagedAt: parsed.engagedAt,
|
||||
crashesInWindow: parsed.crashesInWindow,
|
||||
userConfirmed: parsed.userConfirmed,
|
||||
appVersion: parsed.appVersion,
|
||||
electronVersion: parsed.electronVersion,
|
||||
platform: parsed.platform
|
||||
@@ -69,13 +72,14 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
|
||||
|
||||
export function writeGpuFallbackMarker(
|
||||
userDataPath: string,
|
||||
info: { engagedAt: number; crashesInWindow: number },
|
||||
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean },
|
||||
environment: WindowsGpuFallbackEnvironment
|
||||
): void {
|
||||
const marker: GpuFallbackMarker = {
|
||||
schemeVersion: GPU_FALLBACK_SCHEME_VERSION,
|
||||
engagedAt: info.engagedAt,
|
||||
crashesInWindow: info.crashesInWindow,
|
||||
userConfirmed: info.userConfirmed,
|
||||
appVersion: environment.appVersion,
|
||||
electronVersion: environment.electronVersion,
|
||||
platform: 'win32'
|
||||
|
||||
@@ -239,10 +239,10 @@ describe('updater', () => {
|
||||
changelog: null
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000 + 59 * 60 * 1000)
|
||||
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000)
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
|
||||
// Why: the boundary tick sweeps the updater's other timers (30-minute nudge poll, 45-second
|
||||
// stall guard) too, so pin the reschedule itself — nothing before 24h, a check once it elapses —
|
||||
// rather than an exact process-wide call total.
|
||||
|
||||
@@ -30,6 +30,18 @@ function isAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Job-object teardown is external to fake timers; poll the real process table to a deadline.
|
||||
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (!isAlive(pid)) {
|
||||
return true
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
return !isAlive(pid)
|
||||
}
|
||||
|
||||
describeOnWindows('host job reaps the tree when the host dies', () => {
|
||||
let dir: string
|
||||
|
||||
@@ -83,11 +95,13 @@ describeOnWindows('host job reaps the tree when the host dies', () => {
|
||||
// Force-kill only the host: no tree kill, nothing given a chance to unwind.
|
||||
// This is the daemon-crash shape.
|
||||
process.kill(host.pid!, 'SIGKILL')
|
||||
await sleep(3_000)
|
||||
|
||||
try {
|
||||
expect(isAlive(shellPid)).toBe(false)
|
||||
expect(isAlive(grandchildPid)).toBe(false)
|
||||
const [shellExited, grandchildExited] = await Promise.all([
|
||||
waitForProcessExit(shellPid, 15_000),
|
||||
waitForProcessExit(grandchildPid, 15_000)
|
||||
])
|
||||
expect(shellExited).toBe(true)
|
||||
expect(grandchildExited).toBe(true)
|
||||
} finally {
|
||||
for (const pid of [shellPid, grandchildPid]) {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { buildPosixRunnerScript, buildWindowsRunnerScript } from './setup-runner
|
||||
import type { HookRuntimeTarget } from './hook-runtime-target'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import type { WorktreeSetupLaunch } from '../shared/worktree/launch-types'
|
||||
import type { SetupAgentStartupPolicy } from '../shared/orca-yaml-hook-types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { SetupRunnerShell } from '../shared/setup-runner-command'
|
||||
|
||||
@@ -30,7 +31,8 @@ export function createSetupRunnerScript(
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
|
||||
setupShell?: SetupRunnerShell
|
||||
setupShell?: SetupRunnerShell,
|
||||
projectStartupPolicy?: SetupAgentStartupPolicy
|
||||
): WorktreeSetupLaunch {
|
||||
return createWorktreeRunnerScript({
|
||||
repo,
|
||||
@@ -39,7 +41,8 @@ export function createSetupRunnerScript(
|
||||
runnerBaseName: 'setup-runner',
|
||||
runtimeTarget: getHookRuntimeTarget(projectRuntime),
|
||||
waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup(
|
||||
repo.hookSettings?.setupAgentStartupPolicy
|
||||
repo.hookSettings?.setupAgentStartupPolicy,
|
||||
projectStartupPolicy
|
||||
),
|
||||
setupShell
|
||||
})
|
||||
|
||||
@@ -32,6 +32,8 @@ import type {
|
||||
SessionTabCloseResponse
|
||||
} from '../../shared/session-tab-close'
|
||||
|
||||
export type CloseActiveTabPayload = { sourceId: string }
|
||||
|
||||
export type UiCommandEventApi = {
|
||||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
@@ -105,7 +107,7 @@ export type UiCommandEventApi = {
|
||||
onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void
|
||||
onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
|
||||
onHardReloadBrowserPage: (callback: () => void) => () => void
|
||||
onCloseActiveTab: (callback: () => void) => () => void
|
||||
onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void) => () => void
|
||||
onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void) => () => void
|
||||
onSelectFloatingIndex: (callback: (payload: { index: number }) => void) => () => void
|
||||
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission'
|
||||
|
||||
describe('admitCloseActiveTabPayload', () => {
|
||||
it('preserves the legacy omitted payload', () => {
|
||||
expect(admitCloseActiveTabPayload(undefined)).toEqual({ kind: 'legacy' })
|
||||
})
|
||||
|
||||
it('admits a nonempty source id', () => {
|
||||
expect(admitCloseActiveTabPayload({ sourceId: 'page-1' })).toEqual({
|
||||
kind: 'source',
|
||||
payload: { sourceId: 'page-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([null, {}, [], { sourceId: '' }, { sourceId: 1 }, 'page-1'])(
|
||||
'rejects malformed payload %#',
|
||||
(payload) => {
|
||||
expect(admitCloseActiveTabPayload(payload)).toEqual({ kind: 'invalid' })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CloseActiveTabPayload } from './api/ui-command-event-api'
|
||||
|
||||
export type AdmittedCloseActiveTabPayload =
|
||||
| { kind: 'legacy' }
|
||||
| { kind: 'source'; payload: CloseActiveTabPayload }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
export function admitCloseActiveTabPayload(value: unknown): AdmittedCloseActiveTabPayload {
|
||||
if (value === undefined) {
|
||||
return { kind: 'legacy' }
|
||||
}
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as Record<string, unknown>).sourceId === 'string' &&
|
||||
(value as Record<string, unknown>).sourceId !== ''
|
||||
) {
|
||||
return {
|
||||
kind: 'source',
|
||||
payload: { sourceId: (value as Record<string, unknown>).sourceId as string }
|
||||
}
|
||||
}
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
+11
-2
@@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { preloadE2EConfig } from './e2e-config'
|
||||
import { glApi } from './gitlab'
|
||||
import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission'
|
||||
import type { CloseActiveTabPayload } from './api/ui-command-event-api'
|
||||
import type {
|
||||
SkillDeletePlan,
|
||||
SkillDeleteRequest,
|
||||
@@ -4050,8 +4052,15 @@ const api = {
|
||||
ipcRenderer.on('ui:hardReloadBrowserPage', listener)
|
||||
return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener)
|
||||
},
|
||||
onCloseActiveTab: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => {
|
||||
const admitted = admitCloseActiveTabPayload(payload)
|
||||
if (admitted.kind === 'legacy') {
|
||||
callback()
|
||||
} else if (admitted.kind === 'source') {
|
||||
callback(admitted.payload)
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('ui:closeActiveTab', listener)
|
||||
return () => ipcRenderer.removeListener('ui:closeActiveTab', listener)
|
||||
},
|
||||
|
||||
@@ -654,7 +654,10 @@ describe('PtyHandler negotiated source publication', () => {
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
responseResult(replacementWrites.find((buffer) => responseResult(buffer, 4))!, 4)
|
||||
responseResult(
|
||||
replacementWrites.find((buffer) => responseResult(buffer, 4))!,
|
||||
4
|
||||
)
|
||||
).toMatchObject({
|
||||
id: 'pty-1',
|
||||
incarnationId: expect.any(String),
|
||||
|
||||
@@ -96,34 +96,32 @@ export function usePersistedUIWriter(): void {
|
||||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const ui = useAppStore(
|
||||
useShallow(
|
||||
(s): PersistedUIWriteBaseline => ({
|
||||
sidebarWidth: s.sidebarWidth,
|
||||
rightSidebarOpen: s.rightSidebarOpen,
|
||||
rightSidebarTab: s.rightSidebarTab,
|
||||
rightSidebarExplorerView: s.rightSidebarExplorerView,
|
||||
rightSidebarWidth: s.rightSidebarWidth,
|
||||
markdownTocPanelWidth: s.markdownTocPanelWidth,
|
||||
combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth,
|
||||
groupBy: s.groupBy,
|
||||
sortBy: s.sortBy,
|
||||
projectOrderBy: s.projectOrderBy,
|
||||
showSleepingWorkspaces: s.showSleepingWorkspaces,
|
||||
hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace,
|
||||
hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces,
|
||||
hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces,
|
||||
hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces,
|
||||
hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices,
|
||||
alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace,
|
||||
showDotfilesByWorktree: s.showDotfilesByWorktree,
|
||||
filterRepoIds: s.filterRepoIds,
|
||||
// Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup
|
||||
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
|
||||
// Without persisting, agent rows that survive restart come back bold even when the
|
||||
// user had already visited them.
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
|
||||
})
|
||||
)
|
||||
useShallow((s): PersistedUIWriteBaseline => ({
|
||||
sidebarWidth: s.sidebarWidth,
|
||||
rightSidebarOpen: s.rightSidebarOpen,
|
||||
rightSidebarTab: s.rightSidebarTab,
|
||||
rightSidebarExplorerView: s.rightSidebarExplorerView,
|
||||
rightSidebarWidth: s.rightSidebarWidth,
|
||||
markdownTocPanelWidth: s.markdownTocPanelWidth,
|
||||
combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth,
|
||||
groupBy: s.groupBy,
|
||||
sortBy: s.sortBy,
|
||||
projectOrderBy: s.projectOrderBy,
|
||||
showSleepingWorkspaces: s.showSleepingWorkspaces,
|
||||
hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace,
|
||||
hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces,
|
||||
hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces,
|
||||
hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces,
|
||||
hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices,
|
||||
alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace,
|
||||
showDotfilesByWorktree: s.showDotfilesByWorktree,
|
||||
filterRepoIds: s.filterRepoIds,
|
||||
// Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup
|
||||
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
|
||||
// Without persisting, agent rows that survive restart come back bold even when the
|
||||
// user had already visited them.
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
|
||||
}))
|
||||
)
|
||||
useEffect(() => {
|
||||
// The baseline holds the values this client last saw persisted (newest
|
||||
|
||||
@@ -758,8 +758,8 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// Hard guarantee (K4): always overlay pending after reconcile so list
|
||||
// fetch clobbers never paint unprotected coordinator fields.
|
||||
setPages((current) =>
|
||||
reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries).map((page) =>
|
||||
page ? (overlayPendingOnTaskPagePages([page])[0] ?? []) : null
|
||||
reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries).map(
|
||||
(page) => (page ? (overlayPendingOnTaskPagePages([page])[0] ?? []) : null)
|
||||
)
|
||||
)
|
||||
}, [githubMode, selectedWorkItemsCacheEntries, taskSource, setPages])
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
CommandEmpty,
|
||||
CommandItem
|
||||
} from '@/components/ui/command'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
|
||||
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
|
||||
@@ -568,7 +568,7 @@ function PaletteOpenTabPrimaryLine({
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
data-slot="palette-open-tab-title"
|
||||
className="min-w-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
|
||||
className="min-w-0 flex-1 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
|
||||
>
|
||||
<HighlightedText text={title} matchRanges={titleRanges} />
|
||||
</span>
|
||||
@@ -2022,13 +2022,11 @@ function WorktreeJumpPaletteContent({
|
||||
fetchLinearIssue: state.fetchLinearIssue
|
||||
})
|
||||
.catch(() => null)
|
||||
.then(
|
||||
(issue): CmdJLinearIssuePreview => ({
|
||||
...pendingPreview,
|
||||
issue,
|
||||
loading: false
|
||||
})
|
||||
)
|
||||
.then((issue): CmdJLinearIssuePreview => ({
|
||||
...pendingPreview,
|
||||
issue,
|
||||
loading: false
|
||||
}))
|
||||
linearIssueLookupRef.current = { query: createWorktreeName, promise }
|
||||
void promise.then((preview) => {
|
||||
if (linearIssueLookupGenerationRef.current === generation) {
|
||||
@@ -2077,13 +2075,11 @@ function WorktreeJumpPaletteContent({
|
||||
sourceContext
|
||||
})
|
||||
.catch(() => null)
|
||||
.then(
|
||||
(item): CmdJGitHubWorkItemPreview => ({
|
||||
...pendingPreview,
|
||||
item: item ?? null,
|
||||
loading: false
|
||||
})
|
||||
)
|
||||
.then((item): CmdJGitHubWorkItemPreview => ({
|
||||
...pendingPreview,
|
||||
item: item ?? null,
|
||||
loading: false
|
||||
}))
|
||||
githubLookupRef.current = { query: createWorktreeName, promise }
|
||||
void promise.then((preview) => {
|
||||
if (githubLookupGenerationRef.current === generation) {
|
||||
@@ -3262,30 +3258,33 @@ function WorktreeJumpPaletteContent({
|
||||
}
|
||||
|
||||
if (entry.type === 'hint') {
|
||||
// Why: plain div (not CommandItem) so cmdk can't select it; arrow keys skip it via selectableItems.
|
||||
return (
|
||||
<div
|
||||
<CommandItem
|
||||
key={renderKey}
|
||||
className="mx-0.5 mt-1 flex items-center gap-2 px-3 py-1.5 text-[12px] text-muted-foreground"
|
||||
value={renderKey}
|
||||
onSelect={() => {
|
||||
const previousIndex = selectionItemIds.indexOf(renderKey)
|
||||
flushSync(() => entry.onSeeMore?.())
|
||||
const expandedItemId = Array.from(
|
||||
listRef.current?.querySelectorAll<HTMLElement>('[cmdk-item]') ?? []
|
||||
)[previousIndex]?.getAttribute('data-value')
|
||||
if (expandedItemId) {
|
||||
setSelectedItemId(expandedItemId)
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className={cn(
|
||||
JUMP_PALETTE_ITEM_CLASSNAME,
|
||||
'mt-1 min-h-0 gap-2 py-1.5 text-[12px] text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{entry.label}</span>
|
||||
{entry.onSeeMore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 shrink-0 px-2 text-xs font-medium text-foreground hover:bg-accent"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
entry.onSeeMore?.()
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<span className="h-6 shrink-0 rounded-md border border-input bg-background px-2 text-xs font-medium leading-6 text-foreground">
|
||||
{translate('worktreeJumpPalette.seeMore', 'See more')}
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ describe('AutomationRunHistory unanswered history', () => {
|
||||
expect(container.textContent).not.toContain('No runs yet.')
|
||||
// "0 runs" is a count of something nobody managed to read.
|
||||
expect(container.textContent).not.toContain('0 runs')
|
||||
expect(container.textContent).toContain('Run history could not be loaded')
|
||||
expect(container.textContent).toContain('Run history is unavailable from this host')
|
||||
expect(container.textContent).toContain('does not mean the automation failed or has no runs')
|
||||
expect(container.textContent).toContain('web-01 is not connected')
|
||||
})
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export function AutomationRunHistory({
|
||||
<p className="text-center text-sm text-foreground">
|
||||
{translate(
|
||||
'auto.components.automations.AutomationRunHistory.historyUnavailable',
|
||||
'Run history could not be loaded for this automation.'
|
||||
'Run history is unavailable from this host. This does not mean the automation failed or has no runs.'
|
||||
)}
|
||||
</p>
|
||||
<AutomationOwnerConflictNotice notice={notice} onRecover={onRecoverHistory} />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AutomationsDetailPane } from './AutomationsDetailPane'
|
||||
import { makeAutomation } from './automations-page-fixtures'
|
||||
|
||||
let root: Root | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount())
|
||||
root = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function renderRunsTab(historyUnavailable: boolean): Promise<HTMLButtonElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<AutomationsDetailPane
|
||||
selected={makeAutomation()}
|
||||
selectedExternal={null}
|
||||
selectedExternalRunPage={null}
|
||||
selectedAutomationRunPage={null}
|
||||
selectedRuns={[]}
|
||||
selectedRunsNotice={
|
||||
historyUnavailable
|
||||
? { message: 'Server update required', recovery: 'update-server', severity: 'failure' }
|
||||
: null
|
||||
}
|
||||
activePaneTab="runs"
|
||||
relativeNow={0}
|
||||
externalActionKey={null}
|
||||
selectedRepoDisplayName="Orca"
|
||||
selectedRepoDefaultBaseRef={null}
|
||||
selectedWorkspaceName="Workspace"
|
||||
selectedHostEntry={null}
|
||||
hostLabelById={new Map()}
|
||||
selectedRunNowAvailability={null}
|
||||
selectedAutomationRunPageWorkspaceDisplay={null}
|
||||
selectedAutomationRunPageViewState={null}
|
||||
canRerunSelectedAutomationRunPage={false}
|
||||
isSelectedAutomationRunPageRerunPending={false}
|
||||
worktreeMap={new Map()}
|
||||
fetchExternalAutomationRuns={vi.fn()}
|
||||
onActivePaneTabChange={vi.fn()}
|
||||
onClearExternalRunPage={vi.fn()}
|
||||
onClearAutomationRunPage={vi.fn()}
|
||||
requestExternalAction={vi.fn()}
|
||||
openExternalRunPage={vi.fn()}
|
||||
openEditExternalDialog={vi.fn()}
|
||||
runNow={vi.fn()}
|
||||
openEditDialog={vi.fn()}
|
||||
toggleAutomation={vi.fn()}
|
||||
requestDeleteAutomation={vi.fn()}
|
||||
rerunAutomationRun={vi.fn()}
|
||||
openRunWorkspace={vi.fn()}
|
||||
openAutomationRunPage={vi.fn()}
|
||||
onBackToList={vi.fn()}
|
||||
recoverSelectedRuns={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const runsTab = [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((tab) =>
|
||||
tab.textContent?.includes('Runs')
|
||||
)
|
||||
if (!runsTab) {
|
||||
throw new Error('Runs tab was not rendered')
|
||||
}
|
||||
return runsTab
|
||||
}
|
||||
|
||||
describe('AutomationsDetailPane run count', () => {
|
||||
it('does not report zero when run history is unavailable', async () => {
|
||||
expect((await renderRunsTab(true)).textContent?.trim()).toBe('Runs')
|
||||
})
|
||||
|
||||
it('reports zero when the host answered with an empty history', async () => {
|
||||
expect((await renderRunsTab(false)).textContent).toContain('0')
|
||||
})
|
||||
})
|
||||
@@ -213,7 +213,9 @@ export function AutomationsDetailPane({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="runs" disabled={!selected}>
|
||||
{translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
|
||||
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
|
||||
{selectedRunsNotice ? null : (
|
||||
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
mocks,
|
||||
renderPage,
|
||||
runtimeHost,
|
||||
RUNTIME_ID,
|
||||
RUNTIME_REPO_ID,
|
||||
RUNTIME_SELF_FILTER,
|
||||
RUNTIME_WORKSPACE_ID,
|
||||
@@ -88,7 +89,11 @@ describe('AutomationsPage notice recovery', () => {
|
||||
})
|
||||
|
||||
expect(settings.open).toHaveBeenCalled()
|
||||
expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
|
||||
expect(settings.target).toHaveBeenCalledWith({
|
||||
pane: 'servers',
|
||||
repoId: null,
|
||||
sectionId: RUNTIME_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a row Update server to the row own host, not to the unfiltered list', async () => {
|
||||
@@ -116,6 +121,10 @@ describe('AutomationsPage notice recovery', () => {
|
||||
|
||||
// All hosts selects no host, so only the row's captured owner names one.
|
||||
expect(settings.open).toHaveBeenCalled()
|
||||
expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
|
||||
expect(settings.target).toHaveBeenCalledWith({
|
||||
pane: 'servers',
|
||||
repoId: null,
|
||||
sectionId: RUNTIME_ID
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,6 +92,12 @@ describe('captured owner availability', () => {
|
||||
const availability = automationActionAvailability(legacy, 'delete')
|
||||
expect(availability.kind).toBe('blocked')
|
||||
expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server')
|
||||
expect(availability.kind === 'blocked' && availability.block.message).toContain(
|
||||
'needs an update'
|
||||
)
|
||||
expect(availability.kind === 'blocked' && availability.block.message).toContain(
|
||||
'Scheduled runs may still continue'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a row with no metadata as uncaptured rather than blocked', () => {
|
||||
|
||||
@@ -71,7 +71,7 @@ function unfencedBlock(): AutomationActionBlock {
|
||||
reason: 'unfenced',
|
||||
message: translate(
|
||||
'auto.components.automations.capturedOwner.unfenced',
|
||||
'This host cannot confirm which automation it is changing, so this automation is read-only here.'
|
||||
'Orca on this host needs an update before you can view run history or manage this automation. Scheduled runs may still continue on the host.'
|
||||
),
|
||||
recovery: 'update-server'
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ const DESKTOP_SSH = entry({
|
||||
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
|
||||
kind: 'ssh'
|
||||
})
|
||||
const RUNTIME_ENVIRONMENT_ID = 'gpu'
|
||||
const RUNTIME_SSH = entry({
|
||||
stableRef: {
|
||||
authority: { kind: 'runtime', environmentId: 'gpu' },
|
||||
authority: { kind: 'runtime', environmentId: RUNTIME_ENVIRONMENT_ID },
|
||||
selector: { kind: 'ssh', targetId: 't1' }
|
||||
},
|
||||
kind: 'ssh'
|
||||
@@ -65,7 +66,7 @@ describe('automation host recovery', () => {
|
||||
{ ...RUNTIME_SSH, authorityHealth: 'unavailable' },
|
||||
target
|
||||
)
|
||||
expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith('gpu')
|
||||
expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith(RUNTIME_ENVIRONMENT_ID)
|
||||
expect(target.connectSshTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -78,10 +79,15 @@ describe('automation host recovery', () => {
|
||||
expect(target.retry).toHaveBeenCalledWith(desktopSelf)
|
||||
})
|
||||
|
||||
it('sends a runtime to the servers pane, since nothing here updates a host', () => {
|
||||
it('deep-links a runtime to its update row in Remote Orca Servers settings', () => {
|
||||
const target = deps()
|
||||
runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
|
||||
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
|
||||
expect(target.openSettings).toHaveBeenCalledWith({
|
||||
pane: 'servers',
|
||||
repoId: null,
|
||||
// Runtime environment IDs are nested scroll anchors within the pane.
|
||||
sectionId: RUNTIME_ENVIRONMENT_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a stale desktop SSH registration to the SSH pane instead', () => {
|
||||
|
||||
@@ -20,10 +20,14 @@ export type AutomationHostRecoveryDeps = {
|
||||
openSettings: (target: SettingsNavigationTarget) => void
|
||||
}
|
||||
|
||||
/** Where a host's version is managed; the app cannot update a host on its own. */
|
||||
/** The Remote Orca Servers pane owns each runtime's version status and update action. */
|
||||
function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavigationTarget {
|
||||
if (entry.stableRef.authority.kind === 'runtime') {
|
||||
return { pane: 'servers', repoId: null }
|
||||
return {
|
||||
pane: 'servers',
|
||||
repoId: null,
|
||||
sectionId: entry.stableRef.authority.environmentId
|
||||
}
|
||||
}
|
||||
// A desktop SSH host with no registration generation is a stale registration,
|
||||
// repaired by re-adding the target rather than by updating anything.
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ describe('automation run output snapshot queue equivalence', () => {
|
||||
const reference = createShiftReferenceBuffer()
|
||||
const candidate = createAutomationRunOutputSnapshotBuffer()
|
||||
let chunkCount = 0
|
||||
for (let offset = 0; offset < source.length; ) {
|
||||
for (let offset = 0; offset < source.length;) {
|
||||
const chunkLength = 1 + (random() % 73)
|
||||
const chunk = source.slice(offset, offset + chunkLength)
|
||||
reference.append(chunk)
|
||||
|
||||
@@ -248,8 +248,8 @@ export function useAutomationHostCatalog(
|
||||
void window.api.runtimeEnvironments.connect({ selector: environmentId })
|
||||
},
|
||||
openSettings: (target) => {
|
||||
openSettingsPage()
|
||||
openSettingsTarget(target)
|
||||
openSettingsPage()
|
||||
}
|
||||
}),
|
||||
[controller, openSettingsPage, openSettingsTarget, pairingRevision]
|
||||
|
||||
@@ -35,7 +35,7 @@ function inlineText(
|
||||
// scanning only the bounded text we will actually retain.
|
||||
let normalized = ''
|
||||
let pendingSpace = false
|
||||
for (let index = 0; index < content.length && normalized.length < maxLength; ) {
|
||||
for (let index = 0; index < content.length && normalized.length < maxLength;) {
|
||||
const code = content.charCodeAt(index)
|
||||
if (isInlineWhitespaceCode(code)) {
|
||||
if (code === 13 && content.charCodeAt(index + 1) === 10) {
|
||||
|
||||
@@ -14,9 +14,10 @@ const mocks = vi.hoisted(() => ({
|
||||
onSnapshotRequested: vi.fn(),
|
||||
getPopoutOpen: vi.fn(async () => false),
|
||||
publishSnapshot: vi.fn(async (_snapshot: DashboardSnapshot) => undefined),
|
||||
buildDashboardSnapshot: vi.fn(
|
||||
(_state: unknown, now: number): DashboardSnapshot => ({ generatedAt: now, cards: [] })
|
||||
),
|
||||
buildDashboardSnapshot: vi.fn((_state: unknown, now: number): DashboardSnapshot => ({
|
||||
generatedAt: now,
|
||||
cards: []
|
||||
})),
|
||||
offRevealAgent: vi.fn(),
|
||||
offAckAgent: vi.fn(),
|
||||
offPopoutOpenChanged: vi.fn(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
@@ -6,159 +7,11 @@ import { Copy, Check } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import MermaidBlock from './MermaidBlock'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* Common languages shown in the selector. The user can also type a language
|
||||
* name directly in the markdown fence (```rust) and it will be preserved —
|
||||
* this list is just for quick picking in the UI.
|
||||
*/
|
||||
const LANGUAGES = [
|
||||
{
|
||||
value: '',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'bash',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
|
||||
}
|
||||
},
|
||||
{ value: 'c', label: 'C' },
|
||||
{
|
||||
value: 'cpp',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'css',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'diff',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'go',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'graphql',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'html',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'java',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'javascript',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'kotlin',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'markdown',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'mermaid',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'python',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'ruby',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'rust',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'scss',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'shell',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'sql',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'swift',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'typescript',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'xml',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'yaml',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
|
||||
}
|
||||
}
|
||||
]
|
||||
import {
|
||||
getCodeBlockLanguageLabel,
|
||||
getCodeBlockLanguages,
|
||||
isKnownCodeBlockLanguage
|
||||
} from './rich-markdown-code-block-languages'
|
||||
|
||||
export function RichMarkdownCodeBlock({
|
||||
node,
|
||||
@@ -167,6 +20,10 @@ export function RichMarkdownCodeBlock({
|
||||
useTranslation()
|
||||
const language = (node.attrs.language as string) || ''
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Why: ProseMirror renders every node view in the document, so eagerly
|
||||
// mounting the full language list cost ~25 <option> elements per code block —
|
||||
// in a large document that dwarfs the prose DOM and slows every keystroke.
|
||||
const [languageListMounted, setLanguageListMounted] = useState(false)
|
||||
const copiedResetTimerRef = useRef<number | null>(null)
|
||||
// Why: clipboard IPC can resolve after the node view unmounts; avoid
|
||||
// starting a reset timer that will outlive the component.
|
||||
@@ -195,6 +52,15 @@ export function RichMarkdownCodeBlock({
|
||||
[clearCopiedResetTimer]
|
||||
)
|
||||
|
||||
const mountLanguageList = useCallback(() => {
|
||||
if (languageListMounted) {
|
||||
return
|
||||
}
|
||||
// Why: the native popup opens as this same discrete event's default action,
|
||||
// so the list must be in the DOM before React's normal flush would land.
|
||||
flushSync(() => setLanguageListMounted(true))
|
||||
}, [languageListMounted])
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
updateAttributes({ language: e.target.value })
|
||||
@@ -233,15 +99,25 @@ export function RichMarkdownCodeBlock({
|
||||
contentEditable={false}
|
||||
value={language}
|
||||
onChange={onChange}
|
||||
onMouseDown={mountLanguageList}
|
||||
onFocus={mountLanguageList}
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
{languageListMounted ? (
|
||||
getCodeBlockLanguages().map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
// Why: a closed <select> only paints its selected option, so until the
|
||||
// user reaches for the list one entry renders the same visible label.
|
||||
<option value={language}>{getCodeBlockLanguageLabel(language)}</option>
|
||||
)}
|
||||
{/* If the document has a language not in our list, show it as-is */}
|
||||
{language && !LANGUAGES.some((l) => l.value === language) ? (
|
||||
<option value={language}>{language}</option>
|
||||
{languageListMounted && language && !isKnownCodeBlockLanguage(language) ? (
|
||||
<option key={language} value={language}>
|
||||
{language}
|
||||
</option>
|
||||
) : null}
|
||||
</select>
|
||||
<button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user