mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
Merge origin/main into brennanb2025/chat-recorded-outcomes-from-journal
Takes main's side for the files #24710 also changed; this branch's remaining changes are re-applied on top in focused commits.
This commit is contained in:
@@ -164,7 +164,7 @@ jobs:
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
# Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped
|
||||
# it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION
|
||||
# moves past the Bun daemon's. Its build uses this checkout's installed dependencies.
|
||||
# moves past the Bun daemon's. Install its pinned dependencies independently of this checkout.
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
if [ "$RUNNER_OS" != Linux ]; then exit 0; fi
|
||||
git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT"
|
||||
git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT"
|
||||
ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules"
|
||||
pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts
|
||||
node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad"
|
||||
echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"
|
||||
echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+78
-42
@@ -154,15 +154,17 @@ jobs:
|
||||
echo "No specs requiring the reusable E2E workflow"
|
||||
fi
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
preflight:
|
||||
name: static analysis and typecheck
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true'
|
||||
# Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster --
|
||||
# type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke
|
||||
# 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so
|
||||
# the whole toolchain resolves. Free for public repositories, same as the typecheck job.
|
||||
# the whole toolchain resolves. Free for public repositories.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
shards: ${{ steps.unit-plan.outputs.shards }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -197,21 +199,35 @@ jobs:
|
||||
|
||||
# Keep each check in its own log while sharing this runner.
|
||||
- name: Lint
|
||||
if: '!cancelled()'
|
||||
id: root-lint
|
||||
background: true
|
||||
run: pnpm exec oxlint --format github
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm exec oxlint --format github
|
||||
|
||||
- name: Reject low-evidence patterns
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run audit:anti-slop
|
||||
|
||||
- wait: root-lint
|
||||
|
||||
- name: Enforce focused code-quality plugins
|
||||
if: '!cancelled()'
|
||||
id: native-code-quality
|
||||
background: true
|
||||
run: pnpm run audit:code-quality:native
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run audit:code-quality:native
|
||||
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
# Mobile installation changes import resolution for the native cycle check.
|
||||
@@ -221,28 +237,39 @@ jobs:
|
||||
# resolves types from mobile/node_modules. Without the install every mobile type
|
||||
# degrades to an `error` type — reported as phantom findings against the changed lines.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
if: '!cancelled()'
|
||||
id: changed-code-quality
|
||||
background: true
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Enforce React Doctor on changed lines
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- wait: changed-code-quality
|
||||
|
||||
- name: Check Zustand selector fan-out budget
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:zustand-selector-fanout
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
- name: Enforce dead design-system classes
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:dead-classes
|
||||
|
||||
- name: Check VM runtime rollback compatibility
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
@@ -264,25 +291,33 @@ jobs:
|
||||
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
|
||||
|
||||
- name: Enforce max-lines ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Enforce ts-nocheck ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:ts-nocheck-ratchet
|
||||
|
||||
- name: Enforce runtime Electron-import ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:runtime-electron-ratchet
|
||||
|
||||
- name: Check Node runtime pin
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:node-runtime-pin
|
||||
|
||||
# Why: extraction writes sorted evidence to an isolated temporary path,
|
||||
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
|
||||
- name: Verify localization extraction
|
||||
if: '!cancelled()'
|
||||
id: localization-extraction
|
||||
background: true
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
# Detection failures run the full check; renames retain the removed input path.
|
||||
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
|
||||
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" &&
|
||||
@@ -296,6 +331,7 @@ jobs:
|
||||
# which is a property of the import graph. This proves the Node artifact it enables
|
||||
# actually boots, pairs, creates a worktree and round-trips a real PTY.
|
||||
- name: Boot orcad and round-trip a terminal
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
@@ -310,20 +346,30 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify the generated RPC params catalog
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:rpc-params-catalog
|
||||
|
||||
- name: Verify bundled skill guides
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
- name: Verify localization catalogs
|
||||
if: '!cancelled()'
|
||||
id: localization-catalogs
|
||||
background: true
|
||||
run: pnpm run verify:localization-catalogs
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run verify:localization-catalogs
|
||||
|
||||
- name: Verify localization coverage
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
- wait: [localization-catalogs, localization-extraction]
|
||||
@@ -334,6 +380,7 @@ jobs:
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
@@ -346,33 +393,19 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check feature wall asset budget
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm check:feature-wall-assets
|
||||
|
||||
- name: Verify macOS entitlements
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
# Typechecking uses no native runtime, so it can use the free public ARM runner.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
shards: ${{ steps.unit-plan.outputs.shards }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
|
||||
# the next run skip unchanged files. Share one cache entry across commits while the
|
||||
# PR base stays stable; actions/cache keeps the first successful graph and the
|
||||
# compiler still invalidates stale files from its content hashes.
|
||||
- name: Cache TypeScript incremental state
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: config/*.tsbuildinfo
|
||||
@@ -382,17 +415,24 @@ jobs:
|
||||
|
||||
# Planning shares setup and stays off the compiler's critical path.
|
||||
- name: Plan unit selection
|
||||
if: '!cancelled()'
|
||||
id: unit-plan
|
||||
background: true
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
|
||||
run: node config/scripts/ci-unit-plan.mjs
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
node config/scripts/ci-unit-plan.mjs
|
||||
|
||||
- run: pnpm run typecheck
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
|
||||
- wait: unit-plan
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
with:
|
||||
name: unit-selection-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/unit-selection.json
|
||||
@@ -680,6 +720,7 @@ jobs:
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/fish-xdg-data-dirs-handoff.test.ts \
|
||||
src/main/shell-startup-feature-channel.test.ts \
|
||||
src/main/zsh-deferred-startup-line-init.live-shell.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
@@ -691,20 +732,19 @@ jobs:
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
# Static analysis saves the Node cache; typecheck publishes the plan before tests fan out.
|
||||
# Preflight saves the Node cache and publishes the plan before tests fan out.
|
||||
test:
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
# Cancellation and failed prerequisites stop the expensive matrix.
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
needs.static_analysis.result == 'success' &&
|
||||
needs.typecheck.result == 'success'
|
||||
needs.preflight.result == 'success'
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
shards: ${{ needs.typecheck.outputs.shards }}
|
||||
shards: ${{ needs.preflight.outputs.shards }}
|
||||
|
||||
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
|
||||
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
|
||||
@@ -902,7 +942,7 @@ jobs:
|
||||
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
|
||||
@@ -1059,7 +1099,7 @@ jobs:
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
@@ -1267,8 +1307,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- code_paths
|
||||
- static_analysis
|
||||
- typecheck
|
||||
- preflight
|
||||
- git_compatibility
|
||||
- codex_index_heal_contract
|
||||
- xterm_patch_sync
|
||||
@@ -1297,10 +1336,8 @@ jobs:
|
||||
env:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
PREFLIGHT: ${{ needs.preflight.result }}
|
||||
PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }}
|
||||
@@ -1346,8 +1383,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
|
||||
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
|
||||
check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"
|
||||
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
|
||||
check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN"
|
||||
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('committed pin', () => {
|
||||
|
||||
it('runs in the static analysis job', () => {
|
||||
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '')
|
||||
const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '')
|
||||
expect(commands).toContain('pnpm run check:node-runtime-pin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,8 +156,7 @@ describe('README local link check', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Why the ungated job: static_analysis is skipped for docs-only diffs, which is
|
||||
// exactly the kind of PR that deletes a docs-site GIF the README embeds.
|
||||
// Docs-only diffs skip preflight, so the detector must check README links.
|
||||
it('runs on every PR through the ungated detector and in the lint script', () => {
|
||||
const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
|
||||
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
|
||||
@@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) {
|
||||
describe('CI background step barriers', () => {
|
||||
it('joins every background check without suppressing failures', () => {
|
||||
for (const job of [
|
||||
pr.jobs.static_analysis,
|
||||
pr.jobs.typecheck,
|
||||
pr.jobs.preflight,
|
||||
pr.jobs.mobile_web_app,
|
||||
pr.jobs.package,
|
||||
pr.jobs.shell_contracts,
|
||||
@@ -52,7 +51,7 @@ describe('CI background step barriers', () => {
|
||||
|
||||
it('joins planning before publishing the unit artifact', () => {
|
||||
assertJoinedBefore(
|
||||
pr.jobs.typecheck.steps,
|
||||
pr.jobs.preflight.steps,
|
||||
'unit-plan',
|
||||
(step) => step.uses === 'actions/upload-artifact@v7'
|
||||
)
|
||||
@@ -88,7 +87,7 @@ describe('CI background step barriers', () => {
|
||||
})
|
||||
|
||||
it('finishes native import-cycle analysis before mobile installation changes resolution', () => {
|
||||
const steps = pr.jobs.static_analysis.steps
|
||||
const steps = pr.jobs.preflight.steps
|
||||
assertJoinedBefore(steps, 'native-code-quality', (step) =>
|
||||
step.uses?.endsWith('/install-mobile-dependencies')
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => {
|
||||
const install = arm.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const primer = readWorkflow('pr').jobs.static_analysis
|
||||
const primer = readWorkflow('pr').jobs.preflight
|
||||
expect(arm['runs-on']).toBe(primer['runs-on'])
|
||||
expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only')
|
||||
expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with)
|
||||
@@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang
|
||||
|
||||
it('publishes incremental state under a key and prefix that new PRs restore', () => {
|
||||
const cache = steps.find((step) => step.id === 'typecheck-cache')
|
||||
const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name)
|
||||
const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name)
|
||||
expect(cache.with.path).toBe(prCache.with.path)
|
||||
expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys'])
|
||||
expect(cache.with.key).toBe(
|
||||
|
||||
@@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [
|
||||
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
|
||||
'src/main/terminal-history-fish-session.node-pty.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const JSON_PARSER_CASES = [
|
||||
{ name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 },
|
||||
{ name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 },
|
||||
{ name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 },
|
||||
{ name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true },
|
||||
{ name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true },
|
||||
{ name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true },
|
||||
{ name: 'session-messages', kind: 'session' },
|
||||
{ name: 'session-skipped-objects', kind: 'session' },
|
||||
{ name: 'session-skipped-string', kind: 'session' },
|
||||
{ name: 'session-selected-string', kind: 'session' }
|
||||
]
|
||||
|
||||
export function writeJsonParserFixtures(directory) {
|
||||
for (const fixture of JSON_PARSER_CASES) {
|
||||
let value
|
||||
if (fixture.kind === 'rg') {
|
||||
const text = fixture.unicode
|
||||
? '\ufeff日本語😀x'
|
||||
: fixture.large && fixture.count > 1
|
||||
? 'xxxx'
|
||||
: 'x'
|
||||
const matchBytes = Buffer.byteLength(text)
|
||||
value = {
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: `${text}.ts` },
|
||||
lines: {
|
||||
text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count)
|
||||
},
|
||||
line_number: 1,
|
||||
submatches: Array.from({ length: fixture.count }, (_, index) => ({
|
||||
match: { text },
|
||||
start: index * matchBytes,
|
||||
end: (index + 1) * matchBytes
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = { id: 'synthetic', messages: [{ text: 'one' }] }
|
||||
if (fixture.name === 'session-messages') {
|
||||
value.agent = { model: 'model', other: 'ignored' }
|
||||
value.messages = Array.from({ length: 20_000 }, (_, index) => ({
|
||||
role: index % 2 ? 'assistant' : 'user',
|
||||
text: '\ufeff日本語😀 hello world '.repeat(16),
|
||||
timestamp: index,
|
||||
metadata: { model: 'synthetic', tokens: 512 }
|
||||
}))
|
||||
} else if (fixture.name === 'session-skipped-objects') {
|
||||
value.ignored = Array.from({ length: 200_000 }, (_, index) => ({
|
||||
id: index,
|
||||
data: { text: 'x'.repeat(64), values: [1, 2, 3] }
|
||||
}))
|
||||
} else if (fixture.name === 'session-skipped-string') {
|
||||
value.ignored = '日本語😀x'.repeat(1_500_000)
|
||||
} else {
|
||||
value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }]
|
||||
}
|
||||
}
|
||||
writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs'
|
||||
|
||||
// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first.
|
||||
const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2)
|
||||
if (!bundleDirectory || !global.gc) {
|
||||
throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY')
|
||||
}
|
||||
|
||||
async function load(arm, kind) {
|
||||
return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href)
|
||||
}
|
||||
|
||||
function prepareRun(module, fixture, file) {
|
||||
if (fixture.kind === 'rg') {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
return () =>
|
||||
module.parseRipgrepMatchJson(text, fixture.cap, {
|
||||
structuralTokens: 32 * 1024,
|
||||
nestingDepth: 16
|
||||
})
|
||||
}
|
||||
return () =>
|
||||
module.readStreamedSessionDocument({
|
||||
bytes: createReadStream(file, { highWaterMark: 64 * 1024 }),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: () => ({ count: 0, textLength: 0 }),
|
||||
consume(state, value) {
|
||||
state.count++
|
||||
state.textLength += typeof value?.text === 'string' ? value.text.length : 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
}
|
||||
|
||||
async function consumedContentDigest(module, file) {
|
||||
const result = await module.readStreamedSessionDocument({
|
||||
bytes: createReadStream(file, { highWaterMark: 64 * 1024 }),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: () => createHash('sha256'),
|
||||
consume(hash, value) {
|
||||
hash.update(JSON.stringify(value)).update('\n')
|
||||
}
|
||||
})
|
||||
return { record: result.record, consumedSha256: result.state.digest('hex') }
|
||||
}
|
||||
|
||||
if (workerFixture) {
|
||||
const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`))
|
||||
assert(fixture)
|
||||
const module = await load(workerArm, fixture.kind)
|
||||
const run = prepareRun(module, fixture, workerFixture)
|
||||
global.gc()
|
||||
const before = process.memoryUsage()
|
||||
let running = true
|
||||
let maxLoopGapMs = 0
|
||||
let previous = performance.now()
|
||||
const observe = () => {
|
||||
const now = performance.now()
|
||||
maxLoopGapMs = Math.max(maxLoopGapMs, now - previous)
|
||||
previous = now
|
||||
if (running) {
|
||||
setImmediate(observe)
|
||||
}
|
||||
}
|
||||
setImmediate(observe)
|
||||
const started = performance.now()
|
||||
const result = await run()
|
||||
const elapsedMs = performance.now() - started
|
||||
await new Promise((done) => setImmediate(done))
|
||||
running = false
|
||||
const peakRssMiB = process.resourceUsage().maxRSS / 1024
|
||||
global.gc()
|
||||
const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
elapsedMs,
|
||||
peakRssMiB,
|
||||
retainedHeapDeltaMiB,
|
||||
maxLoopGapMs,
|
||||
digest: digest(result)
|
||||
})
|
||||
)
|
||||
} else {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-'))
|
||||
try {
|
||||
writeJsonParserFixtures(directory)
|
||||
global.gc()
|
||||
const results = []
|
||||
for (const fixture of JSON_PARSER_CASES) {
|
||||
const file = join(directory, `${fixture.name}.json`)
|
||||
const runs = {}
|
||||
const contents = {}
|
||||
for (const arm of ['baseline', 'candidate']) {
|
||||
const module = await load(arm, fixture.kind)
|
||||
runs[arm] = prepareRun(module, fixture, file)
|
||||
if (fixture.kind === 'session') {
|
||||
contents[arm] = await consumedContentDigest(module, file)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(contents.candidate, contents.baseline)
|
||||
for (let warmup = 0; warmup < 3; warmup++) {
|
||||
assert.deepEqual(await runs.candidate(), await runs.baseline())
|
||||
}
|
||||
const samples = { baseline: [], candidate: [] }
|
||||
for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) {
|
||||
for (const arm of pair) {
|
||||
const started = performance.now()
|
||||
await runs[arm]()
|
||||
samples[arm].push(performance.now() - started)
|
||||
}
|
||||
}
|
||||
const memory = { baseline: [], candidate: [] }
|
||||
for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) {
|
||||
for (const arm of pair) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['--expose-gc', import.meta.filename, bundleDirectory, file, arm],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
assert.equal(child.status, 0, child.stderr)
|
||||
memory[arm].push(JSON.parse(child.stdout))
|
||||
}
|
||||
}
|
||||
for (const sample of [...memory.baseline, ...memory.candidate]) {
|
||||
assert.equal(sample.digest, memory.baseline[0].digest)
|
||||
}
|
||||
results.push({
|
||||
name: fixture.name,
|
||||
bytes: statSync(file).size,
|
||||
baseline: summarizeBenchmarkSamples(samples.baseline),
|
||||
candidate: summarizeBenchmarkSamples(samples.candidate),
|
||||
samples,
|
||||
memory
|
||||
})
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ node: process.version, platform: process.platform, arch: process.arch, results },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
const step = workflow.jobs.static_analysis.steps.find(
|
||||
const step = workflow.jobs.preflight.steps.find(
|
||||
(candidate) => candidate.name === 'Verify localization extraction'
|
||||
)
|
||||
expect(step.env).toEqual({
|
||||
expect(step.env).toMatchObject({
|
||||
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
|
||||
})
|
||||
// The base side comes from the merge ref's first parent, so the gate needs no merge base and
|
||||
|
||||
@@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', (
|
||||
expect(build.if).toBeUndefined()
|
||||
expect(build['continue-on-error']).toBeUndefined()
|
||||
expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/)
|
||||
expect(build.run).toContain(
|
||||
'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts'
|
||||
)
|
||||
expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"')
|
||||
expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"')
|
||||
expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"')
|
||||
expect(build.run).not.toContain('GITHUB_ENV')
|
||||
|
||||
@@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
const step = workflow.jobs.static_analysis.steps.find(
|
||||
const step = workflow.jobs.preflight.steps.find(
|
||||
(candidate) => candidate.name === 'Boot orcad and round-trip a terminal'
|
||||
)
|
||||
expect(step.env).toEqual({
|
||||
|
||||
@@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
|
||||
'${{ steps.filter.outputs.mobile_dependencies }}'
|
||||
)
|
||||
const steps = prWorkflow.jobs.static_analysis.steps
|
||||
const steps = prWorkflow.jobs.preflight.steps
|
||||
const install = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-mobile-dependencies'
|
||||
)
|
||||
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
|
||||
expect(install).toBeGreaterThan(-1)
|
||||
expect(install).toBeLessThan(gate)
|
||||
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
|
||||
expect(steps[install].if).toBe(
|
||||
"needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'"
|
||||
)
|
||||
// The install itself moved into the action the packaging jobs share; assert it there so
|
||||
// this job cannot keep the step while the action stops installing anything.
|
||||
const action = parse(
|
||||
@@ -621,21 +623,21 @@ describe('PR Checks skip wiring', () => {
|
||||
})
|
||||
|
||||
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
||||
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
|
||||
for (const jobName of expensiveJobs.filter(
|
||||
(jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName)
|
||||
)) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(
|
||||
['package', 'package_windows'].includes(jobName)
|
||||
? ['code_paths', 'static_analysis', 'typecheck']
|
||||
? ['code_paths', 'preflight']
|
||||
: ['code_paths']
|
||||
)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck'])
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
||||
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}')
|
||||
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
|
||||
expect(prWorkflow.jobs.unit_plan).toBeUndefined()
|
||||
expect(prWorkflow.jobs.test_native_cache).toBeUndefined()
|
||||
})
|
||||
@@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(verifyStep.run).toContain('expected skipped')
|
||||
expect(verifyStep.run).toContain('expected success')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
if (job === 'code_paths') {
|
||||
if (job === 'code_paths' || job === 'preflight') {
|
||||
continue
|
||||
}
|
||||
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
|
||||
|
||||
@@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync(
|
||||
const filterStep = prWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
|
||||
const rollbackStep = prWorkflow.jobs.preflight.steps.find(
|
||||
(step) => step.name === 'Check VM runtime rollback compatibility'
|
||||
)
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
@@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => {
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
const envVar = job.replaceAll('-', '_').toUpperCase()
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
|
||||
if (job === 'code_paths') {
|
||||
if (job === 'code_paths' || job === 'preflight') {
|
||||
continue
|
||||
}
|
||||
expect(successLoop).toContain(`"$${envVar}"`)
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const typecheck = workflow.jobs.typecheck
|
||||
const steps = typecheck.steps
|
||||
const preflight = workflow.jobs.preflight
|
||||
const steps = preflight.steps
|
||||
const compiler = steps.find((step) => step.run === 'pnpm run typecheck')
|
||||
const plan = steps.find((step) => step.id === 'unit-plan')
|
||||
|
||||
it('shares planning setup while keeping the heavy checks on separate runners', () => {
|
||||
expect(workflow.jobs.unit_plan).toBeUndefined()
|
||||
expect(workflow.jobs.test_native_cache).toBeUndefined()
|
||||
expect(typecheck.needs).toEqual(['code_paths'])
|
||||
expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths'])
|
||||
expect(
|
||||
steps.filter((step) => step.uses === './.github/actions/install-node-dependencies')
|
||||
).toHaveLength(1)
|
||||
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
|
||||
expect(compiler.background).toBeUndefined()
|
||||
expect(plan.background).toBe(true)
|
||||
expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs')
|
||||
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
|
||||
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
|
||||
const installs = workflow.jobs.static_analysis.steps.filter(
|
||||
it('shares one setup and runs the unchanged compiler after static checks finish', () => {
|
||||
expect(workflow.jobs.static_analysis).toBeUndefined()
|
||||
expect(workflow.jobs.typecheck).toBeUndefined()
|
||||
expect(preflight.needs).toEqual(['code_paths'])
|
||||
expect(preflight['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
const installs = steps.filter(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(installs).toHaveLength(2)
|
||||
@@ -32,35 +24,152 @@ it('shares planning setup while keeping the heavy checks on separate runners', (
|
||||
expect(install.with['node-version']).toBe('24')
|
||||
expect(install.with['persist-native-cache']).not.toBe('false')
|
||||
}
|
||||
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
|
||||
expect(compiler.background).toBeUndefined()
|
||||
expect(plan.background).toBe(true)
|
||||
expect(plan.run.trim().split('\n')).toEqual([
|
||||
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi',
|
||||
'node config/scripts/ci-unit-plan.mjs'
|
||||
])
|
||||
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
|
||||
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
|
||||
expect(steps.indexOf(compiler)).toBeGreaterThan(
|
||||
steps.findIndex((step) => step.wait?.includes('localization-extraction'))
|
||||
)
|
||||
})
|
||||
|
||||
it('requires compiler success and joined planning before publishing shards and admitting tests', () => {
|
||||
it('requires physical preflight success before publishing shards and admitting consumers', () => {
|
||||
const join = steps.findIndex((step) => step.wait === 'unit-plan')
|
||||
const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7')
|
||||
expect(join).toBeGreaterThan(steps.indexOf(compiler))
|
||||
expect(upload).toBeGreaterThan(join)
|
||||
expect(steps[upload]['continue-on-error']).toBeUndefined()
|
||||
expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}')
|
||||
expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
|
||||
expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}')
|
||||
expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
|
||||
expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
|
||||
for (const job of ['test', 'package', 'package_windows']) {
|
||||
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck'])
|
||||
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight'])
|
||||
}
|
||||
expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'")
|
||||
expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'")
|
||||
expect(workflow.jobs.verify.needs).toContain('static_analysis')
|
||||
expect(workflow.jobs.verify.needs).toContain('typecheck')
|
||||
for (const result of ['success', 'failure', 'cancelled', 'skipped']) {
|
||||
const admitted = runInNewContext(workflow.jobs.test.if, {
|
||||
cancelled: () => false,
|
||||
needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } }
|
||||
})
|
||||
expect(admitted, result).toBe(result === 'success')
|
||||
}
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}')
|
||||
expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe(
|
||||
"${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}"
|
||||
)
|
||||
expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"')
|
||||
expect(workflow.jobs.verify.needs).toContain('preflight')
|
||||
expect(workflow.jobs.verify.needs).not.toContain('typecheck')
|
||||
})
|
||||
|
||||
it.each(
|
||||
[['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map(
|
||||
(changed) => ({ changed })
|
||||
it('pins every foreground and background step to its selected phase', () => {
|
||||
const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'"
|
||||
const typePhase = "needs.code_paths.outputs.typecheck == 'true'"
|
||||
const foreground = steps.filter(
|
||||
(step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '')
|
||||
)
|
||||
)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => {
|
||||
expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([
|
||||
['Reject low-evidence patterns', staticPhase],
|
||||
['Enforce type-aware code-quality baseline', staticPhase],
|
||||
[
|
||||
'./.github/actions/install-mobile-dependencies',
|
||||
`${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'`
|
||||
],
|
||||
['Enforce React Doctor on changed lines', staticPhase],
|
||||
['Check Zustand selector fan-out budget', staticPhase],
|
||||
['Check reliability gate manifest', staticPhase],
|
||||
['Enforce dead design-system classes', staticPhase],
|
||||
['Check VM runtime rollback compatibility', staticPhase],
|
||||
['Enforce max-lines ratchet', staticPhase],
|
||||
['Enforce ts-nocheck ratchet', staticPhase],
|
||||
['Enforce runtime Electron-import ratchet', staticPhase],
|
||||
['Check Node runtime pin', staticPhase],
|
||||
['Boot orcad and round-trip a terminal', staticPhase],
|
||||
['Verify the generated RPC params catalog', staticPhase],
|
||||
['Verify bundled skill guides', staticPhase],
|
||||
['Verify skill freshness manifest', staticPhase],
|
||||
['Verify localization coverage', staticPhase],
|
||||
['Guard against project-owned .d.ts in preload/shared', staticPhase],
|
||||
['Check feature wall asset budget', staticPhase],
|
||||
['Verify macOS entitlements', staticPhase],
|
||||
['Cache TypeScript incremental state', typePhase],
|
||||
['pnpm run typecheck', typePhase],
|
||||
['actions/upload-artifact@v7', typePhase]
|
||||
])
|
||||
expect(
|
||||
steps
|
||||
.filter((step) => step.background)
|
||||
.map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED])
|
||||
).toEqual([
|
||||
['root-lint', `\${{ ${staticPhase} }}`],
|
||||
['native-code-quality', `\${{ ${staticPhase} }}`],
|
||||
['changed-code-quality', `\${{ ${staticPhase} }}`],
|
||||
['localization-extraction', `\${{ ${staticPhase} }}`],
|
||||
['localization-catalogs', `\${{ ${staticPhase} }}`],
|
||||
['unit-plan', `\${{ ${typePhase} }}`]
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false },
|
||||
{
|
||||
changed: ['mobile/src/App.tsx'],
|
||||
static_analysis: true,
|
||||
typecheck: false,
|
||||
mobile_dependencies: true
|
||||
},
|
||||
{
|
||||
changed: ['cloud/package.json'],
|
||||
static_analysis: false,
|
||||
typecheck: false,
|
||||
mobile_dependencies: false
|
||||
},
|
||||
{
|
||||
changed: ['src/main/index.ts'],
|
||||
static_analysis: true,
|
||||
typecheck: true,
|
||||
mobile_dependencies: false
|
||||
},
|
||||
{
|
||||
changed: ['mobile/src/App.tsx', 'src/main/index.ts'],
|
||||
static_analysis: true,
|
||||
typecheck: true,
|
||||
mobile_dependencies: true
|
||||
},
|
||||
{ changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true }
|
||||
])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => {
|
||||
const scope = classifyPrJobs(changed)
|
||||
expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'")
|
||||
expect(scope.test).toBe(scope.typecheck)
|
||||
if (scope.test) {
|
||||
expect(scope.static_analysis).toBe(true)
|
||||
expect({
|
||||
static_analysis: scope.static_analysis,
|
||||
typecheck: scope.typecheck,
|
||||
mobile_dependencies: scope.mobile_dependencies
|
||||
}).toEqual(expected)
|
||||
const needs = {
|
||||
code_paths: {
|
||||
outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)]))
|
||||
}
|
||||
}
|
||||
expect(runInNewContext(preflight.if, { needs })).toBe(
|
||||
expected.static_analysis || expected.typecheck
|
||||
)
|
||||
expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck)
|
||||
expect(scope.test).toBe(expected.typecheck)
|
||||
})
|
||||
|
||||
it('registers successful no-op background work when a phase is unselected or already failed', () => {
|
||||
for (const step of steps.filter((step) => step.background)) {
|
||||
expect(step.if).toBe('!cancelled()')
|
||||
expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.')
|
||||
expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}")
|
||||
expect(step.run.split('\n')[0]).toBe(
|
||||
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs'
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks')
|
||||
const variable = (job) => job.replaceAll('-', '_').toUpperCase()
|
||||
const requiredJobs = [
|
||||
'preflight',
|
||||
...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck')
|
||||
]
|
||||
|
||||
function requiredResults(shouldRun) {
|
||||
return Object.fromEntries(
|
||||
PR_CHECK_JOBS.flatMap((job) => [
|
||||
requiredJobs.flatMap((job) => [
|
||||
[variable(job), shouldRun ? 'success' : 'skipped'],
|
||||
[`${variable(job)}_SHOULD_RUN`, String(shouldRun)]
|
||||
])
|
||||
@@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
})
|
||||
|
||||
it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => {
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
for (const job of requiredJobs) {
|
||||
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
|
||||
const verdict = await verify({ ...requiredResults(true), [variable(job)]: result })
|
||||
expect(verdict.code, `${job}: ${result}`).toBe(1)
|
||||
@@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
})
|
||||
|
||||
it('rejects unexpected downstream execution when the proven plan requires skips', async () => {
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
for (const job of requiredJobs) {
|
||||
const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' })
|
||||
expect(verdict.code, job).toBe(1)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => {
|
||||
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
|
||||
)
|
||||
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
|
||||
expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS])
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'code_paths',
|
||||
'preflight',
|
||||
...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck')
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['pr-test-loc.yml', 'mobile.yml'])(
|
||||
|
||||
@@ -27,6 +27,7 @@ const shellContractFiles = [
|
||||
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
|
||||
@@ -54,7 +55,7 @@ const realZshUsage =
|
||||
describe('PR workflow parallelism', () => {
|
||||
it('keeps lightweight orchestration jobs on the free slim runner', () => {
|
||||
expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim')
|
||||
expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim')
|
||||
expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim')
|
||||
expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim')
|
||||
@@ -79,7 +80,7 @@ describe('PR workflow parallelism', () => {
|
||||
const installStep = sharedTest.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const staticInstall = workflow.jobs.static_analysis.steps.find(
|
||||
const staticInstall = workflow.jobs.preflight.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find(
|
||||
@@ -91,7 +92,7 @@ describe('PR workflow parallelism', () => {
|
||||
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}')
|
||||
expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest')
|
||||
expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined()
|
||||
@@ -121,7 +122,7 @@ describe('PR workflow parallelism', () => {
|
||||
}
|
||||
expect(staticInstall.with['native-runtime']).toBe('node')
|
||||
expect(staticInstall.with['node-version']).toBe('24')
|
||||
expect(workflow.jobs.test.needs).toContain('static_analysis')
|
||||
expect(workflow.jobs.test.needs).toContain('preflight')
|
||||
expect(workflow.jobs.test_native_cache).toBeUndefined()
|
||||
expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node')
|
||||
expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}')
|
||||
@@ -348,11 +349,11 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['typecheck', 'git_compatibility']) {
|
||||
for (const jobName of ['git_compatibility']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('xterm_patch_sync')).toBeUndefined()
|
||||
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
||||
expect(installFor('preflight').with['native-runtime']).toBe('node')
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(sharedTestInstall.with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
@@ -478,7 +479,7 @@ describe('PR workflow parallelism', () => {
|
||||
})
|
||||
|
||||
it('reuses TypeScript incremental state across typecheck runs', () => {
|
||||
const steps = workflow.jobs.typecheck.steps
|
||||
const steps = workflow.jobs.preflight.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
|
||||
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
|
||||
|
||||
@@ -543,8 +544,7 @@ describe('PR workflow parallelism', () => {
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'code_paths',
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'preflight',
|
||||
'git_compatibility',
|
||||
'codex_index_heal_contract',
|
||||
'xterm_patch_sync',
|
||||
|
||||
@@ -258,8 +258,8 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package.
|
||||
"module": "Node16",
|
||||
// The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS.
|
||||
"module": "Node20",
|
||||
"moduleResolution": "Node16",
|
||||
"rootDir": "../src",
|
||||
"outDir": "../out"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.cli.json",
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"module": "Node20",
|
||||
"moduleResolution": "node16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,112 @@ used 42 aggregate runner-minutes across 11 test jobs. The
|
||||
estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are
|
||||
baseline observations; post-merge savings have not yet been measured.
|
||||
|
||||
## October 4 clock, byte and import test fixtures
|
||||
|
||||
These changes retain production behavior, original case names and platform
|
||||
outcomes. The paired pilots use three alternating one-worker Node 24 invocations
|
||||
on Ubuntu 24 ARM. Every median below is a complete focused test invocation;
|
||||
they do not establish whole-shard savings or queue-delay improvements.
|
||||
|
||||
| Workload | Baseline median | Candidate median | Reduction | Hosted evidence |
|
||||
| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ |
|
||||
| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) |
|
||||
| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) |
|
||||
| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) |
|
||||
| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) |
|
||||
| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) |
|
||||
| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) |
|
||||
| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) |
|
||||
| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) |
|
||||
| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) |
|
||||
| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) |
|
||||
|
||||
Provider tests wait for the real fake-child write/ready barrier and drain the
|
||||
host stream before installing a scoped parent clock. The original 2.5/5-second
|
||||
Codex and 3-second Claude deadlines remain; before/at assertions check their
|
||||
boundaries. Early rejection and refusal resolve without waiting on an unreachable
|
||||
barrier; finally and suite teardown restore clocks and spies even after a native
|
||||
Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and
|
||||
removed teardown are caught. Missing-child failure retains a real ten-second observation window.
|
||||
Six faults for early/late stop deadlines, late queued resend and missing child
|
||||
output fail the intended assertions. Native-chat tests still use the real React
|
||||
outbox hooks. Their scoped clock retains probe, retry, churn and target-switch
|
||||
windows, drains async act work, and unmounts before clock restoration. All eight
|
||||
hook faults fail; two boundary faults pass the old coarse tests and fail the new
|
||||
before/at assertions. Node/web typecheck, lint and formatting pass.
|
||||
|
||||
Native Buffer.equals replaces deep per-byte assertion traversal. It checks the
|
||||
complete original bytes and length; fixtures, SQLite operations, encryption and
|
||||
processes are unchanged. The six profile suites retain 114 passes and four
|
||||
existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven
|
||||
profile last-byte/length faults and two encrypted-vault last-byte/length faults
|
||||
fail their exact byte assertions. All six encrypted-vault cases still exercise
|
||||
52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and
|
||||
private permissions.
|
||||
|
||||
The old SSH fixture created 15,197 short stage paths but never exceeded the real
|
||||
1,048,576 UTF-16-character transport tail cap; its two valid entries also left
|
||||
the 64-result assertion vacuous. The replacement uses about 1,300 real excluded
|
||||
stage directories under long Unicode path components, a real shell channel and
|
||||
the production execCommand limiter. Actual find output exceeds that cap, while
|
||||
the generated filter retains both original valid entries. A separate population
|
||||
of 65 valid directories proves the first-64 limit and native find ordering. File,
|
||||
symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes
|
||||
match across treatments (23 passes and four unavailable PowerShell 5.1 skips on
|
||||
the hosted image). Eight cap, ordering, filtering and failure faults are caught.
|
||||
Paths use platform utilities; local PowerShell availability retains its original
|
||||
skip policy. The deadline and SSH fixtures reuse existing process/stream code.
|
||||
OpenCode subscription tests batch only uninterrupted fixture writes between the
|
||||
original observation barriers. Both SQLite schema versions keep every row, rowid,
|
||||
read cap, poll, clock position and case. No durability pragma or production code
|
||||
changes. All 28 cases pass in every pair. Separate captures compare ordered
|
||||
schema and rows, transaction state, pragmas, signals, page requests/results and
|
||||
subscriber callbacks: 124,754,101 payload bytes match, canonical digest
|
||||
`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`.
|
||||
Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and
|
||||
missing rollback each fail the intended case in both schemas. Positive rollback
|
||||
controls pass. The unmeasured full-suite timing report ranked this fixture at
|
||||
134.229 seconds; the paired 47.347-second figure above is the relevant focused
|
||||
baseline, and the two figures must not be mixed into a claimed saving.
|
||||
|
||||
Window attachment tests mock five unrelated registrar modules using their actual
|
||||
types. The existing window ownership, reload, media permission, native file drop,
|
||||
hydration barrier and updater scheduling cases remain real. Exact store/runtime/
|
||||
window arguments and daemon registration after the PTY handler are now asserted;
|
||||
nine actual production wiring/order faults fail. The registrar implementations
|
||||
retain separate handler tests. Concrete existing stubs moved to one fixture to
|
||||
stay within the line limit. All 31 cases and statuses match across all pairs. The
|
||||
final source differs from the timed source only by a required type-assertion
|
||||
safety comment.
|
||||
OpenCode 2 TUI tests use a scoped async clock only after the first real native
|
||||
module import. The unchanged generated plugin runs against the existing fake TUI
|
||||
and fetch. Original poll, permission, retry, preview, slow POST and endpoint
|
||||
windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission,
|
||||
120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes
|
||||
match. Separate ordered captures preserve 678 TUI/event rows and 362 complete
|
||||
POST start/completion rows; wall timestamps and cross-stream interleaving are
|
||||
excluded from equivalence. Eleven generated-source faults fail their intended
|
||||
identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/
|
||||
disposer rejection and timeout controls confirm restoration of clocks, fetch,
|
||||
argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown.
|
||||
Title-send authorization tests retain real terminal creation, graph binding and
|
||||
positive evidence paths. Negative process-evidence probes use scoped clocks
|
||||
after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at
|
||||
6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions
|
||||
check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual
|
||||
early/late wrapper and guard deadlines, false spinner identity and unknown-agent
|
||||
authorization faults fail their intended assertions; native clocks and runtime
|
||||
instance spies are restored after each failure.
|
||||
Range-selection tests stub only the saved-note send menu, which their empty
|
||||
comment populations never render. A facade typed from the actual menu props
|
||||
throws if invoked, and an afterEach assertion verifies no call with unconditional
|
||||
mock clearing in finally. The real hook, Monaco constants/model, line and range
|
||||
drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15
|
||||
original test bodies are byte-unchanged. Three actual range/hunk/draft faults
|
||||
fail their original assertions; a real draft-render menu call hits the facade
|
||||
and sentinel, and an outer cleanup check proves mock state cleared after failure.
|
||||
The actual saved-note menu retains its independent component tests.
|
||||
|
||||
## October 2 headless detector compiler cache
|
||||
|
||||
The deferred detector already avoids dependency setup for known build inputs.
|
||||
@@ -601,6 +707,8 @@ All commands passed and plans matched within each comparison. These command
|
||||
timings exclude setup and queues; compiler variation contributes to the cold
|
||||
difference. The retained arrangement showed no cold compiler penalty.
|
||||
|
||||
The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below.
|
||||
|
||||
Combining static analysis too was rejected. An
|
||||
[alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650)
|
||||
saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds
|
||||
@@ -1739,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings.
|
||||
|
||||
## Sequential static analysis and typecheck: retain separate jobs
|
||||
|
||||
The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity).
|
||||
|
||||
A four-trial hosted screen kept the slim router unchanged and compared the two
|
||||
independent ARM jobs with one ARM job running their unchanged checks sequentially.
|
||||
The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888)
|
||||
@@ -1858,6 +1968,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch
|
||||
is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR
|
||||
checks qualify separately.
|
||||
|
||||
## October 4 reusable cells for terminal context scans
|
||||
|
||||
Terminal cursor-context scans now request one reusable cell per invocation when
|
||||
the adapter offers getNullCell, and pass it through all unchanged text/style
|
||||
scans. Adapters without that optional method keep the existing allocating path.
|
||||
The scratch cell is local and no cell reference escapes into returned context.
|
||||
Browser composer/readiness text, colors, bold flags and wrapping are unchanged.
|
||||
|
||||
Three alternating one-worker ARM pairs in
|
||||
[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677)
|
||||
ran all 19 original cases from readiness census suite 2. Baseline complete
|
||||
invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were
|
||||
33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%.
|
||||
This is a focused workload measurement, not a whole-shard or queue-delay claim.
|
||||
|
||||
Separate baseline/candidate captures retained all 192 cases across six census
|
||||
suites. Every context and visible projection matched: 643,926 of each, with
|
||||
7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order.
|
||||
The canonical capture digest was
|
||||
`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`.
|
||||
These captures run outside the timing samples. All 41 cursor/composer/browser
|
||||
consumer checks passed. Seven faults for lost dim filtering, wide continuation,
|
||||
bold prompt, custom foreground, wrap preservation, adapter fallback and scratch
|
||||
reuse failed their intended assertions. Node and web typecheck, lint and format
|
||||
passed. Two added controls prove per-call scratch lifetime and adapter parity.
|
||||
|
||||
## October 3 producer follow-up: automatic selection for the measured profile
|
||||
|
||||
The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927)
|
||||
@@ -1895,6 +2031,36 @@ automatic selection and cold publication, not a new timing result. Local
|
||||
verification passed eight suites / 184 tests, the changed-code quality gate and
|
||||
compiled-composite actionlint.
|
||||
|
||||
## October 4 shared PR preflight capacity
|
||||
|
||||
Static analysis and the unchanged compiler now share one ARM runner and guarded
|
||||
Node 24 install. Static checks finish and all background work joins before the
|
||||
compiler starts; unit planning still overlaps compilation. Each phase keeps its
|
||||
classifier output. Successful no-op background bodies register every required
|
||||
join when a phase is unselected or an earlier step failed. Unit and package
|
||||
consumers depend on physical job success, including action cleanup.
|
||||
|
||||
Three counterbalanced pairs in
|
||||
[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601)
|
||||
used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1,
|
||||
policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan.
|
||||
Both arms used the PR root-only download-store policy. Total active job time was
|
||||
152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the
|
||||
extra measurement-only evidence steps gives 151 / 151 / 149 versus
|
||||
136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%).
|
||||
Two heavy runner admissions become one. This saves capacity; it does not prove a
|
||||
whole-PR latency or queue gain. The median active dependency barrier increases
|
||||
from 116 to 132 seconds because compilation follows static checks.
|
||||
|
||||
The separate physical-failure run
|
||||
[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694)
|
||||
proved that an included TypeScript error failed the actual compiler, its planner
|
||||
still joined, and unit/package admissions skipped. A registered late action post
|
||||
failure also blocked both consumers after successful foreground checks and
|
||||
published shards. All 12 unselected/prior-failure no-op backgrounds joined, and
|
||||
the downstream audit passed. Local workflow contracts passed 239 tests across
|
||||
12 suites; lint and formatting passed.
|
||||
|
||||
## October 3 retired-cache collection observation
|
||||
|
||||
The same owner-collection assertion failed in unit shard 3 of
|
||||
@@ -1912,6 +2078,47 @@ The source was restored afterward. Extra collection turns therefore preserve the
|
||||
strong-retention oracle. Hosted qualification is still required; these observations
|
||||
do not prove a particular VM-retention cause or quantify avoided retries.
|
||||
|
||||
## October 4 terminal oracle execution
|
||||
|
||||
Three measured test-support changes preserve the original seeds, payloads,
|
||||
chunk boundaries and meaningful assertions. Serializer comparisons reuse cells
|
||||
and format only the first mismatch instead of allocating descriptors for every
|
||||
cell. The terminal parity writer submits every original chunk in FIFO order and
|
||||
awaits the final parser callback. The independent legacy frame oracle memoizes
|
||||
measured code-point widths. Its discarded algebra-only case never called
|
||||
production and still passed when production always threw.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs measured complete invocations:
|
||||
|
||||
| Cohort | Baseline median | Candidate median | Saving |
|
||||
| ---------------------------------------- | --------------- | ---------------- | ------ |
|
||||
| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% |
|
||||
| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% |
|
||||
| Frame equivalence | 18.472s | 13.736s | 25.6% |
|
||||
|
||||
[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
|
||||
retained 116 timed serializer passes and three existing/paired-control skips.
|
||||
Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and
|
||||
8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`),
|
||||
including complete outputs and diagnostic payloads. Twenty candidate controls
|
||||
passed; formatting/color/blank/clipping fault controls detected regressions.
|
||||
|
||||
[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275)
|
||||
retained all 16 parity cases and default fuzz counts. Captures matched 2,325
|
||||
batches, 28,182 original chunks and 1,698,285 input bytes, with identical
|
||||
terminal state and serialization per terminal/batch. Independent terminal
|
||||
completion order differs, so comparison uses canonical per-terminal ordering
|
||||
(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`).
|
||||
All 73 callback/parser/other-consumer controls passed; first-callback, reversed
|
||||
chunks, missing empty boundary and early-completion faults failed.
|
||||
|
||||
The frame candidate passed all 19 retained cases directly against the original
|
||||
uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials.
|
||||
Sequence, surrogate width, byte width and span-transform faults failed real
|
||||
assertions. A part-array alternative was rejected after adding time locally.
|
||||
Hosted Node typecheck passed. These are focused workload savings, not measured
|
||||
whole-shard or queue-delay improvements; application behavior is unchanged.
|
||||
|
||||
## October 3 unit-selection evidence: include failed references
|
||||
|
||||
The caller's `needs.test.result == 'success'` condition prevented the advisory
|
||||
@@ -1962,11 +2169,11 @@ Three alternating one-worker hosted ARM pairs in
|
||||
[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976)
|
||||
measured these complete invocations:
|
||||
|
||||
| Cohort | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --- | --- | --- | --- |
|
||||
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
|
||||
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
|
||||
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
|
||||
| Cohort | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --------------------------------- | ------------------------ | ------------------------ | ------------- |
|
||||
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
|
||||
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
|
||||
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
|
||||
|
||||
The final runtime cohort has seven real cases versus 16 including the copied
|
||||
loops; its new runtime case is included in candidate timing. Recovery has 50
|
||||
@@ -2048,9 +2255,9 @@ Three alternating one-worker hosted ARM pairs in
|
||||
[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492)
|
||||
measured these complete focused invocations:
|
||||
|
||||
| Suite | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --- | --- | --- | --- |
|
||||
| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
|
||||
| Suite | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --------------------- | ------------------------ | ------------------------ | --------------- |
|
||||
| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
|
||||
| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) |
|
||||
|
||||
Each candidate passed its original meaningful checks. Hosted Node typecheck
|
||||
@@ -2059,3 +2266,120 @@ FIFO-only priority failed the queued-contention or interactive-start assertion.
|
||||
Two additional local transcript faults failed the original picker-rejection and
|
||||
repaint-readiness assertions. These are focused suite savings; whole-shard time
|
||||
and queue delay were not measured by this experiment.
|
||||
|
||||
## October 4 aggregate unit-test comparison
|
||||
|
||||
A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399)
|
||||
measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second
|
||||
reduction in the slowest shard (5.98%). It compares the accepted optimizations
|
||||
with their original file snapshots on the same source, five fixed shard
|
||||
assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired
|
||||
trial, not a population estimate or a measurement of PR queue delay.
|
||||
|
||||
| Test process | Original snapshots | Accepted optimizations |
|
||||
| ----------------------------- | ------------------ | ---------------------- |
|
||||
| Shard 1 | 574.221s | 533.116s |
|
||||
| Shard 2 | 572.553s | 569.593s |
|
||||
| Shard 3 | 630.629s | 592.935s |
|
||||
| Shard 4 | 565.412s | 546.759s |
|
||||
| Shard 5 | 609.410s | 581.218s |
|
||||
| Sum: runner time during tests | 2952.225s | 2823.621s |
|
||||
| Maximum: test critical path | 630.629s | 592.935s |
|
||||
|
||||
Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree
|
||||
`4d5487e8b827`. One added eight-case batching qualification passes in a separate
|
||||
0.770-second invocation outside the table, completing the 10,839-module ordinary
|
||||
census. The complete timed case and outcome
|
||||
comparison accounts for eight approved coverage changes: 105,231 original cases
|
||||
versus 105,242 candidate cases. Removed copied simulations and an algebra-only
|
||||
case are accompanied by real runtime, retention, recovery and reusable-cell
|
||||
regressions. No unexpected case or outcome difference is accepted.
|
||||
|
||||
Each arm starts with distinct empty transform and result caches; Node compilation
|
||||
caching is disabled. Cold-cache execution ordering remains Vitest's default and
|
||||
can change with source size. Source snapshots, assignments, raw reports and case outcomes
|
||||
are checked. Only three case-title fields containing random temporary paths or
|
||||
a UUID use stable identities, bound to the exact two test-source hashes; raw
|
||||
titles remain in the artifacts.
|
||||
|
||||
The five dependency setups total 84.284 seconds and are shared by both arms.
|
||||
The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the
|
||||
first start to the last completion. Those job figures include both treatments,
|
||||
setup, uploads and staggered starts; they cannot be assigned to either arm or
|
||||
used as a workflow saving. The table measures test-process wall time, not CPU
|
||||
time or the complete CI workflow. Focused-suite percentages elsewhere in this
|
||||
report are separate measurements and must not be summed into these results.
|
||||
|
||||
The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646)
|
||||
is rejected because a real test failed; its timings do not qualify a gain. Two
|
||||
local invocations sharing one XDG directory reproduced the Muse refresher failure.
|
||||
The fixture now isolates and restores that setting in both arms. The historical
|
||||
serializer case ledger was also independently corrected from the original source
|
||||
before this fresh trial; its seven original cases and twenty candidate cases are
|
||||
an explicit coverage change rather than an assumed equal census.
|
||||
|
||||
## October 4 shard-weight holdouts
|
||||
|
||||
Fresh shard weights were generated with the production importer from a complete
|
||||
successful run of the accepted source. Two subsequent hosted holdouts used those
|
||||
same weights and assignments without retraining. The
|
||||
[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated
|
||||
existing and fresh assignments across the five jobs; the
|
||||
[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed
|
||||
each job's treatment order.
|
||||
|
||||
| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh |
|
||||
| ------------------------ | --------------- | ------------ | ------------------ | --------------- |
|
||||
| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s |
|
||||
| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s |
|
||||
|
||||
Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased
|
||||
the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with
|
||||
a repeated critical-path regression, so the existing weights remain. The 3.01%
|
||||
improvement projected from training module durations is not a measured speed
|
||||
gain.
|
||||
|
||||
Every arm covers the same 10,839 modules and 105,250 case outcomes on source
|
||||
`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers.
|
||||
Both trials use cold caches and the same training data, weights, plans and case
|
||||
identity rules. Complete raw reports, assignments, hashes and opposite treatment
|
||||
orders are checked before combining the results. Two pairs supply no statistical
|
||||
confidence or account-wide queue measurement. The second run's jobs started 119
|
||||
seconds apart; that stagger and the paired jobs' setup and upload costs are
|
||||
separate from the treatment timings above.
|
||||
|
||||
## October 4 remaining unit-test opportunities
|
||||
|
||||
The audit retained real child-process, PTY, SSH and crash-boundary tests. It
|
||||
removed copied simulations or algebra-only cases after fault controls showed
|
||||
that they could pass with production behavior broken. The retained or replacement
|
||||
tests exercise production behavior directly. The focused timings in this report
|
||||
use the final qualified checks. They must not be added together to estimate a
|
||||
whole-workflow saving.
|
||||
|
||||
Several further changes did not justify promotion:
|
||||
|
||||
- A synchronous readiness-clock screen retained all 49 shard cases and their
|
||||
outcomes, but complete invocation time changed only from 34.210 to 33.518
|
||||
seconds in one local pair. That 2% result was too small to ship without a
|
||||
stronger result; the original implementation remains.
|
||||
- A larger local transform-cache screen reduced warm test execution. Separately
|
||||
measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and
|
||||
archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds
|
||||
with caching disabled. This component estimate excludes transfer costs; it is not an
|
||||
end-to-end measurement. Unkeyed plugin options, inherited configuration and
|
||||
import priority also produced stale reuse. Persisted test transforms remain
|
||||
disabled.
|
||||
- Two successful full-run shadow references identified about 1.9% of
|
||||
recorded worker time as omittable. That is advisory worker time, not measured
|
||||
runner occupancy. It does not supply the failed-reference evidence or a
|
||||
complete selected-run comparison needed to enable test selection.
|
||||
- Six sampled failed PR runs contained no failed unit job that could trigger
|
||||
unit-matrix fail-fast. Successful unit siblings of failures in other jobs
|
||||
cannot be counted as savings from that policy. The sample is too small to
|
||||
establish a population-wide rate, and the policy remains unchanged.
|
||||
|
||||
These screens reject the examined changes; they do not establish that every
|
||||
future optimization is exhausted. Shorter admitted jobs and one shared preflight
|
||||
reduce demand on the existing runner allowance. They do not increase that
|
||||
allowance or prove lower queue delay under different account traffic.
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
# jcode hook events
|
||||
|
||||
What jcode actually emits, and why Orca's status mapping is shaped the way it is.
|
||||
Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every
|
||||
`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to
|
||||
a log, then running real turns. Re-capture before changing the mapping; do not
|
||||
edit it from memory.
|
||||
|
||||
## The six events
|
||||
|
||||
jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle
|
||||
points. Five are **observers** — detached, fire-and-forget, they can never slow
|
||||
the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input
|
||||
to its stdin, and waits for it to exit before the tool runs.
|
||||
|
||||
| Event | When | Orca state | Notable payload fields |
|
||||
| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- |
|
||||
| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` |
|
||||
| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` |
|
||||
| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) |
|
||||
| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` |
|
||||
| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` |
|
||||
| `session_end` | session closed | `done` | `source` = `close` |
|
||||
|
||||
`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping
|
||||
it to `working` would spin before the user has typed anything (same reason Devin
|
||||
does not map its `SessionStart`).
|
||||
|
||||
## Captured payloads
|
||||
|
||||
A `jcode run` turn that read one file and wrote another:
|
||||
|
||||
```json
|
||||
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"}
|
||||
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"}
|
||||
{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"}
|
||||
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"}
|
||||
{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"}
|
||||
```
|
||||
|
||||
A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit):
|
||||
|
||||
```json
|
||||
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"}
|
||||
{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"}
|
||||
{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"}
|
||||
```
|
||||
|
||||
Three consequences the mapping depends on:
|
||||
|
||||
- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm
|
||||
workers, headless sessions), not `jcode run`. It is what fills the otherwise
|
||||
blank window between a submitted prompt and the first tool call.
|
||||
- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the
|
||||
outcome, so the tool preview has to be held from the matching `pre_tool`.
|
||||
- **Every jcode tool schema has an `intent` string** the model fills in. It is
|
||||
the preview fallback when no tool-specific key (`file_path`, `command`, …)
|
||||
matches.
|
||||
|
||||
## Why Orca subscribes to the gate
|
||||
|
||||
`pre_tool` is the only event that can report a tool *while it runs*. Without it a
|
||||
three-minute `bash` shows no tool at all until it finishes. Two rules keep the
|
||||
gate from ever costing the agent anything:
|
||||
|
||||
1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits
|
||||
for the process *and* reads its stderr to EOF — a backgrounded child that
|
||||
inherited stderr would hold the gate open for as long as it ran. The managed
|
||||
script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the
|
||||
inherited pipes are closed and the script exits immediately.
|
||||
2. **stdin is drained first.** jcode `write_all`s the full tool input to the
|
||||
hook's stdin. A tool input larger than the pipe buffer (a big `write`) would
|
||||
block that write until the gate timed out if nobody read it, so the script
|
||||
drains stdin before any exit path.
|
||||
|
||||
Orca never blocks a jcode tool call: the script always exits 0.
|
||||
|
||||
## Questions and permissions
|
||||
|
||||
jcode has **no interactive per-tool approval prompt**. Its safety model
|
||||
(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a
|
||||
command outright or asks the model to justify it — both inside the tool, with no
|
||||
human in the loop. There is therefore no hook, and no terminal-title state, for
|
||||
"jcode is waiting on you" during ordinary tool use.
|
||||
|
||||
The one tool a *human* answers is ambient mode's `request_permission`
|
||||
(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with
|
||||
`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the
|
||||
tool input as the question card. `post_tool` for the same tool is *not* mapped —
|
||||
by then the human has already answered.
|
||||
|
||||
Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch,
|
||||
bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail,
|
||||
integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory,
|
||||
multiedit, open, panel, patch, read, schedule, session_search, side_panel,
|
||||
skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools;
|
||||
a substring rule over that set would be matching on coincidence.
|
||||
|
||||
## Terminal titles
|
||||
|
||||
jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI
|
||||
session:
|
||||
|
||||
```
|
||||
jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s
|
||||
```
|
||||
|
||||
The format is `<emoji> jcode <session-name>[ · +N -M][ · work|last ~<duration>]`
|
||||
(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar
|
||||
identity only — status comes from hooks, never from a parsed title. Note there is
|
||||
no "needs input" title state; that is the same gap as above, not an omission in
|
||||
the parser.
|
||||
|
||||
## Per-pane daemons
|
||||
|
||||
jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire
|
||||
*inside the daemon*. Every TUI client connects the daemon the first pane started,
|
||||
so without isolation a second jcode pane's events carry the first pane's
|
||||
`ORCA_PANE_KEY` and its status lands on the wrong tab.
|
||||
|
||||
jcode does forward a client's terminal identity to hooks
|
||||
(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but
|
||||
that allowlist covers tmux/zellij/herdr and the terminal emulators — not
|
||||
`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so
|
||||
each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of
|
||||
the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a
|
||||
full pane key never fits.
|
||||
|
||||
## Windows hook launcher
|
||||
|
||||
Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with
|
||||
`DETACHED_PROCESS`, leaving their children without a console to inherit. A
|
||||
console program such as the managed hook's `curl.exe` can then open a Windows
|
||||
Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for
|
||||
observer hooks and the `pre_tool` gate, keeping their descendants invisible.
|
||||
Changing the managed script alone cannot repair an older Jcode launcher.
|
||||
|
||||
The managed Windows hook redirects its payload file into curl directly, avoiding
|
||||
the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts
|
||||
are refreshed on Orca startup without changing the user's hook configuration.
|
||||
|
||||
Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574
|
||||
Launcher fix: https://github.com/1jehuang/jcode/pull/1490
|
||||
|
||||
## Config shape
|
||||
|
||||
`[hooks]` values accept a string or an array of strings (`HookCommands` in
|
||||
`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload,
|
||||
so hooks can be added without restarting. jcode parses a hook command line
|
||||
shell-style but **executes it directly, not through a shell** — the managed value
|
||||
must be the script path, never an `if [ -f … ]` wrapper.
|
||||
|
||||
That shell-style parse is `parse_hook_command`
|
||||
(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path
|
||||
**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every
|
||||
unquoted backslash as an escape, so a bare Windows path reaches `exec` as
|
||||
`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home
|
||||
with a space splits into two arguments. Single quotes pass a path through
|
||||
verbatim — backslashes are literal inside them — so Orca single-quotes by
|
||||
default and falls back to double quotes (escaping `\` and `"`) only for a path
|
||||
that itself contains a single quote. The value is then TOML-quoted on the way
|
||||
into the file, so neither the raw path nor the shell-quoted string appears
|
||||
alone.
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'@streamparser/json',
|
||||
'stream-json',
|
||||
'stream-chain',
|
||||
'@xterm/headless',
|
||||
'@xterm/addon-serialize',
|
||||
'tldts',
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PickerOption<T extends string = string> = {
|
||||
type Props<T extends string = string> = {
|
||||
visible: boolean
|
||||
title: string
|
||||
subtitle?: string
|
||||
options: PickerOption<T>[]
|
||||
selected: T
|
||||
onSelect: (value: T) => void
|
||||
@@ -32,6 +33,7 @@ type PickerModalContentProps<T extends string = string> = Pick<
|
||||
export function PickerModal<T extends string = string>({
|
||||
visible,
|
||||
title,
|
||||
subtitle,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
@@ -44,6 +46,7 @@ export function PickerModal<T extends string = string>({
|
||||
<BottomDrawer visible={visible} onClose={onClose} onAfterClose={onAfterClose} zIndex={zIndex}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||
</View>
|
||||
|
||||
<PickerModalContent
|
||||
@@ -130,6 +133,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
color: colors.textMuted
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -12,7 +12,8 @@ import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
|
||||
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
|
||||
import {
|
||||
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
|
||||
WORKSPACE_VIEW_SHARED_NOTE
|
||||
} from '../worktree/workspace-list-picker-options'
|
||||
import { isWorktreePinned } from '../worktree/workspace-list-sections'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
@@ -37,6 +38,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
<PickerModal
|
||||
visible={state.showSortPicker}
|
||||
title="Sort By"
|
||||
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
|
||||
options={SORT_OPTIONS}
|
||||
selected={state.sortMode}
|
||||
onSelect={settings.handleSortChange}
|
||||
@@ -46,6 +48,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
<PickerModal
|
||||
visible={state.showGroupPicker}
|
||||
title="Group By"
|
||||
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
|
||||
options={GROUP_OPTIONS}
|
||||
selected={state.groupMode}
|
||||
onSelect={settings.handleGroupChange}
|
||||
@@ -54,7 +57,10 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
|
||||
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
|
||||
<View style={styles.filterModalHeader}>
|
||||
<Text style={styles.filterModalTitle}>Filter</Text>
|
||||
<View style={styles.filterModalHeading}>
|
||||
<Text style={styles.filterModalTitle}>Filter</Text>
|
||||
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
|
||||
</View>
|
||||
{settings.activeFilterCount > 0 && (
|
||||
<Pressable onPress={settings.clearFilters}>
|
||||
<Text style={styles.clearFiltersText}>Clear filters</Text>
|
||||
|
||||
@@ -47,11 +47,19 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalHeading: {
|
||||
flexShrink: 1
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterModalSubtitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
|
||||
@@ -158,7 +158,11 @@ export function useMobileStructuredAgentSession(args: {
|
||||
})
|
||||
|
||||
const messages = useMemo(
|
||||
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
|
||||
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
|
||||
() =>
|
||||
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
|
||||
rejectedInPlace: false
|
||||
}),
|
||||
[state.items, state.submissions]
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { PickerOption } from '../components/PickerModal'
|
||||
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
|
||||
|
||||
// Why: the host may be headless, so the note can't promise a desktop sidebar.
|
||||
export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices'
|
||||
|
||||
export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
|
||||
// Why: desktop and persisted state keep the `smart` key, while mobile shows the product label.
|
||||
{
|
||||
@@ -11,7 +14,7 @@ export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
|
||||
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
|
||||
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' },
|
||||
{ value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' },
|
||||
{ value: 'manual', label: 'Manual', subtitle: 'Server order' }
|
||||
{ value: 'manual', label: 'Manual', subtitle: 'Desktop drag order' }
|
||||
]
|
||||
|
||||
export const WORKSPACE_GROUP_OPTIONS: PickerOption<MobileGroupMode>[] = [
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/typ
|
||||
import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses'
|
||||
|
||||
export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
|
||||
// Desktop sort adds 'manual'; mobile renders it but sorts by server order.
|
||||
// Desktop sort adds 'manual'; mobile orders it by the desktop's drag ranks.
|
||||
export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual'
|
||||
|
||||
// Desktop PersistedUIState fields this screen syncs (a structural subset).
|
||||
|
||||
+2
-1
@@ -182,7 +182,6 @@
|
||||
"@floating-ui/dom": "1.8.0",
|
||||
"@linear/sdk": "^97.0.0",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@streamparser/json": "0.0.26",
|
||||
"@xterm/addon-serialize": "0.15.0-beta.300",
|
||||
"@xterm/headless": "6.1.0-beta.302",
|
||||
"agent-browser": "~0.27.0",
|
||||
@@ -198,6 +197,8 @@
|
||||
"sherpa-onnx": "1.12.37",
|
||||
"smol-toml": "1.8.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"stream-chain": "4.2.6",
|
||||
"stream-json": "3.7.0",
|
||||
"tldts": "7.4.16",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.22.0",
|
||||
|
||||
Generated
+18
-8
@@ -196,9 +196,6 @@ importers:
|
||||
'@parcel/watcher':
|
||||
specifier: ^2.5.6
|
||||
version: 2.5.6
|
||||
'@streamparser/json':
|
||||
specifier: 0.0.26
|
||||
version: 0.0.26
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.300
|
||||
version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))
|
||||
@@ -244,6 +241,12 @@ importers:
|
||||
ssh2:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
stream-chain:
|
||||
specifier: 4.2.6
|
||||
version: 4.2.6
|
||||
stream-json:
|
||||
specifier: 3.7.0
|
||||
version: 3.7.0
|
||||
tldts:
|
||||
specifier: 7.4.16
|
||||
version: 7.4.16
|
||||
@@ -3185,9 +3188,6 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@streamparser/json@0.0.26':
|
||||
resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -7354,6 +7354,12 @@ packages:
|
||||
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
stream-chain@4.2.6:
|
||||
resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==}
|
||||
|
||||
stream-json@3.7.0:
|
||||
resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
|
||||
|
||||
@@ -10208,8 +10214,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@streamparser/json@0.0.26': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
optional: true
|
||||
|
||||
@@ -14965,6 +14969,12 @@ snapshots:
|
||||
|
||||
stdin-discarder@0.3.2: {}
|
||||
|
||||
stream-chain@4.2.6: {}
|
||||
|
||||
stream-json@3.7.0:
|
||||
dependencies:
|
||||
stream-chain: 4.2.6
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
|
||||
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
builtin typeset -g __orca_in_command=1
|
||||
}
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -54,12 +88,27 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
|
||||
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
else
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
fi
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
@@ -202,13 +251,25 @@ __orca_deferred_init() {
|
||||
# the permanent hook has not run yet and the first prompt would lose its mark.
|
||||
__orca_has_feature markers && __orca_osc133_precmd
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then
|
||||
if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then
|
||||
export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}"
|
||||
|
||||
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
builtin typeset -g __orca_in_command=1
|
||||
}
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -54,8 +88,17 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
|
||||
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
else
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
@@ -67,6 +110,12 @@ __orca_deferred_init() {
|
||||
printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
@@ -219,13 +268,25 @@ __orca_deferred_init() {
|
||||
# the permanent hook has not run yet and the first prompt would lose its mark.
|
||||
__orca_has_feature markers && __orca_osc133_precmd
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ fi
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
|
||||
@@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
|
||||
builtin unset ORCA_HISTFILE
|
||||
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -40,7 +74,21 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
@@ -170,13 +218,25 @@ __orca_deferred_init() {
|
||||
zle -N zle-line-init __orca_prompt_mark
|
||||
fi
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -147,8 +147,10 @@ describe('managed hook script refresh', () => {
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
const previousKimiHome = process.env.KIMI_CODE_HOME
|
||||
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
try {
|
||||
await withPlatform('win32', () => {
|
||||
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
@@ -187,6 +189,11 @@ describe('managed hook script refresh', () => {
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = previousKimiHome
|
||||
}
|
||||
if (previousXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from './server'
|
||||
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
|
||||
import { PANE } from './server.test-fixtures'
|
||||
|
||||
describe('startup prompt control with status hooks disabled', () => {
|
||||
it.each([false, true])(
|
||||
'persists a pending terminal status at shutdown after starting with hooks %s',
|
||||
async (statusHooksEnabled) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
|
||||
const server = new AgentHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled })
|
||||
server.setStatusHooksEnabled(false)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'folder-1',
|
||||
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
|
||||
})
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
|
||||
const statusPath = server.lastStatusPath
|
||||
if (!statusPath) {
|
||||
throw new Error('missing status persistence path')
|
||||
}
|
||||
server.stop()
|
||||
expect(existsSync(statusPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'folder-1',
|
||||
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('enables and disables status without restarting the control listener', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
|
||||
class IsolatedHookServer extends AgentHookServer {
|
||||
constructor() {
|
||||
super()
|
||||
this._setOpenCodeBinderDepsForTests({
|
||||
dbPath: () => join(dir, 'no-user-db'),
|
||||
listSessions: async () => [],
|
||||
listPanes: () => [],
|
||||
sweep: async () => []
|
||||
})
|
||||
}
|
||||
}
|
||||
const server = new IsolatedHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled: false })
|
||||
const endpoint = server.endpointFilePath
|
||||
if (!endpoint) {
|
||||
throw new Error('missing control endpoint')
|
||||
}
|
||||
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
|
||||
const post = (path: string) =>
|
||||
fetch(`http://127.0.0.1:${coords.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-orca-agent-hook-token': coords.token },
|
||||
body: '{}'
|
||||
})
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
await server.start({ statusHooksEnabled: true })
|
||||
expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
|
||||
expect((await post('/hook/opencode')).status).toBe(204)
|
||||
server.setStatusHooksEnabled(false)
|
||||
expect(server.buildPtyEnv()).toEqual({})
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
await server.start()
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
server.setStartupPromptClaimListener(
|
||||
() => 'pending',
|
||||
() => {}
|
||||
)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
|
||||
allowed: false,
|
||||
pending: true
|
||||
})
|
||||
server.setStatusHooksEnabled(true)
|
||||
expect((await post('/hook/opencode')).status).toBe(204)
|
||||
expect(server.endpointFilePath).toBe(endpoint)
|
||||
expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
|
||||
const server = new AgentHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled: false })
|
||||
expect(server.buildPtyEnv()).toEqual({})
|
||||
const statusPath = server.lastStatusPath
|
||||
if (!statusPath) {
|
||||
throw new Error('missing status persistence path')
|
||||
}
|
||||
writeFileSync(statusPath, 'existing status must survive control-only shutdown')
|
||||
const endpoint = server.endpointFilePath
|
||||
if (!endpoint) {
|
||||
throw new Error('missing control endpoint')
|
||||
}
|
||||
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
|
||||
const post = (path: string, body: string, token = coords.token) =>
|
||||
fetch(`http://127.0.0.1:${coords.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
|
||||
body
|
||||
})
|
||||
expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
|
||||
allowed: false
|
||||
})
|
||||
const clear = vi.fn()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, clear)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
|
||||
allowed: true
|
||||
})
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
|
||||
allowed: false
|
||||
})
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect((await post('/hook/opencode', '{}')).status).toBe(404)
|
||||
expect((await post('/statusline/claude', '{}')).status).toBe(404)
|
||||
server.stop()
|
||||
expect(clear).toHaveBeenCalledTimes(1)
|
||||
expect(readFileSync(statusPath, 'utf8')).toBe(
|
||||
'existing status must survive control-only shutdown'
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,11 @@
|
||||
// short of its own Content-Length. The listener fails open on every request error, so the only
|
||||
// way this stays diagnosable is if the truncation is classified before it is swallowed.
|
||||
import { connect } from 'node:net'
|
||||
import { ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
|
||||
import { AgentHookServer } from './server'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
|
||||
|
||||
async function postTruncatedHook(
|
||||
port: number,
|
||||
@@ -40,11 +42,15 @@ async function postTruncatedHook(
|
||||
}
|
||||
|
||||
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
|
||||
async function postStalledHook(port: number, token: string): Promise<void> {
|
||||
async function postStalledHook(
|
||||
port: number,
|
||||
token: string,
|
||||
pathname = '/hook/claude'
|
||||
): Promise<void> {
|
||||
const socket = connect({ port, host: '127.0.0.1' })
|
||||
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
|
||||
socket.write(
|
||||
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
)
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.on('close', () => resolve())
|
||||
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
|
||||
expect(reports).toHaveLength(1)
|
||||
}, 20_000)
|
||||
|
||||
it('classifies an interrupted startup claim as retryable without consuming it', async () => {
|
||||
const { server, port, token, reports } = await startServer()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, () => {})
|
||||
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
|
||||
try {
|
||||
await postTruncatedHook(port, token, {
|
||||
pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
|
||||
sentBytes: '{"nonce":',
|
||||
announcedLength: 1000
|
||||
})
|
||||
// The reset peer cannot receive this response; observe the real handler's classification.
|
||||
expect(writeHead.mock.calls).toEqual([[503]])
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(reports).toEqual([])
|
||||
} finally {
|
||||
writeHead.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
|
||||
const { server, port, token, reports } = await startServer()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, () => {})
|
||||
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
|
||||
try {
|
||||
await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
|
||||
expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(reports).toEqual([])
|
||||
} finally {
|
||||
writeHead.mockRestore()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('never reports for POSTs that deliver their whole body', async () => {
|
||||
const { port, token, reports } = await startServer()
|
||||
|
||||
|
||||
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
|
||||
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
|
||||
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
|
||||
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
|
||||
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
|
||||
import { trackEmptyPaneKeyHook } from './server-transport-rules'
|
||||
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
|
||||
import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
|
||||
|
||||
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
|
||||
export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
|
||||
/** Start the loopback listener after hydration and spool replay have settled. */
|
||||
async start(options?: {
|
||||
env?: string
|
||||
userDataPath?: string
|
||||
endpointNamespace?: string
|
||||
statusHooksEnabled?: boolean
|
||||
}): Promise<void> {
|
||||
if (this.server) {
|
||||
if (options?.statusHooksEnabled !== undefined) {
|
||||
this.setStatusHooksEnabled(options.statusHooksEnabled)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.statusHooksEnabled = options?.statusHooksEnabled !== false
|
||||
|
||||
if (options?.env) {
|
||||
this.env = options.env
|
||||
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.token = randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.lastWrittenJson = null
|
||||
if (!this.ownerStateInitialized) {
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
// Drain before binding the listener so replay cannot race a live hook during startup.
|
||||
if (this.endpointDir) {
|
||||
const replayedPaneKeys = new Set<string>()
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: (paneKey) =>
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
|
||||
ingest: (record: SpoolRecord) => {
|
||||
this.ingestSpoolRecord(record)
|
||||
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
|
||||
}
|
||||
})
|
||||
// Why: the owner may have died while Orca was down; check each replayed pane once.
|
||||
for (const paneKey of replayedPaneKeys) {
|
||||
void this.checkAgentPresence(paneKey)
|
||||
}
|
||||
}
|
||||
this.ownerStateInitialized = true
|
||||
if (this.statusHooksEnabled) {
|
||||
this.initializeStatusHookOwner()
|
||||
}
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'POST') {
|
||||
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
const claim = this.onStartupPromptClaim?.(body)
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
allowed: claim === true,
|
||||
...(claim === 'pending' ? { pending: true } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.statusHooksEnabled) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
|
||||
const statusLineEvent = parseClaudeStatusLineBody(body)
|
||||
if (statusLineEvent) {
|
||||
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch (error) {
|
||||
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
|
||||
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
|
||||
res.writeHead(503)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{"allowed":false}')
|
||||
return
|
||||
}
|
||||
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
|
||||
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
|
||||
// this is the one failure mode that silently stops status for every runtime at once.
|
||||
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.rollbackTransportStart()
|
||||
throw error
|
||||
}
|
||||
this.startOpenCodeBinderLoop()
|
||||
if (this.statusHooksEnabled) {
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private rollbackTransportStart(): void {
|
||||
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
|
||||
this.flushStatusPersistSync()
|
||||
// Terminal status may still have a pending write while hook ingress is disabled.
|
||||
if (this.statusHooksEnabled || this.statusPersistTimer) {
|
||||
this.flushStatusPersistSync()
|
||||
}
|
||||
this.stopOpenCodeBinderLoop()
|
||||
this.stopTmuxStatus()
|
||||
this.rollbackTransportStart()
|
||||
this.env = 'production'
|
||||
this.onAgentStatus = null
|
||||
this.onClaudeStatusLine = null
|
||||
this.clearStartupPromptClaims?.()
|
||||
this.clearStartupPromptClaims = null
|
||||
this.onStartupPromptClaim = null
|
||||
this.onPaneStatusCleared = null
|
||||
this.onTransportInterference = null
|
||||
this.transportInterference.reset()
|
||||
|
||||
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
|
||||
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
|
||||
|
||||
export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
setStartupPromptClaimListener(
|
||||
listener: (body: unknown) => boolean | 'pending',
|
||||
clear: () => void
|
||||
): void {
|
||||
this.onStartupPromptClaim = listener
|
||||
this.clearStartupPromptClaims = clear
|
||||
}
|
||||
|
||||
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
|
||||
|
||||
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
|
||||
buildPtyEnv(): Record<string, string> {
|
||||
if (this.port <= 0 || !this.token) {
|
||||
if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
|
||||
return {}
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
|
||||
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
|
||||
protected env = 'production'
|
||||
protected onAgentStatus: ServerAgentStatusListener = null
|
||||
protected onClaudeStatusLine: ServerStatusLineListener = null
|
||||
protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
|
||||
protected clearStartupPromptClaims: (() => void) | null = null
|
||||
protected statusHooksEnabled = true
|
||||
protected onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
|
||||
protected statusDropListeners = new Set<StatusDropListener>()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
|
||||
|
||||
export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
|
||||
setStatusHooksEnabled(enabled: boolean): void {
|
||||
if (enabled === this.statusHooksEnabled) {
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
this.flushStatusPersistSync()
|
||||
this.stopOpenCodeBinderLoop()
|
||||
for (const timer of this.assistantMessageRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.assistantMessageRetryTimers.clear()
|
||||
this.clearAllTranscriptPolls()
|
||||
}
|
||||
this.statusHooksEnabled = enabled
|
||||
if (enabled && this.server) {
|
||||
this.initializeStatusHookOwner()
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
protected initializeStatusHookOwner(): void {
|
||||
if (!this.ownerStateInitialized) {
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
// Drain before binding the listener so replay cannot race a live hook during startup.
|
||||
if (this.endpointDir) {
|
||||
const replayedPaneKeys = new Set<string>()
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: (paneKey) =>
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
|
||||
ingest: (record: SpoolRecord) => {
|
||||
this.ingestSpoolRecord(record)
|
||||
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
|
||||
}
|
||||
})
|
||||
// Why: the owner may have died while Orca was down; check each replayed pane once.
|
||||
for (const paneKey of replayedPaneKeys) {
|
||||
void this.checkAgentPresence(paneKey)
|
||||
}
|
||||
}
|
||||
this.ownerStateInitialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a token-bearing config.json owner-only', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
// Why: a restrictive runner umask would mask a dropped mode.
|
||||
const originalUmask = process.umask(0o022)
|
||||
try {
|
||||
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
|
||||
writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), {
|
||||
mode: 0o600
|
||||
})
|
||||
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' })
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a missing config.json owner-only', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const originalUmask = process.umask(0o022)
|
||||
try {
|
||||
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
|
||||
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing config keys and dedups already-trusted folders', () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const realpath = realpathSync(workspace)
|
||||
|
||||
@@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
// Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts).
|
||||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readStreamedSessionDocument } from './session-document-stream'
|
||||
|
||||
async function* bytes(content: string, chunkSize = 7): AsyncGenerator<Buffer> {
|
||||
const buffer = Buffer.from(content)
|
||||
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
||||
yield buffer.subarray(offset, offset + chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
function read(
|
||||
content: string,
|
||||
overrides: Partial<Parameters<typeof readStreamedSessionDocument<unknown[]>>[0]> = {}
|
||||
) {
|
||||
return readStreamedSessionDocument({
|
||||
bytes: bytes(content),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: (): unknown[] => [],
|
||||
consume: (state, value) => {
|
||||
state.push(value)
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('streamed session document contracts', () => {
|
||||
it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])(
|
||||
'a later messages value %s clears an earlier fold',
|
||||
async (last) => {
|
||||
expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] })
|
||||
}
|
||||
)
|
||||
|
||||
it('replaces a failed earlier array and continues with its successor', async () => {
|
||||
const consume = (state: unknown[], value: unknown): void => {
|
||||
if (value === 'bad') {
|
||||
throw new Error('discarded failure')
|
||||
}
|
||||
state.push(value)
|
||||
}
|
||||
expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({
|
||||
record: {},
|
||||
state: [2, 3]
|
||||
})
|
||||
expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({
|
||||
record: {},
|
||||
state: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the first consumer failure unless a later array replaces it', async () => {
|
||||
const failure = new Error('consumer failed')
|
||||
let calls = 0
|
||||
await expect(
|
||||
read('{"messages":[1,2]}', {
|
||||
consume: () => {
|
||||
calls++
|
||||
throw failure
|
||||
}
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('gives malformed trailing JSON precedence over a consumer failure', async () => {
|
||||
await expect(
|
||||
read('{"messages":[1]} trailing', {
|
||||
consume: () => {
|
||||
throw new Error('consumer')
|
||||
}
|
||||
})
|
||||
).rejects.toBeInstanceOf(SyntaxError)
|
||||
})
|
||||
|
||||
it('keeps projected object resets and full-field overlap precedence', async () => {
|
||||
expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({
|
||||
record: { agent: {} },
|
||||
state: []
|
||||
})
|
||||
expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({
|
||||
record: { agent: { model: 'm', extra: [1, 2] } },
|
||||
state: []
|
||||
})
|
||||
expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({
|
||||
record: { messages: {} },
|
||||
state: [1, 2]
|
||||
})
|
||||
expect(
|
||||
await read('{"messages":{"model":"m","ignored":1}}', {
|
||||
objectFields: { messages: ['model'] }
|
||||
})
|
||||
).toEqual({ record: { messages: { model: 'm' } }, state: [] })
|
||||
})
|
||||
|
||||
it('preserves selected prototype keys as own properties', async () => {
|
||||
const content =
|
||||
'{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}'
|
||||
const parsed = await read(content)
|
||||
expect(parsed?.record).toEqual(JSON.parse(content))
|
||||
expect(Object.getPrototypeOf(parsed?.record)).toBeNull()
|
||||
expect(Object.prototype).not.toHaveProperty('polluted')
|
||||
})
|
||||
|
||||
it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])(
|
||||
'rejects malformed input %j',
|
||||
async (content) => {
|
||||
await expect(read(content)).rejects.toBeInstanceOf(SyntaxError)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['null', '[]', '123', '"text"'])(
|
||||
'does not publish a nonobject document %s',
|
||||
async (content) => {
|
||||
expect(await read(content)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('validates discarded subtrees and closes their source on malformed input', async () => {
|
||||
let closed = false
|
||||
async function* malformed() {
|
||||
try {
|
||||
yield Buffer.from('{"ignored":[{"deep":1},]}')
|
||||
throw new Error('must not request another chunk')
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves source failure and closes the source even after a consumer failure', async () => {
|
||||
const failure = new Error('disk failure')
|
||||
let closed = false
|
||||
async function* failing() {
|
||||
try {
|
||||
yield Buffer.from('{"messages":[1]')
|
||||
throw failure
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
read('', {
|
||||
bytes: failing(),
|
||||
consume: () => {
|
||||
throw new Error('consumer')
|
||||
}
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves escaped astral text across large and byte-sized chunks', async () => {
|
||||
const value = `${'x'.repeat(65530)}日本語😀\\literal`
|
||||
const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] })
|
||||
for (const size of [1, 65536, content.length * 4]) {
|
||||
expect(await read(content, { bytes: bytes(content, size) })).toEqual({
|
||||
record: {},
|
||||
state: [value, '\ud800', '\udc00']
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json'
|
||||
import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { createJsonTokenReader } from '../../shared/json-token-reader'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
/** Fold one root array while retaining only the root fields the agent parser uses. */
|
||||
@@ -13,92 +14,108 @@ export async function readStreamedSessionDocument<T>(args: {
|
||||
consume: (state: T, value: unknown) => void
|
||||
signal?: AbortSignal
|
||||
}): Promise<{ record: Record<string, unknown>; state: T } | null> {
|
||||
const parser = new JSONParser({
|
||||
paths: [
|
||||
...args.fields.map((field) => `$.${field}`),
|
||||
...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) =>
|
||||
fields.map((field) => `$.${root}.${field}`)
|
||||
),
|
||||
...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : [])
|
||||
],
|
||||
keepStack: false,
|
||||
stringBufferSize: 64 * 1024
|
||||
})
|
||||
const record: Record<string, unknown> = Object.create(null)
|
||||
const fields = new Set(args.fields)
|
||||
let depth = 0
|
||||
let expectingRootKey = false
|
||||
parser.onToken = ({ token, value }) => {
|
||||
if (depth === 1 && expectingRootKey && token === TokenType.STRING) {
|
||||
if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) {
|
||||
record[value] = Object.create(null)
|
||||
}
|
||||
expectingRootKey = false
|
||||
}
|
||||
if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
|
||||
if (depth === 0 && token === TokenType.LEFT_BRACE) {
|
||||
expectingRootKey = true
|
||||
}
|
||||
depth++
|
||||
} else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
|
||||
depth--
|
||||
} else if (token === TokenType.COMMA && depth === 1) {
|
||||
expectingRootKey = true
|
||||
}
|
||||
}
|
||||
const objectFields = new Map(
|
||||
Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)])
|
||||
)
|
||||
const foldedArray = Symbol('folded session array')
|
||||
let state = args.create()
|
||||
let currentArray: unknown = null
|
||||
let consumeFailure: { error: unknown } | undefined
|
||||
let inFoldedArray = false
|
||||
const reset = (): void => {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
}
|
||||
const retain = (path: (string | number)[]): boolean => {
|
||||
const [root, child] = path
|
||||
return (
|
||||
typeof root === 'string' &&
|
||||
((root !== args.arrayKey && fields.has(root)) ||
|
||||
(inFoldedArray && root === args.arrayKey && path.length >= 2) ||
|
||||
(typeof child === 'string' && objectFields.get(root)?.has(child) === true))
|
||||
)
|
||||
}
|
||||
// Every discarded container needs a rule; dropping only its parent still builds large children.
|
||||
const discard = {
|
||||
filter: (path: (string | number)[]) => !retain(path),
|
||||
create: () => null,
|
||||
add: () => {}
|
||||
}
|
||||
const assembler = new FlexAssembler({
|
||||
maxDepth: Infinity,
|
||||
objectRules: [
|
||||
objectRule<Record<string, unknown>>({
|
||||
filter: (path) => path.length === 0,
|
||||
create: () => record,
|
||||
add: (target, key, value) => {
|
||||
if (key === args.arrayKey) {
|
||||
if (value !== foldedArray) {
|
||||
reset()
|
||||
}
|
||||
} else if (fields.has(key)) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}),
|
||||
objectRule<Record<string, unknown>>({
|
||||
filter: (path) =>
|
||||
path.length === 1 &&
|
||||
typeof path[0] === 'string' &&
|
||||
objectFields.has(path[0]) &&
|
||||
(!fields.has(path[0]) || path[0] === args.arrayKey),
|
||||
create: (path) => {
|
||||
const projected: Record<string, unknown> = Object.create(null)
|
||||
record[String(path[0])] = projected
|
||||
return projected
|
||||
},
|
||||
add: (target, key, value) => {
|
||||
const root = assembler.path[0]
|
||||
if (typeof root === 'string' && objectFields.get(root)?.has(key)) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}),
|
||||
discard
|
||||
],
|
||||
arrayRules: [
|
||||
arrayRule<null>({
|
||||
filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey,
|
||||
create: () => {
|
||||
reset()
|
||||
inFoldedArray = true
|
||||
return null
|
||||
},
|
||||
add: (_target, value) => {
|
||||
if (!consumeFailure) {
|
||||
try {
|
||||
args.consume(state, value)
|
||||
} catch (error) {
|
||||
consumeFailure = { error }
|
||||
}
|
||||
}
|
||||
},
|
||||
finalize: () => {
|
||||
inFoldedArray = false
|
||||
return foldedArray
|
||||
}
|
||||
}),
|
||||
discard
|
||||
]
|
||||
})
|
||||
const parser = createJsonTokenReader((token) => {
|
||||
if (
|
||||
token.name === 'keyValue' &&
|
||||
assembler.depth === 1 &&
|
||||
!assembler.isArray &&
|
||||
objectFields.has(token.value)
|
||||
) {
|
||||
record[token.value] = Object.create(null)
|
||||
}
|
||||
assembler.consume(token)
|
||||
})
|
||||
const decoder = new StringDecoder('utf8')
|
||||
let objectRoot: boolean | undefined
|
||||
parser.onValue = ({ key, value, parent, stack }) => {
|
||||
if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) {
|
||||
if (parent !== currentArray) {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
currentArray = parent
|
||||
}
|
||||
if (!consumeFailure) {
|
||||
try {
|
||||
args.consume(state, value)
|
||||
} catch (error) {
|
||||
consumeFailure = { error }
|
||||
}
|
||||
}
|
||||
// The parser's array cursor is independent of retained array slots.
|
||||
parent.pop()
|
||||
} else if (
|
||||
stack.length === 2 &&
|
||||
typeof stack[1].key === 'string' &&
|
||||
typeof key === 'string' &&
|
||||
parent &&
|
||||
!Array.isArray(parent)
|
||||
) {
|
||||
const root = stack[1].key
|
||||
const projected = record[root]
|
||||
if (
|
||||
Object.hasOwn(args.objectFields ?? {}, root) &&
|
||||
args.objectFields?.[root]?.includes(key) &&
|
||||
projected &&
|
||||
typeof projected === 'object'
|
||||
) {
|
||||
Reflect.set(projected, key, value)
|
||||
}
|
||||
} else if (stack.length === 1 && typeof key === 'string') {
|
||||
if (key === args.arrayKey) {
|
||||
if (value !== currentArray || !Array.isArray(value)) {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
}
|
||||
currentArray = null
|
||||
} else if (fields.has(key)) {
|
||||
record[key] = value
|
||||
}
|
||||
if (parent && typeof parent === 'object') {
|
||||
Reflect.deleteProperty(parent, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for await (const chunk of args.bytes) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (objectRoot === undefined) {
|
||||
@@ -107,37 +124,17 @@ export async function readStreamedSessionDocument<T>(args: {
|
||||
objectRoot = first === 123
|
||||
}
|
||||
}
|
||||
parseJson(() => parser.write(decoder.write(chunk)))
|
||||
parser.write(decoder.write(chunk))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
const tail = decoder.end()
|
||||
if (tail) {
|
||||
parseJson(() => parser.write(tail))
|
||||
}
|
||||
if (objectRoot === undefined) {
|
||||
throw new SyntaxError('Unexpected end of JSON input')
|
||||
}
|
||||
if (!parser.isEnded) {
|
||||
parseJson(() => parser.end(), true)
|
||||
parser.write(tail)
|
||||
}
|
||||
parser.end()
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (consumeFailure) {
|
||||
throw consumeFailure.error
|
||||
}
|
||||
return objectRoot ? { record, state } : null
|
||||
}
|
||||
|
||||
function parseJson(run: () => void, ending = false): void {
|
||||
try {
|
||||
run()
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof TokenizerError ||
|
||||
error instanceof TokenParserError ||
|
||||
(ending && error instanceof Error)
|
||||
) {
|
||||
throw new SyntaxError(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as workerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
|
||||
import type { SessionFileDiscovery } from './session-scanner-types'
|
||||
import type { TranscriptReadOutcome } from './session-transcript-consumers'
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
registerTranscriptConsumer,
|
||||
resetTranscriptConsumersForTests
|
||||
} from './session-transcript-consumers'
|
||||
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
const file = {
|
||||
path: '/fixture/opencode.db#session',
|
||||
@@ -40,6 +44,7 @@ beforeEach(() => {
|
||||
resetSessionParseCacheForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
})
|
||||
@@ -48,7 +53,11 @@ function configure(agent: 'opencode' | 'opencode2') {
|
||||
readers.discover.mockResolvedValue([{ agent, rootDir: '/fixture', files: [file] }])
|
||||
const accumulator = createAccumulator({ agent, file, sessionId: 'session' })
|
||||
accumulator.title = 'SQLite session'
|
||||
return finalizeSession(accumulator, 'linux')
|
||||
const session = finalizeSession(accumulator, 'linux')
|
||||
if (!session) {
|
||||
throw new Error('Configured SQLite session was empty')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function untilAborted(signal: AbortSignal | undefined): Promise<never> {
|
||||
@@ -61,6 +70,83 @@ function untilAborted(signal: AbortSignal | undefined): Promise<never> {
|
||||
}
|
||||
|
||||
describe.each(['opencode', 'opencode2'] as const)('%s scan cancellation', (agent) => {
|
||||
it('reports a deadline while retaining completed sessions and other agents, then retries', async () => {
|
||||
vi.useFakeTimers()
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-scan-deadline-'))
|
||||
try {
|
||||
const session = configure(agent)
|
||||
const blocked = { ...file, path: '/fixture/opencode.db#blocked' }
|
||||
const claudePath = join(root, 'claude.jsonl')
|
||||
writeFileSync(
|
||||
claudePath,
|
||||
`${JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'retained-claude',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: root,
|
||||
message: { role: 'user', content: 'Retain this other-agent session' }
|
||||
})}\n`
|
||||
)
|
||||
readers.discover.mockResolvedValue([
|
||||
{ agent, rootDir: '/fixture', files: [file, blocked] },
|
||||
{ agent: 'claude', rootDir: root, files: [{ ...file, path: claudePath }] }
|
||||
])
|
||||
readers.parse.mockImplementation(({ sessionId, signal }) =>
|
||||
sessionId === 'blocked'
|
||||
? runOpenCodeSqliteScanRequest(signal, untilAborted)
|
||||
: Promise.resolve(session)
|
||||
)
|
||||
const pending = scanAiVaultSessions({ platform: 'linux' })
|
||||
await vi.waitFor(() => expect(readers.parse).toHaveBeenCalledTimes(2))
|
||||
await vi.advanceTimersByTimeAsync(45_000)
|
||||
const result = await pending
|
||||
expect(result.sessions.map((row) => row.sessionId)).toEqual(
|
||||
expect.arrayContaining(['session', 'retained-claude'])
|
||||
)
|
||||
expect(result.sessions).toHaveLength(2)
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
agent,
|
||||
path: blocked.path,
|
||||
message: expect.stringContaining('45s work budget')
|
||||
})
|
||||
])
|
||||
readers.parse.mockResolvedValue({ ...session, sessionId: 'blocked', filePath: blocked.path })
|
||||
const recovered = await scanAiVaultSessions({ platform: 'linux' })
|
||||
expect(recovered.sessions).toHaveLength(3)
|
||||
expect(
|
||||
readers.parse.mock.calls.filter(([args]) => args.sessionId === 'blocked')
|
||||
).toHaveLength(2)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a deadline capture incomplete instead of caching a failed history', async () => {
|
||||
vi.useFakeTimers()
|
||||
const session = configure(agent)
|
||||
const outcomes: TranscriptReadOutcome[] = []
|
||||
registerTranscriptConsumer({
|
||||
beginRead: () => ({ message() {}, finish: (outcome) => outcomes.push(outcome) })
|
||||
})
|
||||
readers.capture.mockImplementationOnce(({ signal }) =>
|
||||
runOpenCodeSqliteScanRequest(signal, untilAborted)
|
||||
)
|
||||
const pending = scanAiVaultSessions({ platform: 'linux' })
|
||||
await vi.waitFor(() => expect(readers.capture).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(45_000)
|
||||
const result = await pending
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({ message: expect.stringContaining('45s work budget') })
|
||||
])
|
||||
expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }])
|
||||
readers.capture.mockResolvedValue({ session, messages })
|
||||
expect((await scanAiVaultSessions({ platform: 'linux' })).sessions).toHaveLength(1)
|
||||
expect(readers.capture).toHaveBeenCalledTimes(2)
|
||||
expect(outcomes.at(-1)?.incomplete).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['parse', 'capture'] as const)(
|
||||
'cancels an active %s and retries the uncached read',
|
||||
async (mode) => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
OPENCODE_SQLITE_SCAN_BUDGET_MS,
|
||||
runOpenCodeSqliteScanRequest,
|
||||
withOpenCodeSqliteScanScope
|
||||
} from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
function waitForAbort(signal: AbortSignal | undefined): Promise<never> {
|
||||
if (!signal) {
|
||||
throw new Error('Missing scoped request signal')
|
||||
}
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
it('spends the budget while admission is pending and refuses later work in that scan', async () => {
|
||||
vi.useFakeTimers()
|
||||
const admitted = vi.fn()
|
||||
const outcome = withOpenCodeSqliteScanScope(async () => {
|
||||
const error = await runOpenCodeSqliteScanRequest(undefined, waitForAbort).catch((err) => err)
|
||||
expect(error).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
|
||||
await expect(runOpenCodeSqliteScanRequest(undefined, admitted)).rejects.toBe(error)
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS)
|
||||
await outcome
|
||||
expect(admitted).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('banks only outstanding work across legs and does not spend other-agent time', async () => {
|
||||
vi.useFakeTimers()
|
||||
const outcome = withOpenCodeSqliteScanScope(async () => {
|
||||
await runOpenCodeSqliteScanRequest(undefined, () => wait(20_000))
|
||||
await wait(70_000)
|
||||
return runOpenCodeSqliteScanRequest(undefined, waitForAbort)
|
||||
}).catch((error) => error)
|
||||
await vi.advanceTimersByTimeAsync(90_000)
|
||||
let completed = false
|
||||
void outcome.then(() => {
|
||||
completed = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(24_999)
|
||||
expect(completed).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('counts overlapping preparation and worker waits once', async () => {
|
||||
vi.useFakeTimers()
|
||||
const outcome = withOpenCodeSqliteScanScope(() =>
|
||||
runOpenCodeSqliteScanRequest(undefined, () =>
|
||||
Promise.all([
|
||||
runOpenCodeSqliteScanRequest(undefined, waitForAbort),
|
||||
runOpenCodeSqliteScanRequest(undefined, waitForAbort)
|
||||
])
|
||||
)
|
||||
).catch((error) => error)
|
||||
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS - 1)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps concurrent scans independent and gives the next scan a fresh owner and budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
const owners: unknown[] = []
|
||||
const first = withOpenCodeSqliteScanScope(() =>
|
||||
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
|
||||
owners.push(owner)
|
||||
return waitForAbort(signal)
|
||||
})
|
||||
).catch((error) => error)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
const second = withOpenCodeSqliteScanScope(() =>
|
||||
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
|
||||
owners.push(owner)
|
||||
return waitForAbort(signal)
|
||||
})
|
||||
).catch((error) => error)
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(await first).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(await second).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
|
||||
await withOpenCodeSqliteScanScope(() =>
|
||||
runOpenCodeSqliteScanRequest(undefined, async (_signal, owner) => {
|
||||
owners.push(owner)
|
||||
})
|
||||
)
|
||||
expect(new Set(owners).size).toBe(3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves the caller cancellation reason and disposes its timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller cancelled')
|
||||
const outcome = withOpenCodeSqliteScanScope(() =>
|
||||
runOpenCodeSqliteScanRequest(controller.signal, waitForAbort)
|
||||
).catch((error) => error)
|
||||
controller.abort(reason)
|
||||
expect(await outcome).toBe(reason)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves unrelated native-chat and Zcode calls unscoped and retires the scan signal', async () => {
|
||||
vi.useFakeTimers()
|
||||
let scopedSignal: AbortSignal | undefined
|
||||
const caller = new AbortController()
|
||||
await withOpenCodeSqliteScanScope(async () => {
|
||||
for (const agent of ['zcode', 'native-chat'] as const) {
|
||||
await runOpenCodeSqliteScanRequest(
|
||||
caller.signal,
|
||||
async (signal, owner) => {
|
||||
expect(signal).toBe(caller.signal)
|
||||
expect(owner).toBeUndefined()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
},
|
||||
agent
|
||||
)
|
||||
}
|
||||
await runOpenCodeSqliteScanRequest(undefined, async (signal) => {
|
||||
scopedSignal = signal
|
||||
})
|
||||
})
|
||||
expect(scopedSignal?.aborted).toBe(true)
|
||||
expect(caller.signal.aborted).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
|
||||
import type { WorkerThreadRequestOwner } from '../worker-thread-request-queue'
|
||||
|
||||
export const OPENCODE_SQLITE_SCAN_BUDGET_MS = 45_000
|
||||
|
||||
class OpenCodeSqliteScanScope implements WorkerThreadRequestOwner {
|
||||
private readonly controller = new AbortController()
|
||||
readonly signal = this.controller.signal
|
||||
private remainingMs = OPENCODE_SQLITE_SCAN_BUDGET_MS
|
||||
private outstanding = 0
|
||||
private armedAt = 0
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
|
||||
async run<T>(
|
||||
callerSignal: AbortSignal | undefined,
|
||||
fn: (signal: AbortSignal, owner: WorkerThreadRequestOwner) => Promise<T>
|
||||
): Promise<T> {
|
||||
throwIfSignalAborted(callerSignal)
|
||||
throwIfSignalAborted(this.signal)
|
||||
if (this.outstanding++ === 0) {
|
||||
this.armedAt = Date.now()
|
||||
this.timer = setTimeout(() => {
|
||||
const error = new Error(
|
||||
`OpenCode SQLite scan exceeded its ${OPENCODE_SQLITE_SCAN_BUDGET_MS / 1000}s work budget`
|
||||
)
|
||||
error.name = 'OpenCodeSqliteScanDeadlineError'
|
||||
this.controller.abort(error)
|
||||
}, this.remainingMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
const signal = callerSignal ? AbortSignal.any([callerSignal, this.signal]) : this.signal
|
||||
try {
|
||||
return await fn(signal, this)
|
||||
} finally {
|
||||
if (--this.outstanding === 0) {
|
||||
this.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pause()
|
||||
this.controller.abort(new Error('OpenCode SQLite scan ended'))
|
||||
}
|
||||
|
||||
private pause(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.remainingMs = Math.max(0, this.remainingMs - (Date.now() - this.armedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanScope = new AsyncLocalStorage<OpenCodeSqliteScanScope>()
|
||||
|
||||
export async function withOpenCodeSqliteScanScope<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const scope = new OpenCodeSqliteScanScope()
|
||||
try {
|
||||
return await scanScope.run(scope, fn)
|
||||
} finally {
|
||||
scope.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// The clock covers outstanding SQLite work, including admission and WSL preparation.
|
||||
export function runOpenCodeSqliteScanRequest<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
fn: (signal?: AbortSignal, owner?: WorkerThreadRequestOwner) => Promise<T>,
|
||||
agent?: 'opencode2' | 'zcode' | 'native-chat'
|
||||
): Promise<T> {
|
||||
const scope = agent === 'zcode' || agent === 'native-chat' ? undefined : scanScope.getStore()
|
||||
return scope ? scope.run(signal, fn) : fn(signal)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
OpenCodeSqliteWorkerResponse
|
||||
} from './session-scanner-opencode-sqlite-worker-protocol'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
// A worker_threads stand-in the tests drive directly: it records posted requests
|
||||
// and lets a test emit message/error/exit without a built worker bundle.
|
||||
@@ -75,6 +76,63 @@ function makeFactory(workers: FakeWorker[]): () => Worker {
|
||||
}
|
||||
|
||||
describe('OpenCodeSqliteWorkerClient', () => {
|
||||
it('expires a scan in the FIFO without cancelling ordinary reads or a later scan', async () => {
|
||||
vi.useFakeTimers()
|
||||
const workers: FakeWorker[] = []
|
||||
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
try {
|
||||
const ordinary = [1, 2].map((id) =>
|
||||
client.list({
|
||||
dbPaths: [`/ordinary-${id}.db`],
|
||||
limit: 1,
|
||||
issues: []
|
||||
})
|
||||
)
|
||||
const scan = withOpenCodeSqliteScanScope(() =>
|
||||
client.list({
|
||||
dbPaths: ['/scan.db'],
|
||||
limit: 1,
|
||||
issues
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
workers[0].emit('message', {
|
||||
id: workers[0].lastId(),
|
||||
ok: true,
|
||||
value: { candidates: [], issues: [] }
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await expect(scan).resolves.toEqual([])
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'scope',
|
||||
message: expect.stringContaining('45s work budget')
|
||||
})
|
||||
])
|
||||
expect(workers[0].postedRequests).toHaveLength(2)
|
||||
expect(workers[0].terminated).toBe(false)
|
||||
workers[0].emit('message', {
|
||||
id: workers[0].lastId(),
|
||||
ok: true,
|
||||
value: { candidates: [], issues: [] }
|
||||
})
|
||||
await expect(Promise.all(ordinary)).resolves.toEqual([[], []])
|
||||
const next = withOpenCodeSqliteScanScope(() =>
|
||||
client.list({ dbPaths: ['/next.db'], limit: 1, issues: [] })
|
||||
)
|
||||
workers[0].emit('message', {
|
||||
id: workers[0].lastId(),
|
||||
ok: true,
|
||||
value: { candidates: [], issues: [] }
|
||||
})
|
||||
await expect(next).resolves.toEqual([])
|
||||
} finally {
|
||||
client.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('correlates responses by id and ignores stale ids', async () => {
|
||||
const workers: FakeWorker[] = []
|
||||
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
|
||||
@@ -188,7 +246,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
|
||||
client.list({ dbPaths: ['/tmp/opencode.db'], limit: 10, issues: listIssues })
|
||||
).resolves.toEqual([])
|
||||
expect(
|
||||
listIssues.some((issue) => /background scanner could not start/.test(issue.message))
|
||||
listIssues.some((issue) => issue.message.includes('background scanner could not start'))
|
||||
).toBe(true)
|
||||
await expect(
|
||||
client.parse({ dbPath: '/tmp/opencode.db', sessionId: 'ses_skipped', platform: 'darwin' })
|
||||
@@ -264,7 +322,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
|
||||
const first = await client.list({ dbPaths: ['/db'], limit: 10, issues: firstIssues })
|
||||
expect(first).toEqual([])
|
||||
expect(
|
||||
firstIssues.some((issue) => /background scanner could not start/.test(issue.message))
|
||||
firstIssues.some((issue) => issue.message.includes('background scanner could not start'))
|
||||
).toBe(true)
|
||||
await expect(
|
||||
client.parse({ dbPath: '/db', sessionId: 'ses_heal', platform: 'darwin' })
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
// Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off
|
||||
// the main-process event loop. This module owns only the OpenCode legs; the
|
||||
@@ -117,7 +118,8 @@ export class OpenCodeSqliteWorkerClient {
|
||||
...(args.agent ? { agent: args.agent } : {})
|
||||
}),
|
||||
LIST_TIMEOUT_MS,
|
||||
args.signal
|
||||
args.signal,
|
||||
args.agent
|
||||
)) as OpenCodeSqliteListValue
|
||||
args.issues.push(...value.issues)
|
||||
return value.candidates
|
||||
@@ -178,7 +180,8 @@ export class OpenCodeSqliteWorkerClient {
|
||||
...(args.agent ? { agent: args.agent } : {})
|
||||
}),
|
||||
PARSE_TIMEOUT_MS,
|
||||
args.signal
|
||||
args.signal,
|
||||
args.agent
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
|
||||
return value as AiVaultSession | null
|
||||
@@ -217,7 +220,8 @@ export class OpenCodeSqliteWorkerClient {
|
||||
...(args.agent ? { agent: args.agent } : {})
|
||||
}),
|
||||
CAPTURE_TIMEOUT_MS,
|
||||
args.signal
|
||||
args.signal,
|
||||
args.agent
|
||||
)
|
||||
return parseOpenCodeSqliteCaptureValue(value)
|
||||
} catch (err) {
|
||||
@@ -229,7 +233,12 @@ export class OpenCodeSqliteWorkerClient {
|
||||
args: Omit<OpenCodeNativeChatReadRequest, 'id'>,
|
||||
signal?: AbortSignal
|
||||
): Promise<OpenCodeNativeChatReadValue> {
|
||||
const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal)
|
||||
const value = await this.dispatch(
|
||||
(id) => ({ ...args, id }),
|
||||
PARSE_TIMEOUT_MS,
|
||||
signal,
|
||||
'native-chat'
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames.
|
||||
return value as OpenCodeNativeChatReadValue
|
||||
}
|
||||
@@ -241,13 +250,20 @@ export class OpenCodeSqliteWorkerClient {
|
||||
private async dispatch(
|
||||
buildRequest: (id: number) => OpenCodeSqliteWorkerRequest,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
agent?: 'opencode2' | 'zcode' | 'native-chat'
|
||||
): Promise<unknown> {
|
||||
const deadline = this.requestTimeoutMs ?? timeoutMs
|
||||
const response = await this.requests.dispatch(
|
||||
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
|
||||
deadline,
|
||||
signal
|
||||
const response = await runOpenCodeSqliteScanRequest(
|
||||
signal,
|
||||
(requestSignal, owner) =>
|
||||
this.requests.dispatch(
|
||||
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
|
||||
deadline,
|
||||
requestSignal,
|
||||
owner
|
||||
),
|
||||
agent
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
openCodeWslPath
|
||||
} from './session-scanner-opencode-wsl-client'
|
||||
import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path'
|
||||
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
// Why: resolve the built worker entry + own the process-wide shared client so
|
||||
// the client class stays free of Electron (require'd lazily here) and the
|
||||
@@ -179,8 +180,14 @@ async function listForHost(
|
||||
const first = paths.values().next().value!
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
try {
|
||||
const client = await openCodeWslClient(distro, first, args.signal)
|
||||
const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues })
|
||||
const result = await runOpenCodeSqliteScanRequest(
|
||||
args.signal,
|
||||
async (signal) => {
|
||||
const client = await openCodeWslClient(distro, first, signal)
|
||||
return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues })
|
||||
},
|
||||
args.agent
|
||||
)
|
||||
return result.flatMap((candidate) => {
|
||||
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent)
|
||||
const original = parsed && paths.get(parsed.dbPath)
|
||||
@@ -223,8 +230,14 @@ async function parseForHost(
|
||||
if (!wsl) {
|
||||
return getSharedClient().parse(args)
|
||||
}
|
||||
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
|
||||
const session = await client.parse({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
|
||||
const session = await runOpenCodeSqliteScanRequest(
|
||||
args.signal,
|
||||
async (signal) => {
|
||||
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
|
||||
return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
|
||||
},
|
||||
args.agent
|
||||
)
|
||||
return mapOpenCodeWslSession(session, args.dbPath)
|
||||
}
|
||||
|
||||
@@ -235,8 +248,14 @@ async function captureForHost(
|
||||
if (!wsl) {
|
||||
return getSharedClient().capture(args)
|
||||
}
|
||||
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
|
||||
const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
|
||||
const capture = await runOpenCodeSqliteScanRequest(
|
||||
args.signal,
|
||||
async (signal) => {
|
||||
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
|
||||
return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
|
||||
},
|
||||
args.agent
|
||||
)
|
||||
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
import type * as wslClientModule from './session-scanner-opencode-wsl-client'
|
||||
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
native: {
|
||||
@@ -59,9 +60,53 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('OpenCode SQLite execution-host routes', () => {
|
||||
it('budgets WSL preparation while retaining a native source that already answered', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.native.list.mockResolvedValueOnce([row(native)])
|
||||
mocks.guest.mockImplementationOnce((_distro, _path, signal: AbortSignal | undefined) => {
|
||||
if (!signal) {
|
||||
throw new Error('Missing scoped preparation signal')
|
||||
}
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
)
|
||||
})
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const result = withOpenCodeSqliteScanScope(() =>
|
||||
listOpenCodeSqliteSessionsViaWorker({
|
||||
dbPaths: [native, ubuntu],
|
||||
limit: 2,
|
||||
issues
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(45_000)
|
||||
expect((await result).map((entry) => entry.file.path)).toEqual([`${native}#same-session`])
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ubuntu,
|
||||
kind: 'scope',
|
||||
message: expect.stringContaining('45s work budget')
|
||||
})
|
||||
])
|
||||
mocks.guest.mockResolvedValueOnce({ list: vi.fn(async () => [row(guest)]) })
|
||||
const recoveredIssues: AiVaultScanIssue[] = []
|
||||
const recovered = await withOpenCodeSqliteScanScope(() =>
|
||||
listOpenCodeSqliteSessionsViaWorker({
|
||||
dbPaths: [ubuntu],
|
||||
limit: 2,
|
||||
issues: recoveredIssues
|
||||
})
|
||||
)
|
||||
expect(recovered).toHaveLength(1)
|
||||
expect(recoveredIssues).toEqual([])
|
||||
})
|
||||
|
||||
it('separates native and distro databases and preserves equal IDs in different distros', async () => {
|
||||
const list = vi.fn(async (args) => [row(args.dbPaths[0])])
|
||||
mocks.guest.mockResolvedValue({ list })
|
||||
|
||||
@@ -45,6 +45,7 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
|
||||
import { withDevinSessionsDbScan } from './session-scanner-devin-db'
|
||||
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
|
||||
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
|
||||
@@ -61,6 +62,10 @@ const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
|
||||
export async function scanAiVaultSessions(
|
||||
options: AiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
return withOpenCodeSqliteScanScope(() => scanAiVaultSessionStores(options))
|
||||
}
|
||||
|
||||
async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise<AiVaultListResult> {
|
||||
// The span makes scan cost visible in the local trace file: STA-1278-style
|
||||
// "one core pegged" reports need to show whether transcript scanning is the
|
||||
// subsystem burning CPU, and how much of each scan the cache absorbed.
|
||||
|
||||
@@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => {
|
||||
const service = new AntigravityAccountService(store, h.backend)
|
||||
h.setNative(paddedCredential('new-account', 60000))
|
||||
await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
expect(store.read().accounts).toHaveLength(52)
|
||||
h.setNative(paddedCredential('large-51', 65000))
|
||||
await expect(service.listAccounts()).rejects.toThrow('could not be saved')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
expect(store.read().accounts).toHaveLength(52)
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => {
|
||||
'Protected secret storage'
|
||||
)
|
||||
expect(() => store.read()).toThrow('Protected secret storage')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR)
|
||||
expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({
|
||||
userDataPath: USER_DATA
|
||||
})
|
||||
@@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
|
||||
it('leaves packaged Linux PATH untouched when no shim could be written', () => {
|
||||
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
|
||||
const env: Record<string, string> = { PATH: '/usr/bin' }
|
||||
const env: Record<string, string> = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' }
|
||||
prependOrcaCliDirToChildPath(env, {
|
||||
isPackaged: true,
|
||||
userDataPath: USER_DATA,
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leads packaged macOS PATH with the bundled CLI dir', () => {
|
||||
@@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin'))
|
||||
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => {
|
||||
const env: Record<string, string> = { Path: 'C:\\Windows\\System32' }
|
||||
const env: Record<string, string> = {
|
||||
Path: 'C:\\Windows\\System32',
|
||||
ORCA_CLI_BIN_DIR: '/parent-host/cli',
|
||||
ORCA_WSL_CLI_DIR: '/guest/orca/bin'
|
||||
}
|
||||
prependOrcaCliDirToChildPath(env, {
|
||||
isPackaged: true,
|
||||
userDataPath: USER_DATA,
|
||||
@@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
})
|
||||
expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`)
|
||||
expect(env.PATH).toBeUndefined()
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
|
||||
expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin')
|
||||
})
|
||||
|
||||
it('leaves a packaged darwin/win32 PATH alone with no resources root', () => {
|
||||
@@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
platform
|
||||
})
|
||||
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe(
|
||||
platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin')
|
||||
)
|
||||
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath(
|
||||
opts: OrcaCliChildPathOptions
|
||||
): string | null {
|
||||
const platform = opts.platform ?? process.platform
|
||||
delete env.ORCA_CLI_BIN_DIR
|
||||
// Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
|
||||
// drives a foreign platform through the seam.
|
||||
const pathDelimiter = platform === 'win32' ? ';' : delimiter
|
||||
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
|
||||
if (!opts.isPackaged) {
|
||||
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
|
||||
if (platform !== 'win32') {
|
||||
env.ORCA_CLI_BIN_DIR = devCliBin
|
||||
}
|
||||
const inheritedPath = readInheritedPath(env, platform)
|
||||
// Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
|
||||
env[resolvePathEnvKey(env, platform)] = inheritedPath
|
||||
@@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath(
|
||||
// Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
|
||||
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
|
||||
if (shimDir) {
|
||||
env.ORCA_CLI_BIN_DIR = shimDir
|
||||
const inheritedEntries = readInheritedPath(env, platform)
|
||||
.split(pathDelimiter)
|
||||
.filter((entry) => entry.length > 0 && entry !== shimDir)
|
||||
@@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath(
|
||||
} else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) {
|
||||
// Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
|
||||
const bundledCliBin = join(opts.resourcesPath, 'bin')
|
||||
if (platform === 'darwin') {
|
||||
env.ORCA_CLI_BIN_DIR = bundledCliBin
|
||||
}
|
||||
const inheritedPath = readInheritedPath(env, platform)
|
||||
env[resolvePathEnvKey(env, platform)] = inheritedPath
|
||||
? `${bundledCliBin}${pathDelimiter}${inheritedPath}`
|
||||
|
||||
@@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => {
|
||||
expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth)
|
||||
})
|
||||
|
||||
it('keeps an Orca-added MCP server while settings and trust still refresh', () => {
|
||||
const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json')
|
||||
const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] })
|
||||
writeFileSync(baselinePath, baseline)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
writeFileSync(
|
||||
join(sharedRuntimeHome, 'config.toml'),
|
||||
[
|
||||
'model = "stale"',
|
||||
'',
|
||||
'[projects."/trusted-in-orca"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[projects."/revoked"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[hooks.state."orca:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'',
|
||||
'[mcp_servers.demo-mcp]',
|
||||
'command = "demo"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
|
||||
|
||||
const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')
|
||||
expect(sharedConfig).toContain('model = "canonical"')
|
||||
expect(sharedConfig).not.toContain('model = "stale"')
|
||||
expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"')
|
||||
expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"')
|
||||
expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"')
|
||||
expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]')
|
||||
expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"')
|
||||
expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline)
|
||||
})
|
||||
|
||||
it('does not delete config when the canonical source is transiently missing', () => {
|
||||
const staleConfig = 'model_provider = "stale-provider"\n'
|
||||
writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8')
|
||||
|
||||
@@ -28,7 +28,9 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture'
|
||||
/** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */
|
||||
async function newestRunDelegation(frames: Frame[]): Promise<NativeChatSubagentDelegation | null> {
|
||||
const { conversation } = projectNativeChatTranscript(
|
||||
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [])
|
||||
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], {
|
||||
rejectedInPlace: true
|
||||
})
|
||||
)
|
||||
const runs = conversation.filter((message: NativeChatMessage) =>
|
||||
message.blocks.some((block) => block.type === 'tool-call')
|
||||
|
||||
@@ -47,7 +47,9 @@ function positionalRuns(rows: AgentJournalRenderItem[]): {
|
||||
})
|
||||
})
|
||||
const transcript = projectNativeChatTranscript(
|
||||
projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds)
|
||||
projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map(
|
||||
withoutCallIds
|
||||
)
|
||||
)
|
||||
// The conversation's runs, then each helper's section's.
|
||||
const runs = [
|
||||
|
||||
@@ -21,6 +21,20 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const BASELINE_FILE = '.orca-config-settings-baseline.json'
|
||||
|
||||
type StoredBaselineFixture = {
|
||||
version: 1 | 3
|
||||
settings: Record<string, string | null>
|
||||
mcpServers?: string[]
|
||||
}
|
||||
|
||||
function writeBaseline(baseline: StoredBaselineFixture): string {
|
||||
const serialized = JSON.stringify(baseline)
|
||||
writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized)
|
||||
return serialized
|
||||
}
|
||||
|
||||
describe('canonical MCP ownership during config mirroring', () => {
|
||||
it('does not duplicate a server defined inline in the canonical MCP table', () => {
|
||||
writeFileSync(
|
||||
@@ -130,6 +144,60 @@ describe('MCP ownership migration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the retained shared home canonical under a pre-ownership baseline', () => {
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
writeBaseline({ version: 1, settings: {} })
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
[
|
||||
'[mcp_servers.demo-mcp]',
|
||||
'command = "orca-only"',
|
||||
'[mcp_servers.removed]',
|
||||
'command = "mirrored"',
|
||||
'[mcp_servers.kept]',
|
||||
'command = "stale"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemHomePath, 'config.toml'),
|
||||
'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n'
|
||||
)
|
||||
const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] })
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"')
|
||||
expect(runtimeConfig).not.toContain('[mcp_servers.removed]')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"')
|
||||
expect(runtimeConfig).not.toContain('"stale"')
|
||||
expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline)
|
||||
})
|
||||
|
||||
it('leaves the retained shared home untouched when its baseline cannot be read', () => {
|
||||
const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n'
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig)
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
mkdirSync(join(runtimeHomePath, BASELINE_FILE))
|
||||
|
||||
expect(() =>
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
).toThrow()
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig)
|
||||
})
|
||||
|
||||
it('tracks commented CRLF names so their later removal remains authoritative', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type CodexSettingsPromotionHomes,
|
||||
type CodexSettingsPromotionPlan
|
||||
} from './config-settings-promotion'
|
||||
import { readCodexSettingsBaseline } from './config-settings-baseline'
|
||||
import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline'
|
||||
import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall'
|
||||
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
|
||||
import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard'
|
||||
@@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
|
||||
let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? ''
|
||||
if (rawSystemConfig.trim() !== '') {
|
||||
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath)
|
||||
// The retired home has no ownership baseline; its entire MCP root stays canonical.
|
||||
mirroredRuntimeConfig =
|
||||
runtimeConfigBeforeMirror !== null
|
||||
? mergeSystemCodexConfigIntoRuntime(
|
||||
runtimeConfigBeforeMirror,
|
||||
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir),
|
||||
sourceConfigDir,
|
||||
new Set(),
|
||||
true
|
||||
...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath)
|
||||
)
|
||||
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
}
|
||||
@@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
|
||||
writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: MCP servers added from an Orca terminal exist only in the retired home,
|
||||
* so its last mirror's baseline decides which ones ~/.codex owns. With no
|
||||
* baseline the whole root stays canonical, as before; an unreadable one throws
|
||||
* rather than guess. Read-only: this one-way refresh never advances it.
|
||||
*/
|
||||
function readLegacySharedHomeMcpOwnership(
|
||||
runtimeHomePath: string
|
||||
): [mirroredMcpServerNames: ReadonlySet<string>, mirroredMcpServerRoot: boolean] {
|
||||
const observation = observeCodexSettingsBaseline(runtimeHomePath)
|
||||
if (observation.kind === 'indeterminate') {
|
||||
throw new Error('Codex settings baseline could not be read')
|
||||
}
|
||||
return observation.kind === 'present'
|
||||
? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot]
|
||||
: [new Set(), true]
|
||||
}
|
||||
|
||||
type CodexConfigMirrorResult =
|
||||
| { status: 'skipped-missing-source' }
|
||||
| { status: 'refused-indeterminate'; error: unknown }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../runtime/structured-worker-identity'
|
||||
|
||||
const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/
|
||||
const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/
|
||||
// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global.
|
||||
const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
|
||||
|
||||
@@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => {
|
||||
cwd: '/worktree',
|
||||
codexHome: '/pinned/home',
|
||||
resumeThreadId: null,
|
||||
env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
|
||||
env: {
|
||||
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
|
||||
CODEX_HOME: '/shell/home',
|
||||
ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
|
||||
}
|
||||
},
|
||||
'spawn-token',
|
||||
'session-not-a-worker'
|
||||
@@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
|
||||
ORCA_AGENT_SESSION_ID: 'session-not-a-worker',
|
||||
ORCA_STRUCTURED_SESSION: '1',
|
||||
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
|
||||
...(process.platform !== 'win32'
|
||||
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
|
||||
: {}),
|
||||
ORCA_USER_DATA_PATH: expect.any(String),
|
||||
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
|
||||
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
|
||||
@@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
|
||||
ORCA_AGENT_SESSION_ID: sessionId,
|
||||
ORCA_STRUCTURED_SESSION: '1',
|
||||
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
|
||||
...(process.platform !== 'win32'
|
||||
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
|
||||
: {}),
|
||||
ORCA_USER_DATA_PATH: expect.any(String),
|
||||
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
|
||||
})
|
||||
@@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw`
|
||||
result: {
|
||||
sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null,
|
||||
cliCommand: process.env.ORCA_CLI_COMMAND ?? null,
|
||||
cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null,
|
||||
path: process.env.PATH ?? process.env.Path ?? null
|
||||
}
|
||||
})
|
||||
@@ -135,6 +147,7 @@ describe('the spawned Codex child', () => {
|
||||
await expect(connection.request('test/env')).resolves.toEqual({
|
||||
sessionId,
|
||||
cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER),
|
||||
cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR),
|
||||
path: expect.stringMatching(DEV_CLI_BIN_FIRST)
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -206,6 +206,7 @@ function drawnPromptRows(): string[][] {
|
||||
client.items,
|
||||
[],
|
||||
client.submissions,
|
||||
{ rejectedInPlace: true },
|
||||
projectStructuredQuestionMessages
|
||||
)
|
||||
)
|
||||
@@ -243,9 +244,9 @@ describe('a Codex ask with several questions', () => {
|
||||
])
|
||||
// Mobile draws the shared projection in journal order, one row per question.
|
||||
expect(
|
||||
projectStructuredAgentSessionMessages(client.items, [], client.submissions).map(
|
||||
({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)
|
||||
)
|
||||
projectStructuredAgentSessionMessages(client.items, [], client.submissions, {
|
||||
rejectedInPlace: false
|
||||
}).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null))
|
||||
).toEqual(ASKED.map(({ question }) => question))
|
||||
})
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
|
||||
ORCA_AGENT_SESSION_ID: 'session-1',
|
||||
ORCA_STRUCTURED_SESSION: '1',
|
||||
ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/),
|
||||
...(process.platform !== 'win32'
|
||||
? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) }
|
||||
: {}),
|
||||
ORCA_USER_DATA_PATH: expect.any(String),
|
||||
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
|
||||
PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path'
|
||||
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
|
||||
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
|
||||
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
|
||||
@@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
${ORCA_CLI_POSIX_PATH_RESTORE}
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors'
|
||||
import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors'
|
||||
import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip'
|
||||
|
||||
// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor.
|
||||
@@ -78,48 +78,6 @@ function allocatingBufferRows(
|
||||
}
|
||||
|
||||
describe('serialize oracle cell reuse', () => {
|
||||
it.each([false, true])(
|
||||
'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)',
|
||||
(conpty) => {
|
||||
const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
try {
|
||||
const writes = [
|
||||
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
|
||||
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
|
||||
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
|
||||
'scroll1\r\nscroll2\r\nscroll3\r\n',
|
||||
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
|
||||
'\x1b[?1049l\x1b[2J\x1b[Hclear'
|
||||
]
|
||||
for (const data of writes) {
|
||||
writeTerminal(terminal, data)
|
||||
for (const buffer of [
|
||||
terminal.buffer.normal,
|
||||
terminal.buffer.alternate,
|
||||
terminal.buffer.active
|
||||
]) {
|
||||
expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual(
|
||||
allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)
|
||||
)
|
||||
}
|
||||
terminal.resize(terminal.cols === 14 ? 9 : 14, 4)
|
||||
expect(
|
||||
bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols)
|
||||
).toEqual(
|
||||
allocatingBufferRows(
|
||||
terminal.buffer.active,
|
||||
0,
|
||||
terminal.buffer.active.length,
|
||||
terminal.cols
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the order of every text flag combination while reloading a plain cell', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
@@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => {
|
||||
const scratch = terminal.buffer.active.getNullCell()
|
||||
expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED')
|
||||
expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4))
|
||||
expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual(
|
||||
allocatingBufferRows(terminal.buffer.active, 0, 1, 4)
|
||||
)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(terminal, '\x1b[1mA\x1b[0m B界')
|
||||
const buffer = terminal.buffer.active
|
||||
const scratch = buffer.getNullCell()
|
||||
const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
|
||||
const getLine = buffer.getLine.bind(buffer)
|
||||
const loaded: unknown[] = []
|
||||
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
|
||||
const line = getLine(y)
|
||||
if (line) {
|
||||
const getCell = line.getCell.bind(line)
|
||||
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
|
||||
loaded.push(cell)
|
||||
return getCell(x, cell)
|
||||
})
|
||||
}
|
||||
return line
|
||||
})
|
||||
const expected = allocatingBufferRows(buffer, 0, 2, 4)
|
||||
loaded.length = 0
|
||||
const actual = bufferRows(buffer, 0, 2, 4)
|
||||
expect(actual).toEqual(expected)
|
||||
expect(allocate).toHaveBeenCalledTimes(1)
|
||||
expect(loaded).toHaveLength(8)
|
||||
expect(loaded.every((cell) => cell === scratch)).toBe(true)
|
||||
writeTerminal(terminal, '\x1b[2J\x1b[Hnew')
|
||||
bufferRows(buffer, 0, 2, 4)
|
||||
expect(actual).toEqual(expected)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
|
||||
try {
|
||||
@@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) {
|
||||
for (let y = 0; y < Math.max(expected.length, actual.length); y++) {
|
||||
const expectedRow = expected[y]
|
||||
const actualRow = actual[y]
|
||||
if (
|
||||
!expectedRow ||
|
||||
!actualRow ||
|
||||
expectedRow.length !== actualRow.length ||
|
||||
!expectedRow.every(
|
||||
(cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯'))
|
||||
)
|
||||
) {
|
||||
return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function expectComparisonParity(
|
||||
expected: Buffer,
|
||||
actual: Buffer,
|
||||
cols: number,
|
||||
expectedStart = 0,
|
||||
expectedEnd = expected.length,
|
||||
actualStart = 0,
|
||||
actualEnd = actual.length
|
||||
): ReturnType<typeof compareBufferRows> {
|
||||
const frozen = allocatingRowDiff(
|
||||
'parity',
|
||||
allocatingBufferRows(expected, expectedStart, expectedEnd, cols),
|
||||
allocatingBufferRows(actual, actualStart, actualEnd, cols)
|
||||
)
|
||||
expect(
|
||||
compareBufferRows(
|
||||
'parity',
|
||||
expected,
|
||||
expectedStart,
|
||||
expectedEnd,
|
||||
actual,
|
||||
actualStart,
|
||||
actualEnd,
|
||||
cols
|
||||
)
|
||||
).toEqual(frozen)
|
||||
return frozen
|
||||
}
|
||||
|
||||
describe('serialize oracle streaming comparison', () => {
|
||||
it('reuses one cell per buffer and stops before rows after the first difference', () => {
|
||||
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
|
||||
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast')
|
||||
writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast')
|
||||
for (const buffer of [source.buffer.active, replay.buffer.active]) {
|
||||
const scratch = buffer.getNullCell()
|
||||
vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
|
||||
const getLine = buffer.getLine.bind(buffer)
|
||||
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
|
||||
const line = getLine(y)
|
||||
if (line) {
|
||||
const getCell = line.getCell.bind(line)
|
||||
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
|
||||
expect(cell).toBe(scratch)
|
||||
return getCell(x, cell)
|
||||
})
|
||||
}
|
||||
return line
|
||||
})
|
||||
}
|
||||
const diff = compareBufferRows(
|
||||
'visible-grid',
|
||||
source.buffer.active,
|
||||
0,
|
||||
4,
|
||||
replay.buffer.active,
|
||||
0,
|
||||
4,
|
||||
8
|
||||
)
|
||||
expect(diff?.row).toBe(0)
|
||||
for (const buffer of [source.buffer.active, replay.buffer.active]) {
|
||||
expect(buffer.getNullCell).toHaveBeenCalledTimes(1)
|
||||
expect(buffer.getLine).toHaveBeenCalledTimes(2)
|
||||
expect(buffer.getLine).toHaveBeenCalledWith(0)
|
||||
expect(buffer.getLine).toHaveBeenCalledWith(3)
|
||||
}
|
||||
writeTerminal(source, '\x1b[2J\x1b[Hchanged')
|
||||
expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000')
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves first-row diagnostics through buffer changes (ConPTY=%s)',
|
||||
(conpty) => {
|
||||
const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
try {
|
||||
for (const [index, data] of [
|
||||
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
|
||||
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
|
||||
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
|
||||
'scroll1\r\nscroll2\r\nscroll3\r\n',
|
||||
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
|
||||
'\x1b[?1049l\x1b[2J\x1b[Hclear'
|
||||
].entries()) {
|
||||
writeTerminal(source, data)
|
||||
writeTerminal(replay, data)
|
||||
for (const [expected, actual] of [
|
||||
[source.buffer.active, replay.buffer.active],
|
||||
[source.buffer.normal, replay.buffer.normal],
|
||||
[source.buffer.alternate, replay.buffer.alternate]
|
||||
]) {
|
||||
expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull()
|
||||
expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1)
|
||||
}
|
||||
const corruption = `\x1b[H\x1b[0mFAULT${index}`
|
||||
writeTerminal(replay, corruption)
|
||||
expect(
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
|
||||
).not.toBeNull()
|
||||
writeTerminal(source, corruption)
|
||||
source.resize(source.cols === 14 ? 9 : 14, 4)
|
||||
replay.resize(source.cols, 4)
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
|
||||
}
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('detects every text-flag loss and compares all combinations against the allocating oracle', () => {
|
||||
const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
const sgr = [1, 2, 3, 4, 7, 8, 9]
|
||||
const writeFlags = (terminal: Terminal, mask: number): void => {
|
||||
const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0)
|
||||
writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`)
|
||||
}
|
||||
for (let mask = 0; mask < 128; mask++) {
|
||||
writeFlags(source, mask)
|
||||
writeFlags(replay, mask)
|
||||
expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull()
|
||||
for (let bit = 0; bit < sgr.length; bit++) {
|
||||
if ((mask & (1 << bit)) !== 0) {
|
||||
writeFlags(replay, mask & ~(1 << bit))
|
||||
expect(
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, 4)
|
||||
).not.toBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['abc界', 'abc ', 4, false],
|
||||
['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false],
|
||||
['abc ', 'abc界', 4, true],
|
||||
['é', 'è', 8, true],
|
||||
['\x1b[32mA', '\x1b[38;5;2mA', 8, false],
|
||||
['\x1b[42m ', '\x1b[48;5;2m ', 8, false],
|
||||
['\x1b[7;31m ', '\x1b[7;32m ', 8, true],
|
||||
['\x1b[4m\x1b[2J', '\x1b[2J', 8, false],
|
||||
['\x1b[4m ', ' ', 8, true],
|
||||
['\x1b[4m \x1b[0mB', ' B', 8, true],
|
||||
['text\r\n', 'text', 8, false],
|
||||
['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true]
|
||||
] as const)(
|
||||
'preserves blank, clipped and color policy for %j / %j',
|
||||
(expected, actual, cols, differs) => {
|
||||
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
|
||||
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
|
||||
try {
|
||||
writeTerminal(source, expected)
|
||||
writeTerminal(replay, actual)
|
||||
expect(
|
||||
Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols))
|
||||
).toBe(differs)
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5)
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4)
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual:
|
||||
|
||||
type BufferLine = NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>
|
||||
type Buffer = Terminal['buffer']['active']
|
||||
type Cell = ReturnType<Buffer['getNullCell']>
|
||||
|
||||
const COLOR_MODE_P16 = 16777216
|
||||
const COLOR_MODE_P256 = 33554432
|
||||
@@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean {
|
||||
return expected === actual || (expected === CLIPPED && actual.startsWith('▯'))
|
||||
}
|
||||
|
||||
function rowsMatch(expected: string[], actual: string[]): boolean {
|
||||
return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!))
|
||||
function sameColor(
|
||||
expectedMode: number,
|
||||
expectedColor: number,
|
||||
actualMode: number,
|
||||
actualColor: number
|
||||
): boolean {
|
||||
return (
|
||||
expectedColor === actualColor &&
|
||||
canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor)
|
||||
)
|
||||
}
|
||||
|
||||
export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] {
|
||||
const rows: string[][] = []
|
||||
const reusableCell = buffer.getNullCell()
|
||||
for (let y = start; y < end; y++) {
|
||||
rows.push(rowCells(buffer.getLine(y), cols, reusableCell))
|
||||
// Equal public fields imply identical descriptors; differing fields still use the frozen policy.
|
||||
function sameCellFields(expected: Cell, actual: Cell): boolean {
|
||||
if (
|
||||
expected.getWidth() !== actual.getWidth() ||
|
||||
!sameColor(
|
||||
expected.getBgColorMode(),
|
||||
expected.getBgColor(),
|
||||
actual.getBgColorMode(),
|
||||
actual.getBgColor()
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
|
||||
rows.pop()
|
||||
const expectedChars = expected.getChars()
|
||||
const actualChars = actual.getChars()
|
||||
const expectedBlank = expectedChars === '' || expectedChars === ' '
|
||||
const actualBlank = actualChars === '' || actualChars === ' '
|
||||
if (expectedBlank || actualBlank) {
|
||||
return (
|
||||
expectedBlank &&
|
||||
actualBlank &&
|
||||
(expectedChars === ' ' && expected.isUnderline() !== 0) ===
|
||||
(actualChars === ' ' && actual.isUnderline() !== 0) &&
|
||||
(expectedChars === ' ' && expected.isStrikethrough() !== 0) ===
|
||||
(actualChars === ' ' && actual.isStrikethrough() !== 0) &&
|
||||
(expectedChars === ' ' && expected.isOverline() !== 0) ===
|
||||
(actualChars === ' ' && actual.isOverline() !== 0) &&
|
||||
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
|
||||
(expected.isInverse() === 0 ||
|
||||
sameColor(
|
||||
expected.getFgColorMode(),
|
||||
expected.getFgColor(),
|
||||
actual.getFgColorMode(),
|
||||
actual.getFgColor()
|
||||
))
|
||||
)
|
||||
}
|
||||
return rows
|
||||
return (
|
||||
expectedChars === actualChars &&
|
||||
sameColor(
|
||||
expected.getFgColorMode(),
|
||||
expected.getFgColor(),
|
||||
actual.getFgColorMode(),
|
||||
actual.getFgColor()
|
||||
) &&
|
||||
(expected.isBold() !== 0) === (actual.isBold() !== 0) &&
|
||||
(expected.isDim() !== 0) === (actual.isDim() !== 0) &&
|
||||
(expected.isItalic() !== 0) === (actual.isItalic() !== 0) &&
|
||||
(expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) &&
|
||||
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
|
||||
(expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) &&
|
||||
(expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function compareRowSets(
|
||||
function trimmedEnd(
|
||||
buffer: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
cols: number,
|
||||
scratch: Cell
|
||||
): number {
|
||||
while (end > start) {
|
||||
const line = buffer.getLine(end - 1)
|
||||
let blank = true
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) {
|
||||
blank = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!blank) {
|
||||
break
|
||||
}
|
||||
end--
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
function lineCellsMatch(
|
||||
expected: BufferLine | undefined,
|
||||
actual: BufferLine | undefined,
|
||||
cols: number,
|
||||
expectedScratch: Cell,
|
||||
actualScratch: Cell
|
||||
): boolean {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const expectedCell =
|
||||
expected && x < expected.length ? expected.getCell(x, expectedScratch) : null
|
||||
const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null
|
||||
const clipped =
|
||||
x === cols - 1 &&
|
||||
((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) ||
|
||||
(actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1))
|
||||
if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!cellsMatch(
|
||||
cellDescriptor(expected, x, cols, expectedScratch),
|
||||
cellDescriptor(actual, x, cols, actualScratch)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */
|
||||
export function compareBufferRows(
|
||||
stage: string,
|
||||
expected: string[][],
|
||||
actual: string[][]
|
||||
expected: Buffer,
|
||||
expectedStart: number,
|
||||
expectedEnd: number,
|
||||
actual: Buffer,
|
||||
actualStart: number,
|
||||
actualEnd: number,
|
||||
cols: number
|
||||
): GridDiff | null {
|
||||
const length = Math.max(expected.length, actual.length)
|
||||
for (let y = 0; y < length; y++) {
|
||||
const e = expected[y]
|
||||
const a = actual[y]
|
||||
if (!e || !a || !rowsMatch(e, a)) {
|
||||
return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') }
|
||||
const expectedScratch = expected.getNullCell()
|
||||
const actualScratch = actual.getNullCell()
|
||||
const expectedLength =
|
||||
trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart
|
||||
const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart
|
||||
for (let y = 0; y < Math.max(expectedLength, actualLength); y++) {
|
||||
const expectedLine = expected.getLine(expectedStart + y)
|
||||
const actualLine = actual.getLine(actualStart + y)
|
||||
if (
|
||||
y >= expectedLength ||
|
||||
y >= actualLength ||
|
||||
!lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch)
|
||||
) {
|
||||
return {
|
||||
stage,
|
||||
row: y,
|
||||
expected:
|
||||
y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined,
|
||||
actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -18,12 +18,7 @@ import {
|
||||
variantTrailingBackgroundRows
|
||||
} from './serialize-grid-variant-scope'
|
||||
import type { GridDiff } from './serialize-grid-cell-descriptors'
|
||||
import {
|
||||
bufferRows,
|
||||
cellDescriptor,
|
||||
CLIPPED,
|
||||
compareRowSets
|
||||
} from './serialize-grid-cell-descriptors'
|
||||
import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors'
|
||||
|
||||
export type NamedSerializer = { name: string; create: () => SerializeAddon }
|
||||
|
||||
@@ -156,15 +151,25 @@ async function compareReplay(
|
||||
(src.type === dst.type
|
||||
? null
|
||||
: { stage: 'active-buffer', expected: src.type, actual: dst.type }) ??
|
||||
compareRowSets(
|
||||
compareBufferRows(
|
||||
'visible-grid',
|
||||
bufferRows(src, src.baseY, src.baseY + rows, cols),
|
||||
bufferRows(dst, dst.baseY, dst.baseY + rows, cols)
|
||||
src,
|
||||
src.baseY,
|
||||
src.baseY + rows,
|
||||
dst,
|
||||
dst.baseY,
|
||||
dst.baseY + rows,
|
||||
cols
|
||||
) ??
|
||||
compareRowSets(
|
||||
compareBufferRows(
|
||||
'normal-buffer',
|
||||
bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols),
|
||||
bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols)
|
||||
source.buffer.normal,
|
||||
normalStart,
|
||||
source.buffer.normal.length,
|
||||
replay.buffer.normal,
|
||||
0,
|
||||
replay.buffer.normal.length,
|
||||
cols
|
||||
) ??
|
||||
(src.cursorX === dst.cursorX && src.cursorY === dst.cursorY
|
||||
? null
|
||||
|
||||
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile'
|
||||
import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile'
|
||||
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
|
||||
import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path'
|
||||
import {
|
||||
OVERLAY_ONLY_FEATURES,
|
||||
STARTUP_COMMAND_FEATURES
|
||||
@@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
itWithBash.each([
|
||||
['daemon', getDaemonBashShellReadyRcfileContent],
|
||||
['local', getBashShellReadyRcfileContent]
|
||||
] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => {
|
||||
const cliBin = join(userDataPath, 'cli', 'bin')
|
||||
const ambientBin = join(userDataPath, 'ambient-bin')
|
||||
mkdirSync(cliBin, { recursive: true })
|
||||
mkdirSync(ambientBin)
|
||||
for (const bin of [cliBin, ambientBin]) {
|
||||
const launcher = join(bin, 'orca-dev')
|
||||
writeFileSync(launcher, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcher, 0o755)
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
HOME: userDataPath,
|
||||
USERPROFILE: userDataPath,
|
||||
PATH: `${ambientBin}:/usr/bin:/bin`,
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
}
|
||||
const expectedLauncher = prependOrcaCliDirToChildPath(env, {
|
||||
isPackaged: false,
|
||||
userDataPath
|
||||
})
|
||||
writeFileSync(
|
||||
join(userDataPath, '.bash_profile'),
|
||||
'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n'
|
||||
)
|
||||
const rcfile = join(userDataPath, 'cli-path-rcfile')
|
||||
writeFileSync(rcfile, content())
|
||||
const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 5000
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher)
|
||||
})
|
||||
|
||||
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
|
||||
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
|
||||
@@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void {
|
||||
core.writeSync(data)
|
||||
}
|
||||
|
||||
type Cell = ReturnType<Terminal['buffer']['active']['getNullCell']>
|
||||
|
||||
function observeCellReads(terminal: Terminal) {
|
||||
const allocated: Cell[] = []
|
||||
const targets: (Cell | undefined)[] = []
|
||||
const active = terminal.buffer.active
|
||||
const source = {
|
||||
rows: terminal.rows,
|
||||
modes: terminal.modes,
|
||||
buffer: {
|
||||
active: {
|
||||
baseY: active.baseY,
|
||||
cursorX: active.cursorX,
|
||||
cursorY: active.cursorY,
|
||||
viewportY: active.viewportY,
|
||||
getNullCell: () => {
|
||||
const cell = active.getNullCell()
|
||||
allocated.push(cell)
|
||||
return cell
|
||||
},
|
||||
getLine: (row: number) => {
|
||||
const line = active.getLine(row)
|
||||
if (!line) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
isWrapped: line.isWrapped,
|
||||
length: line.length,
|
||||
translateToString: line.translateToString.bind(line),
|
||||
getCell: (column: number, reusableCell?: Cell) => {
|
||||
targets.push(reusableCell)
|
||||
return line.getCell(column, reusableCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { source, allocated, targets }
|
||||
}
|
||||
|
||||
describe('readTerminalCursorLineContext', () => {
|
||||
it('reuses one cell across every scan without retaining it between reads', () => {
|
||||
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
|
||||
try {
|
||||
writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8')
|
||||
const rig = observeCellReads(terminal)
|
||||
const first = readTerminalCursorLineContext(rig.source, terminal.rows)
|
||||
expect(rig.allocated).toHaveLength(1)
|
||||
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
|
||||
expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true)
|
||||
rig.targets.length = 0
|
||||
expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first)
|
||||
expect(rig.allocated).toHaveLength(2)
|
||||
expect(rig.allocated[1]).not.toBe(rig.allocated[0])
|
||||
expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves full context for an adapter without reusable cells', () => {
|
||||
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
|
||||
try {
|
||||
writeSync(
|
||||
terminal,
|
||||
'\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8'
|
||||
)
|
||||
const rig = observeCellReads(terminal)
|
||||
const source = {
|
||||
...rig.source,
|
||||
buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } }
|
||||
}
|
||||
const context = readTerminalCursorLineContext(source, terminal.rows)
|
||||
expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows))
|
||||
expect(context?.typedRows).toEqual(['❯ typed'])
|
||||
expect(context?.typedRowsBelow[0]).toBe('界e\u0301')
|
||||
expect(context?.beforeCursor).toBe('❯ typed')
|
||||
expect(context?.afterCursor).toBe('')
|
||||
expect(context?.rawAfterCursor).toBe(' hint')
|
||||
expect(context?.promptGlyphBoldRows).toEqual([true])
|
||||
expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
|
||||
expect(rig.allocated).toEqual([])
|
||||
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
|
||||
expect(rig.targets.every((cell) => cell === undefined)).toBe(true)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
|
||||
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
|
||||
|
||||
@@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
|
||||
['main/bitbucket/client.ts', 1],
|
||||
['main/bitbucket/user-request.ts', 1],
|
||||
['main/gitea/client.ts', 1],
|
||||
// Generated OpenCode claim source consumes JSON or cancels its body in finally.
|
||||
['main/opencode/opencode-startup-prompt-source.ts', 1],
|
||||
['main/orca-profiles/profile-cloud-client.ts', 1],
|
||||
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
|
||||
['main/rate-limits/codex-fetcher.ts', 3],
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('counts only matches against the ripgrep cap', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep(''))
|
||||
|
||||
const files = await listQuickOpenFiles(
|
||||
@@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', null, -2))
|
||||
|
||||
await expect(
|
||||
@@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
|
||||
|
||||
await expect(
|
||||
@@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('still rejects an ignored-pass failure for unfiltered listings', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -88,6 +88,33 @@ describe('filesystem-list-files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
|
||||
const child = createMockProcess()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const store: Store = Object.create(null)
|
||||
const promise = listQuickOpenFiles('/repo', store)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
|
||||
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
|
||||
if (kind === 'incomplete') {
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
await expect(promise).rejects.toThrow('not valid UTF-8')
|
||||
if (kind === 'invalid') {
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts NUL-delimited filenames containing newlines as one result each', async () => {
|
||||
const child = createMockProcess()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const store: Store = Object.create(null)
|
||||
const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
|
||||
child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0')
|
||||
await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r'])
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a synchronous launch failure before cleanup has been initialized', async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })
|
||||
@@ -122,7 +149,7 @@ describe('filesystem-list-files', () => {
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts')
|
||||
p1.stdout?.emit('data', 'one.ts\0two.ts')
|
||||
p1.emit('close', 0, null)
|
||||
}, 0)
|
||||
const result = await promise
|
||||
@@ -154,12 +181,12 @@ describe('filesystem-list-files', () => {
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs')
|
||||
source.stdout?.emit('data', 'source.ts\n')
|
||||
source.stdout?.emit('data', 'source.ts\0')
|
||||
source.emit('close', 0, null)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs')
|
||||
broad.stdout?.emit('data', 'ignored-file.ts\n')
|
||||
broad.stdout?.emit('data', 'ignored-file.ts\0')
|
||||
await expect(listing).resolves.toEqual(['source.ts'])
|
||||
expect(broad.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -177,18 +204,18 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
// Simulate stdout output for normal files
|
||||
setTimeout(() => {
|
||||
p1.stdout?.emit('data', 'file1.ts\n')
|
||||
p1.stdout?.emit('data', 'node_modules/bad.js\n')
|
||||
p1.stdout?.emit('data', '.git/config\n')
|
||||
p1.stdout?.emit('data', '.github/workflows/ci.yml\n')
|
||||
p1.stdout?.emit('data', 'file1.ts\0')
|
||||
p1.stdout?.emit('data', 'node_modules/bad.js\0')
|
||||
p1.stdout?.emit('data', '.git/config\0')
|
||||
p1.stdout?.emit('data', '.github/workflows/ci.yml\0')
|
||||
p1.stdout?.emit('data', 'dir1/') // incomplete line
|
||||
p1.stdout?.emit('data', 'file2.js\n')
|
||||
p1.stdout?.emit('data', 'file2.js\0')
|
||||
|
||||
// The broad pass includes ignored files too.
|
||||
p1.stdout?.emit('data', '.env.local\n')
|
||||
p1.stdout?.emit('data', 'dist/generated.js\n')
|
||||
p1.stdout?.emit('data', 'file1.ts\n') // Duplicate
|
||||
p1.stdout?.emit('data', 'node_modules/ignored.js\n')
|
||||
p1.stdout?.emit('data', '.env.local\0')
|
||||
p1.stdout?.emit('data', 'dist/generated.js\0')
|
||||
p1.stdout?.emit('data', 'file1.ts\0') // Duplicate
|
||||
p1.stdout?.emit('data', 'node_modules/ignored.js\0')
|
||||
p1.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
@@ -213,7 +240,7 @@ describe('filesystem-list-files', () => {
|
||||
const promise = listQuickOpenFiles('C:\\repo', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
p1.stdout?.emit('data', 'src/index.ts\0')
|
||||
p1.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
@@ -237,7 +264,7 @@ describe('filesystem-list-files', () => {
|
||||
const promise = listQuickOpenFiles('C:\\repo', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n')
|
||||
p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0')
|
||||
p1.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
@@ -310,7 +337,7 @@ describe('filesystem-list-files', () => {
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
p1.stdout?.emit('data', 'src/index.ts\0')
|
||||
p1.emit('close', 2, null)
|
||||
}, 10)
|
||||
|
||||
@@ -332,7 +359,7 @@ describe('filesystem-list-files', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial')
|
||||
p1.stdout?.emit('data', 'src/index.ts\0partial')
|
||||
const rejection = expect(promise).rejects.toThrow('rg list timed out')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
@@ -376,12 +403,12 @@ describe('filesystem-list-files', () => {
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
|
||||
p1.stdout?.emit('data', '.next/cache/1.js\0')
|
||||
p1.stdout?.emit('data', '.cache/data.json\0')
|
||||
p1.stdout?.emit('data', '.stably/config.json\0')
|
||||
p1.stdout?.emit('data', '.vscode/settings.json\0')
|
||||
p1.stdout?.emit('data', '.idea/workspace.xml\0')
|
||||
p1.stdout?.emit('data', 'valid.ts\0')
|
||||
p1.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
|
||||
import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder'
|
||||
import { sep } from 'node:path'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -8,7 +10,6 @@ import {
|
||||
buildExcludePathPrefixes,
|
||||
buildRgArgsForQuickOpen,
|
||||
normalizeQuickOpenRgLine,
|
||||
type RgOutputMode,
|
||||
shouldExcludeQuickOpenRelPath,
|
||||
shouldIncludeQuickOpenPath
|
||||
} from '../../shared/quick-open-filter'
|
||||
@@ -79,6 +80,10 @@ export async function listQuickOpenFiles(
|
||||
|
||||
const runRg = (args: string[]): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
finish(error)
|
||||
}, Boolean(wslDistroForOutput))
|
||||
let buf = ''
|
||||
let done = false
|
||||
let parseablePathCount = 0
|
||||
@@ -138,18 +143,22 @@ export async function listQuickOpenFiles(
|
||||
return
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const handleStdoutData = (chunk: string): void => {
|
||||
buf += chunk
|
||||
const handleStdoutData = (chunk: Buffer | string): void => {
|
||||
const decoded = filenameDecoder.decode(chunk)
|
||||
if (decoded === null) {
|
||||
return
|
||||
}
|
||||
buf += decoded
|
||||
let start = 0
|
||||
let newlineIdx = buf.indexOf('\n', start)
|
||||
while (newlineIdx !== -1) {
|
||||
if (processLine(buf.substring(start, newlineIdx))) {
|
||||
let delimiterIdx = buf.indexOf('\0', start)
|
||||
while (delimiterIdx !== -1) {
|
||||
if (processLine(buf.substring(start, delimiterIdx))) {
|
||||
buf = ''
|
||||
finishAtLimit()
|
||||
return
|
||||
}
|
||||
start = newlineIdx + 1
|
||||
newlineIdx = buf.indexOf('\n', start)
|
||||
start = delimiterIdx + 1
|
||||
delimiterIdx = buf.indexOf('\0', start)
|
||||
}
|
||||
buf = start < buf.length ? buf.substring(start) : ''
|
||||
}
|
||||
@@ -211,14 +220,15 @@ export async function listQuickOpenFiles(
|
||||
finish(new Error(`rg killed by ${signal}`))
|
||||
return
|
||||
}
|
||||
if (!filenameDecoder.finish()) {
|
||||
return
|
||||
}
|
||||
if (buf && processLine(buf)) {
|
||||
buf = ''
|
||||
finishAtLimit()
|
||||
return
|
||||
}
|
||||
if (code === 0 || code === 1) {
|
||||
finish()
|
||||
} else if (code === 2 && parseablePathCount > 0) {
|
||||
if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) {
|
||||
// rg can return 2 for unreadable subdirectories while still listing
|
||||
// usable files from the rest of the root.
|
||||
finish()
|
||||
@@ -257,7 +267,6 @@ export async function listQuickOpenFiles(
|
||||
|
||||
children.push({ child, isDone: () => done, finish })
|
||||
|
||||
child.stdout?.setEncoding('utf-8')
|
||||
child.stdout?.on('data', handleStdoutData)
|
||||
child.stderr?.on('data', handleStderrData)
|
||||
child.once('error', handleError)
|
||||
@@ -290,16 +299,9 @@ export async function listQuickOpenFiles(
|
||||
}
|
||||
|
||||
function finishAtLimit(): void {
|
||||
for (const entry of children) {
|
||||
if (entry.isDone()) {
|
||||
continue
|
||||
}
|
||||
entry.finish()
|
||||
if (entry.child.exitCode === null && entry.child.signalCode === null) {
|
||||
killSpawnedRipgrepProcess(entry.child)
|
||||
}
|
||||
}
|
||||
killSurvivors()
|
||||
}
|
||||
|
||||
try {
|
||||
if (maxResults === undefined && maxSerializedBytes === undefined) {
|
||||
// The broader pass already includes source files; an unbounded listing needs only one scan.
|
||||
@@ -314,7 +316,12 @@ export async function listQuickOpenFiles(
|
||||
) {
|
||||
// Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches.
|
||||
await runRg(ignoredPass).catch((err: unknown) => {
|
||||
if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) {
|
||||
if (
|
||||
!pathFilter ||
|
||||
signal?.aborted ||
|
||||
err instanceof RipgrepUnavailableError ||
|
||||
err instanceof RipgrepFilenameError
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
@@ -329,19 +336,3 @@ export async function listQuickOpenFiles(
|
||||
? result
|
||||
: limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes)
|
||||
}
|
||||
|
||||
function getQuickOpenRgOutputMode(
|
||||
rawLine: string,
|
||||
translatedLine: string,
|
||||
rootPath: string
|
||||
): RgOutputMode {
|
||||
if (
|
||||
translatedLine !== rawLine ||
|
||||
rawLine.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
|
||||
rawLine.startsWith('\\\\')
|
||||
) {
|
||||
return { kind: 'absolute', rootPath }
|
||||
}
|
||||
return { kind: 'cwd-relative' }
|
||||
}
|
||||
|
||||
@@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const controller = new AbortController()
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
|
||||
query: 'file',
|
||||
limit: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', Buffer.from([0xf0, 0x9f]))
|
||||
controller.abort()
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
})
|
||||
|
||||
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 })
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
|
||||
if (kind === 'incomplete') {
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
await expect(promise).rejects.toThrow('not valid UTF-8')
|
||||
if (kind === 'invalid') {
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves control characters within ranked paths', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
|
||||
query: 'target',
|
||||
limit: 2
|
||||
})
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0')
|
||||
child.emit('close', 0, null)
|
||||
expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort())
|
||||
})
|
||||
|
||||
it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
|
||||
query: 'target',
|
||||
limit: 2
|
||||
})
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1))
|
||||
await expect(promise).rejects.toThrow('file path exceeds the listing limit')
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
child.stdout?.emit('data', 'target.ts\0')
|
||||
child.emit('close', 0, null)
|
||||
})
|
||||
|
||||
it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
@@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg')
|
||||
expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs')
|
||||
;(child.stdout as unknown as EventEmitter).emit(
|
||||
child.stdout?.emit(
|
||||
'data',
|
||||
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n`
|
||||
)
|
||||
;(child.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n'
|
||||
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0`
|
||||
)
|
||||
child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0')
|
||||
child.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
@@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
limit: 32
|
||||
})
|
||||
await flushMicrotasks()
|
||||
;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
|
||||
child.stdout?.emit('data', 'src/target.ts\0')
|
||||
child.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
|
||||
@@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2)
|
||||
;(succeeded.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
'data/chunk-077568/sta-4354-gitignored-target.bin\n'
|
||||
)
|
||||
succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0')
|
||||
succeeded.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
@@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
limit: 32
|
||||
})
|
||||
await flushMicrotasks()
|
||||
;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
|
||||
succeeded.stdout?.emit('data', 'src/target.ts\0')
|
||||
succeeded.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
|
||||
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
|
||||
import { sep } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
|
||||
@@ -6,8 +8,7 @@ import {
|
||||
buildRgArgsForQuickOpen,
|
||||
normalizeQuickOpenRgLine,
|
||||
shouldExcludeQuickOpenRelPath,
|
||||
shouldIncludeQuickOpenPath,
|
||||
type RgOutputMode
|
||||
shouldIncludeQuickOpenPath
|
||||
} from '../../shared/quick-open-filter'
|
||||
import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search'
|
||||
import {
|
||||
@@ -110,7 +111,11 @@ function scanRipgrepPaths(args: {
|
||||
return Promise.reject(fileListingCancellationError(args.signal))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a)
|
||||
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
finish(error)
|
||||
}, Boolean(args.wslDistroForOutput))
|
||||
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0)
|
||||
let done = false
|
||||
let parseablePathCount = 0
|
||||
let processErrorObserved = false
|
||||
@@ -141,7 +146,7 @@ function scanRipgrepPaths(args: {
|
||||
: rawLine
|
||||
const relPath = normalizeQuickOpenRgLine(
|
||||
translated,
|
||||
getOutputMode(rawLine, translated, args.authorizedRootPath)
|
||||
getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath)
|
||||
)
|
||||
if (relPath === null) {
|
||||
return
|
||||
@@ -178,11 +183,19 @@ function scanRipgrepPaths(args: {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const handleStdoutData = (chunk: string): void => {
|
||||
pathAccumulator.push(chunk, (path) => {
|
||||
const handleStdoutData = (chunk: Buffer | string): void => {
|
||||
const decoded = filenameDecoder.decode(chunk)
|
||||
if (decoded === null) {
|
||||
return
|
||||
}
|
||||
const result = pathAccumulator.push(decoded, (path) => {
|
||||
processLine(path)
|
||||
return true
|
||||
})
|
||||
if (result === 'path-too-large') {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
finish(new Error('Quick Open file path exceeds the listing limit'))
|
||||
}
|
||||
}
|
||||
const handleStderrData = (): void => {
|
||||
/* drain */
|
||||
@@ -233,6 +246,9 @@ function scanRipgrepPaths(args: {
|
||||
finish(new Error(`rg killed by ${signal}`))
|
||||
return
|
||||
}
|
||||
if (!filenameDecoder.finish()) {
|
||||
return
|
||||
}
|
||||
const trailingPath = pathAccumulator.finish()
|
||||
if (trailingPath) {
|
||||
processLine(trailingPath)
|
||||
@@ -249,7 +265,6 @@ function scanRipgrepPaths(args: {
|
||||
finish(fileListingCancellationError(args.signal))
|
||||
}
|
||||
|
||||
child.stdout?.setEncoding('utf-8')
|
||||
child.stdout?.on('data', handleStdoutData)
|
||||
child.stderr?.on('data', handleStderrData)
|
||||
child.once('error', handleError)
|
||||
@@ -265,12 +280,3 @@ function scanRipgrepPaths(args: {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode {
|
||||
return translatedLine !== rawLine ||
|
||||
rawLine.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
|
||||
rawLine.startsWith('\\\\')
|
||||
? { kind: 'absolute', rootPath }
|
||||
: { kind: 'cwd-relative' }
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps post-spawn errors on the existing empty-result path', async () => {
|
||||
it('rejects post-spawn errors instead of returning an empty result', async () => {
|
||||
const child = createMockProcess()
|
||||
Object.defineProperty(child, 'pid', { value: 1 })
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
@@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => {
|
||||
await flushMicrotasks()
|
||||
child.emit('error', new Error('post-spawn failure'))
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ files: [] })
|
||||
await expect(promise).rejects.toThrow('post-spawn failure')
|
||||
})
|
||||
|
||||
// Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler
|
||||
@@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => {
|
||||
expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg')
|
||||
})
|
||||
|
||||
it('marks WSL filenames that UNC cannot represent as incomplete', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler.
|
||||
registerFilesystemHandlers({} as never)
|
||||
const promise = handlers.get('fs:search')!(
|
||||
{ sender: { id: 7 } },
|
||||
{ rootPath: 'C:\\repo', query: 'hello' }
|
||||
)
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit(
|
||||
'data',
|
||||
`${JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: './a\\b.txt' },
|
||||
lines: { text: 'hello\n' },
|
||||
line_number: 1,
|
||||
submatches: [{ start: 0, end: 5 }]
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
child.emit('close', 0, null)
|
||||
await expect(promise).resolves.toMatchObject({ files: [], truncated: true })
|
||||
expect(toWindowsWslPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('translates WSL rg output for Windows-path project search results', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics'
|
||||
import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
@@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
|
||||
|
||||
return new Promise<SearchResult>((resolvePromise, rejectPromise) => {
|
||||
const rgArgs = buildRgArgs(args.query, rootPath, args)
|
||||
const rgArgs = buildRgArgs(args.query, '.', args)
|
||||
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
|
||||
const previousChild = activeTextSearches.get(searchKey)
|
||||
if (previousChild) {
|
||||
@@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
}
|
||||
|
||||
const acc = createAccumulator()
|
||||
const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER)
|
||||
const lines = new SearchSubprocessLineAccumulator()
|
||||
const diagnostics = new RipgrepSearchDiagnostics()
|
||||
let resolved = false
|
||||
let processErrorObserved = false
|
||||
let unavailableExitObserved = false
|
||||
@@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
let killTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const transformAbsPath = wslDistroForOutput
|
||||
? (path: string): string =>
|
||||
path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path
|
||||
? (path: string): string | null =>
|
||||
path.includes('\\')
|
||||
? null
|
||||
: path.startsWith('/')
|
||||
? toWindowsWslPath(path, wslDistroForOutput)
|
||||
: path
|
||||
: undefined
|
||||
|
||||
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
|
||||
@@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
}
|
||||
resolvePromise(result)
|
||||
}
|
||||
const resolveOnce = (): void => finish(finalize(acc))
|
||||
const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => {
|
||||
const error = diagnostics.failure(code, signal, acc)
|
||||
finish(error ? Promise.reject(error) : finalize(acc))
|
||||
}
|
||||
const rejectUnavailable = (): void =>
|
||||
finish(Promise.reject(bundledRipgrepUnavailableError()))
|
||||
const processLine = (line: string): void => {
|
||||
@@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
activeTextSearches.set(searchKey, nextChild)
|
||||
|
||||
const handleStdoutData = (chunk: string): void => {
|
||||
lines.push(chunk, processLine)
|
||||
if (!lines.push(chunk, processLine)) {
|
||||
acc.truncated = true
|
||||
if (child) {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
}
|
||||
resolveOnce()
|
||||
}
|
||||
}
|
||||
const handleStderrData = (): void => {
|
||||
// Drain stderr so rg cannot block on a full pipe.
|
||||
const handleStderrData = (chunk: Buffer): void => {
|
||||
diagnostics.append(chunk)
|
||||
}
|
||||
const handleError = (error: NodeJS.ErrnoException): void => {
|
||||
processErrorObserved = true
|
||||
@@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
return
|
||||
}
|
||||
if (child && isRipgrepUnavailableExit(child, null, null)) {
|
||||
// Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the
|
||||
// binary for it tells the user to reinstall Orca over a workspace that simply moved.
|
||||
// Why detach close first: a failed spawn emits error THEN close(code < 0), and
|
||||
// close settles synchronously, so this probe would otherwise race it on a sub-ms
|
||||
// margin -- two measurements disagreed on which wins. Detaching makes it deterministic.
|
||||
// Distinguish a missing workspace from a missing binary before close can settle.
|
||||
child.off('close', handleClose)
|
||||
// Why catch: a failed probe must not strand the search; fall back to the prior verdict.
|
||||
void isRipgrepSpawnCwdUsable(rootPath)
|
||||
.catch(() => true)
|
||||
.then((usable) => {
|
||||
// Why re-check: finish() drops its argument once settled, so a rejected promise
|
||||
// built after the close handler already won would go unhandled.
|
||||
// A late rejected promise must not escape after close settles the search.
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
@@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
})
|
||||
return
|
||||
}
|
||||
resolveOnce()
|
||||
finish(Promise.reject(error))
|
||||
if (child) {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
}
|
||||
}
|
||||
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
// Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise
|
||||
@@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
rejectUnavailable()
|
||||
return
|
||||
}
|
||||
const tail = lines.finish()
|
||||
const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null
|
||||
if (tail !== null) {
|
||||
processLine(tail)
|
||||
}
|
||||
resolveOnce()
|
||||
resolveOnce(code ?? -1, signal)
|
||||
}
|
||||
|
||||
nextChild.stdout?.setEncoding('utf-8')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodePath from 'node:path'
|
||||
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodePath>()
|
||||
return { ...actual, extname: actual.win32.extname }
|
||||
})
|
||||
|
||||
import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents'
|
||||
|
||||
describe('remote Markdown filenames on a Windows client', () => {
|
||||
it('keeps the local filename helper on Windows semantics', () => {
|
||||
expect(isMarkdownDocumentName('notes\\.md')).toBe(false)
|
||||
expect(isMarkdownDocumentName('notes.md\\')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])(
|
||||
'preserves POSIX filename %s and its extension',
|
||||
(relativePath) => {
|
||||
const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1)
|
||||
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({
|
||||
filePath: `/home/repo/${relativePath}`,
|
||||
relativePath,
|
||||
basename,
|
||||
name: basename.slice(0, basename.lastIndexOf('.'))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['notes.md\\', 'nested/.md', '../outside.md'])(
|
||||
'rejects non-Markdown or escaping POSIX filename %s',
|
||||
(relativePath) => {
|
||||
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('normalizes Windows remote separators before reading the basename', () => {
|
||||
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({
|
||||
filePath: 'C:\\repo/notes/README.MD',
|
||||
relativePath: 'notes/README.MD',
|
||||
basename: 'README.MD',
|
||||
name: 'README'
|
||||
})
|
||||
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull()
|
||||
expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => {
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => {
|
||||
vi.useFakeTimers()
|
||||
const result = listMarkdownDocuments(root)
|
||||
const outcome = expect(result).rejects.toThrow('timed out')
|
||||
child.stdout.write(Buffer.from([0xf0, 0x9f]))
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await outcome
|
||||
})
|
||||
|
||||
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
|
||||
const result = listMarkdownDocuments(root)
|
||||
child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
|
||||
if (kind === 'incomplete') {
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
await expect(result).rejects.toThrow('not valid UTF-8')
|
||||
})
|
||||
|
||||
it('accepts an empty listing', async () => {
|
||||
const result = listMarkdownDocuments(root)
|
||||
child.emit('close', 1, null)
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { markdownDocumentFromFilePath } from './markdown-documents'
|
||||
import {
|
||||
markdownDocumentFromFilePath,
|
||||
markdownDocumentFromRelativePath
|
||||
} from './markdown-documents'
|
||||
|
||||
describe('markdownDocumentFromFilePath', () => {
|
||||
it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => {
|
||||
expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({
|
||||
filePath: '/repo\\/a\\b.md',
|
||||
relativePath: 'a\\b.md',
|
||||
basename: 'a\\b.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves POSIX remote filenames and trailing root backslashes', () => {
|
||||
for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) {
|
||||
expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({
|
||||
filePath: `/repo\\/${name}`,
|
||||
relativePath: name,
|
||||
basename: name.slice(name.lastIndexOf('/') + 1)
|
||||
})
|
||||
}
|
||||
expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])(
|
||||
'preserves Windows separator handling under %s',
|
||||
(root) => {
|
||||
expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({
|
||||
filePath: `${root.slice(0, -1)}/a/b.md`,
|
||||
relativePath: 'a/b.md',
|
||||
basename: 'b.md'
|
||||
})
|
||||
expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps in-root path segments that merely start with parent traversal text', () => {
|
||||
expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({
|
||||
filePath: '/workspace/..notes/file.md',
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { normalizeRelativePath } from '../../shared/text-search-paths'
|
||||
import {
|
||||
basename as pathBasename,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
posix,
|
||||
relative,
|
||||
resolve
|
||||
} from 'node:path'
|
||||
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
|
||||
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
|
||||
import { parseWslPath } from '../wsl'
|
||||
@@ -7,36 +18,34 @@ import {
|
||||
ripgrepMissingCwdError
|
||||
} from '../../shared/ripgrep-process-availability'
|
||||
|
||||
function normalizeRelativePath(path: string): string {
|
||||
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
|
||||
export function isMarkdownDocumentName(name: string): boolean {
|
||||
return isMarkdownExtension(extname(name))
|
||||
}
|
||||
|
||||
export function isMarkdownDocumentName(name: string): boolean {
|
||||
const extension = extname(name).toLowerCase()
|
||||
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
|
||||
function isMarkdownExtension(extension: string): boolean {
|
||||
const normalized = extension.toLowerCase()
|
||||
return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown'
|
||||
}
|
||||
|
||||
function basenameFromRelativePath(relativePath: string): string {
|
||||
const normalizedPath = relativePath.replaceAll('\\', '/')
|
||||
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
|
||||
return relativePath.slice(relativePath.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
function isSafeRelativePath(relativePath: string): boolean {
|
||||
return !relativePath.split('/').includes('..')
|
||||
}
|
||||
|
||||
function hasParentTraversalSegment(relativePath: string): boolean {
|
||||
return relativePath.split(/[\\/]+/).includes('..')
|
||||
}
|
||||
|
||||
function rootRelativePath(rootPath: string, filePath: string): string | null {
|
||||
const resolvedRoot = resolve(rootPath)
|
||||
const resolvedFile = resolve(filePath)
|
||||
const relativePath = relative(resolvedRoot, resolvedFile)
|
||||
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
|
||||
if (
|
||||
!isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalizeRelativePath(relativePath)
|
||||
return normalizeRelativePath(relativePath, rootPath)
|
||||
}
|
||||
|
||||
export function fileDocumentFromFilePath(
|
||||
@@ -50,7 +59,7 @@ export function fileDocumentFromFilePath(
|
||||
rootRelativePath(rootPath, filePath) ??
|
||||
(options.outsideRootRelativePath === 'basename'
|
||||
? basename
|
||||
: normalizeRelativePath(relative(rootPath, filePath)))
|
||||
: normalizeRelativePath(relative(rootPath, filePath), rootPath))
|
||||
return {
|
||||
filePath,
|
||||
relativePath,
|
||||
@@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath(
|
||||
rootPath: string,
|
||||
relativePath: string
|
||||
): MarkdownDocument | null {
|
||||
const normalizedRelativePath = normalizeRelativePath(relativePath)
|
||||
const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath)
|
||||
// Why: SSH providers should return root-relative paths; reject escape
|
||||
// segments before building a synthetic absolute path for renderer use.
|
||||
if (!isSafeRelativePath(normalizedRelativePath)) {
|
||||
return null
|
||||
}
|
||||
const basename = basenameFromRelativePath(normalizedRelativePath)
|
||||
if (!isMarkdownDocumentName(basename)) {
|
||||
// Remote separators are already normalized; a POSIX backslash stays part of the name.
|
||||
const extension = posix.extname(basename)
|
||||
if (!isMarkdownExtension(extension)) {
|
||||
return null
|
||||
}
|
||||
const extension = extname(basename)
|
||||
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
|
||||
const normalizedRoot = rootPath.replace(
|
||||
isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/,
|
||||
''
|
||||
)
|
||||
return {
|
||||
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
|
||||
relativePath: normalizedRelativePath,
|
||||
@@ -131,6 +144,10 @@ export async function listMarkdownDocuments(
|
||||
)
|
||||
|
||||
return new Promise((resolveListing, reject) => {
|
||||
const filenameDecoder = new RipgrepFilenameDecoder(
|
||||
(error) => finish(error),
|
||||
Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro)
|
||||
)
|
||||
const documents: MarkdownDocument[] = []
|
||||
let carry = ''
|
||||
let stderr = ''
|
||||
@@ -172,8 +189,12 @@ export async function listMarkdownDocuments(
|
||||
const onStderr = (chunk: string): void => {
|
||||
stderr = (stderr + chunk).slice(0, 4096)
|
||||
}
|
||||
const onData = (chunk: string): void => {
|
||||
carry += chunk
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
const decoded = filenameDecoder.decode(chunk)
|
||||
if (decoded === null) {
|
||||
return
|
||||
}
|
||||
carry += decoded
|
||||
let start = 0
|
||||
let end: number
|
||||
while ((end = carry.indexOf('\0', start)) !== -1) {
|
||||
@@ -201,10 +222,11 @@ export async function listMarkdownDocuments(
|
||||
finish(ripgrepMissingCwdError(rootPath))
|
||||
} else if (signal || (code !== 0 && code !== 1)) {
|
||||
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
|
||||
} else if (carry) {
|
||||
finish(new Error('Incomplete path in Markdown document listing'))
|
||||
} else {
|
||||
finish()
|
||||
if (!filenameDecoder.finish()) {
|
||||
return
|
||||
}
|
||||
finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(
|
||||
@@ -212,7 +234,6 @@ export async function listMarkdownDocuments(
|
||||
MARKDOWN_LISTING_TIMEOUT_MS
|
||||
)
|
||||
timer.unref?.()
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stderr?.setEncoding('utf8')
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onStderr)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
|
||||
@@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../opencode/hook-service', () => ({
|
||||
openCodeHookService: {
|
||||
buildPtyEnv: () => ({}),
|
||||
refreshLegacySharedPlugin: vi.fn(),
|
||||
clearPty: vi.fn()
|
||||
},
|
||||
openCode2HookService: {
|
||||
buildPtyEnv: () => ({}),
|
||||
refreshLegacySharedPlugin: vi.fn(),
|
||||
clearPty: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
|
||||
|
||||
vi.mock('../pi/titlebar-extension-service', () => ({
|
||||
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
|
||||
|
||||
@@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record<string, unknown>) => ({
|
||||
})
|
||||
|
||||
export const openCodeHookServiceModuleMock = () => ({
|
||||
OpenCodeHookService: class {
|
||||
buildPtyEnv = vi.fn(() => ({}))
|
||||
},
|
||||
openCodeHookService: {
|
||||
buildPtyEnv: openCodeBuildPtyEnvMock,
|
||||
refreshLegacySharedPlugin: vi.fn<() => void>(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
||||
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
|
||||
import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { selectShellStartupFeatures } from '../shell-startup-features'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => {
|
||||
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it.each(['/bin/bash', '/bin/zsh'])(
|
||||
'does not wrap a bare %s pane merely to expose this app CLI',
|
||||
(shellPath) => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
try {
|
||||
const env = buildPtyHostEnv(
|
||||
'bare-cli-pane',
|
||||
{},
|
||||
{
|
||||
isPackaged: false,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: null,
|
||||
agentStatusHooksEnabled: false
|
||||
}
|
||||
)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin')
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath,
|
||||
env,
|
||||
hasStartupCommand: false,
|
||||
waitsForShellReady: false,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
).toEqual([])
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('does not install managed Pi extensions when Pi is disabled', () => {
|
||||
piBuildPtyEnvMock.mockClear()
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
|
||||
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
|
||||
import {
|
||||
detectExplicitPiAgentKindFromCommand,
|
||||
isPiCompatibleAgentType
|
||||
} from '../../../../shared/pi-agent-kind'
|
||||
import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard'
|
||||
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
|
||||
import {
|
||||
OPENCODE_CONFIG_DIR_ENV_KEYS,
|
||||
isOpenCodeLegacySharedConfigDir
|
||||
} from '../../../opencode/legacy-shared-config-dir'
|
||||
import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer'
|
||||
import { mimoCodeHookService } from '../../../mimo/hook-service'
|
||||
import { agentHookServer } from '../../../agent-hooks/server'
|
||||
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
|
||||
@@ -28,13 +23,13 @@ import {
|
||||
exposePiManagedExtensionEnv,
|
||||
isMimoLaunchCommand,
|
||||
resolveMimocodeSourceHome,
|
||||
resolveOpenCodeSourceConfigDir,
|
||||
resolvePiAgentSourceDir,
|
||||
resolveScopedPiAgentSourceDir,
|
||||
restoreOrStripOverlayEnv
|
||||
} from './pi-agent'
|
||||
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
|
||||
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
|
||||
import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config'
|
||||
|
||||
/**
|
||||
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
|
||||
@@ -50,32 +45,9 @@ export function buildPtyHostEnv(
|
||||
mergePersistedWindowsPath(baseEnv)
|
||||
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
|
||||
|
||||
// Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config.
|
||||
const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean =>
|
||||
isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath)
|
||||
const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {}
|
||||
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
|
||||
if (isLegacyOpenCodeHooksDir(baseEnv[key])) {
|
||||
delete baseEnv[key]
|
||||
}
|
||||
if (!isLegacyOpenCodeHooksDir(process.env[key])) {
|
||||
inheritedOpenCodeEnv[key] = process.env[key]
|
||||
}
|
||||
}
|
||||
// A daemon or sibling shell can retain a retired path that main no longer sees.
|
||||
openCodeHookService.refreshLegacySharedPlugin()
|
||||
openCode2HookService.refreshLegacySharedPlugin()
|
||||
const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv)
|
||||
const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir)
|
||||
? undefined
|
||||
: resolvedOpenCodeConfigDir
|
||||
const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath)
|
||||
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
|
||||
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
|
||||
const openCodeAgent = selectOpenCodeHookAgent(
|
||||
opts.launchAgent,
|
||||
launchCommandHint,
|
||||
(agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents)
|
||||
)
|
||||
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
|
||||
? opts.launchAgent
|
||||
: opts.launchAgent === undefined
|
||||
@@ -110,37 +82,13 @@ export function buildPtyHostEnv(
|
||||
? resolvePiAgentSourceDir(baseEnv, 'prime-agent')
|
||||
: resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent')
|
||||
|
||||
restoreOrStripOverlayEnv(
|
||||
const openCodeAgent = applyOpenCodeStatusPluginEnv(
|
||||
id,
|
||||
baseEnv,
|
||||
{
|
||||
primary: 'OPENCODE_CONFIG_DIR',
|
||||
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
|
||||
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
|
||||
preserveExplicitPrimary: true
|
||||
},
|
||||
inheritedOpenCodeEnv
|
||||
openCodeConfig,
|
||||
opts,
|
||||
launchCommandHint
|
||||
)
|
||||
delete baseEnv.ORCA_OPENCODE_AGENT
|
||||
if (openCodeAgent) {
|
||||
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
|
||||
const openCodeStatusService =
|
||||
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
|
||||
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
|
||||
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
|
||||
if (!opts.isWsl) {
|
||||
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
|
||||
}
|
||||
if (baseEnv.OPENCODE_CONFIG_DIR) {
|
||||
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
|
||||
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
|
||||
if (preexistingOpenCodeConfigDir) {
|
||||
// Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
|
||||
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
|
||||
} else {
|
||||
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.agentStatusHooksEnabled) {
|
||||
if (isMimoLaunchCommand(launchCommandHint)) {
|
||||
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
|
||||
@@ -343,5 +291,8 @@ export function buildPtyHostEnv(
|
||||
// process.env when baseEnv carries none, which is the daemon path's normal shape.
|
||||
stripLegacyTerminalShimEnv(baseEnv, process.platform)
|
||||
|
||||
if (!opts.isWsl) {
|
||||
ensureOpenCodeStartupPromptForLaunch(baseEnv)
|
||||
}
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
OPENCODE_CONFIG_DIR_ENV_KEYS,
|
||||
isOpenCodeLegacySharedConfigDir
|
||||
} from '../../../opencode/legacy-shared-config-dir'
|
||||
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
|
||||
import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent'
|
||||
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
|
||||
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import type { BuildPtyHostEnvOptions } from './types'
|
||||
|
||||
type OpenCodeSourceConfig = {
|
||||
inheritedEnv: NodeJS.ProcessEnv
|
||||
directory: string | undefined
|
||||
}
|
||||
|
||||
export function captureOpenCodeSourceConfig(
|
||||
env: Record<string, string>,
|
||||
userDataPath: string
|
||||
): OpenCodeSourceConfig {
|
||||
const isLegacyDirectory = (dir: string | undefined): boolean =>
|
||||
isOpenCodeLegacySharedConfigDir(dir, userDataPath)
|
||||
const inheritedEnv: NodeJS.ProcessEnv = {}
|
||||
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
|
||||
if (isLegacyDirectory(env[key])) {
|
||||
delete env[key]
|
||||
}
|
||||
if (!isLegacyDirectory(process.env[key])) {
|
||||
inheritedEnv[key] = process.env[key]
|
||||
}
|
||||
}
|
||||
// A daemon or sibling shell can retain a retired path that main no longer sees.
|
||||
openCodeHookService.refreshLegacySharedPlugin()
|
||||
openCode2HookService.refreshLegacySharedPlugin()
|
||||
const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv)
|
||||
return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory }
|
||||
}
|
||||
|
||||
export function applyOpenCodeStatusPluginEnv(
|
||||
id: string,
|
||||
env: Record<string, string>,
|
||||
config: OpenCodeSourceConfig,
|
||||
options: Pick<
|
||||
BuildPtyHostEnvOptions,
|
||||
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl'
|
||||
>,
|
||||
command: string | undefined
|
||||
): 'opencode' | 'opencode2' | null {
|
||||
const agent = selectOpenCodeHookAgent(
|
||||
options.launchAgent,
|
||||
command,
|
||||
(candidate) =>
|
||||
options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents)
|
||||
)
|
||||
restoreOrStripOverlayEnv(
|
||||
env,
|
||||
{
|
||||
primary: 'OPENCODE_CONFIG_DIR',
|
||||
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
|
||||
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
|
||||
preserveExplicitPrimary: true
|
||||
},
|
||||
config.inheritedEnv
|
||||
)
|
||||
delete env.ORCA_OPENCODE_AGENT
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService
|
||||
env.ORCA_OPENCODE_AGENT = agent
|
||||
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
|
||||
if (!options.isWsl) {
|
||||
Object.assign(env, service.buildPtyEnv(id, config.directory))
|
||||
}
|
||||
if (env.OPENCODE_CONFIG_DIR) {
|
||||
// Shell startup can re-export the default; preserve this pane's overlay and original source.
|
||||
env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR
|
||||
if (config.directory) {
|
||||
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory
|
||||
} else {
|
||||
delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
}
|
||||
}
|
||||
return agent
|
||||
}
|
||||
@@ -77,6 +77,28 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('OpenCode installation uses the current enabled agents', () => {
|
||||
it('refuses spawn if a prepared startup intent loses its owned installer', () => {
|
||||
mkdirSync(fixture.userData, { recursive: true })
|
||||
writeFileSync(
|
||||
join(fixture.userData, 'opencode-startup-prompt-overlays'),
|
||||
'blocked fixture root'
|
||||
)
|
||||
expect(() =>
|
||||
buildPtyHostEnv(
|
||||
'owned-launch',
|
||||
{
|
||||
OPENCODE_CONFIG_DIR: custom,
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief'
|
||||
},
|
||||
{ ...options, agentStatusHooksEnabled: false }
|
||||
)
|
||||
).toThrow('launch was canceled')
|
||||
expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}')
|
||||
expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin')
|
||||
})
|
||||
|
||||
const combinations = [
|
||||
{ disabled: [], fallback: 'opencode' },
|
||||
{ disabled: ['opencode'], fallback: 'opencode2' },
|
||||
|
||||
@@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp
|
||||
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
|
||||
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
|
||||
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
|
||||
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
|
||||
|
||||
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
|
||||
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx))
|
||||
}
|
||||
|
||||
async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
|
||||
const args = ctx.args
|
||||
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
|
||||
if (ctx.nativeWindowsConptySpawn) {
|
||||
@@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
ctx.pendingRegistrationPtyId = null
|
||||
}
|
||||
publishPtyIpcSpawnCommit(ctx, committedSize)
|
||||
|
||||
// Admission must precede reflow: a replaced spawn cannot resize its successor's model.
|
||||
reflowHeadlessTerminalToCommittedGrid({
|
||||
result: ctx.result,
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function buildPtyIpcSpawnOptions(
|
||||
ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete)
|
||||
}
|
||||
deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete)
|
||||
ctx.spawnEnv = await prepareOpenCodePtyLaunch({
|
||||
const openCodeLaunch = await prepareOpenCodePtyLaunch({
|
||||
command: ctx.launchCommand,
|
||||
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
env: ctx.spawnEnv,
|
||||
@@ -77,6 +77,8 @@ export async function buildPtyIpcSpawnOptions(
|
||||
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
|
||||
: {})
|
||||
})
|
||||
ctx.spawnEnv = openCodeLaunch.env
|
||||
ctx.launchCommand = openCodeLaunch.command
|
||||
promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath)
|
||||
ctx.spawnOptions = {
|
||||
cols: args.cols,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
|
||||
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
|
||||
import {
|
||||
admitPtyReattachOwnership,
|
||||
discardUnpersistedPtySpawn,
|
||||
@@ -37,6 +38,10 @@ import {
|
||||
} from '../pane/spawn-registration'
|
||||
|
||||
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedRuntimePtySpawn(ctx))
|
||||
}
|
||||
|
||||
async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
const args = ctx.args
|
||||
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
|
||||
@@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
if (ctx.result.incarnationId) {
|
||||
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
|
||||
}
|
||||
|
||||
claimSshPaneLease({
|
||||
store: ctx.deps.store,
|
||||
connectionId: args.connectionId,
|
||||
|
||||
@@ -104,7 +104,7 @@ export async function buildRuntimePtySpawnOptions(
|
||||
env: ctx.env,
|
||||
envToDelete: ctx.spawnOptions.envToDelete
|
||||
})
|
||||
ctx.env = await prepareOpenCodePtyLaunch({
|
||||
const openCodeLaunch = await prepareOpenCodePtyLaunch({
|
||||
command: ctx.launchCommand,
|
||||
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
env: ctx.env,
|
||||
@@ -116,6 +116,8 @@ export async function buildRuntimePtySpawnOptions(
|
||||
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
|
||||
: {})
|
||||
})
|
||||
ctx.env = openCodeLaunch.env
|
||||
ctx.launchCommand = openCodeLaunch.command
|
||||
ctx.spawnOptions.env = ctx.env
|
||||
promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath)
|
||||
const noDaemonLaunch = planCodexNoDaemonLaunch({
|
||||
|
||||
@@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh
|
||||
})
|
||||
|
||||
it('repoints a managed entry left behind by a copied home or a platform switch', () => {
|
||||
// Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands
|
||||
// the exact script path — a stale entry stuck the install on `partial` forever
|
||||
// with no Orca action able to repair it.
|
||||
const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh'
|
||||
const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n`
|
||||
const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service'
|
||||
import {
|
||||
getJcodeConfigPath,
|
||||
getJcodeManagedCommand,
|
||||
getJcodeManagedScriptFileName,
|
||||
getJcodeManagedScriptPath,
|
||||
JCODE_HOOK_EVENTS
|
||||
} from './hook-settings'
|
||||
@@ -72,6 +73,123 @@ describe('JcodeHookService', () => {
|
||||
expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"')
|
||||
})
|
||||
|
||||
it('reports a missing managed script and repairs it without changing config', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
const config = readFileSync(getJcodeConfigPath(), 'utf8')
|
||||
rmSync(getJcodeManagedScriptPath())
|
||||
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
state: 'partial',
|
||||
managedHooksPresent: true,
|
||||
detail: 'Managed hook script missing'
|
||||
})
|
||||
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode')
|
||||
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
|
||||
})
|
||||
|
||||
it('reports missing scripts alongside incomplete event coverage and user hooks', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
writeFileSync(
|
||||
getJcodeConfigPath(),
|
||||
`[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`,
|
||||
'utf8'
|
||||
)
|
||||
rmSync(getJcodeManagedScriptPath())
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toContain('Managed hook script missing')
|
||||
expect(status.detail).toContain(
|
||||
'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end'
|
||||
)
|
||||
expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
|
||||
expect(service.install().state).toBe('partial')
|
||||
expect(service.getStatus().detail).not.toContain('script missing')
|
||||
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain(
|
||||
'turn_end = "~/bin/my-turn-notify"'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/Users/previous/.orca/agent-hooks/jcode-hook.sh',
|
||||
'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd'
|
||||
])('recognizes a stale managed command %s without a local script', (stalePath) => {
|
||||
const service = new JcodeHookService()
|
||||
const configPath = getJcodeConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
expect(status.detail).toContain('Managed hook command outdated for events: turn_end')
|
||||
expect(status.detail).toContain('Managed hook script missing')
|
||||
expect(status.detail).not.toContain('User-owned')
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect(readFileSync(configPath, 'utf8')).not.toContain('previous')
|
||||
})
|
||||
|
||||
it('does not report complete stale event coverage as installed when the current script exists', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh')
|
||||
writeFileSync(
|
||||
getJcodeConfigPath(),
|
||||
`[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
expect(status.detail).toBe(
|
||||
`Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}`
|
||||
)
|
||||
expect(service.install().state).toBe('installed')
|
||||
})
|
||||
|
||||
it('reports legacy unquoted commands as outdated even when the script exists', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
writeFileSync(
|
||||
getJcodeConfigPath(),
|
||||
`[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toContain('Managed hook command outdated for events:')
|
||||
expect(status.detail).not.toContain('script missing')
|
||||
expect(status.detail).not.toContain('User-owned')
|
||||
expect(service.install().state).toBe('installed')
|
||||
})
|
||||
|
||||
it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => {
|
||||
const configPath = getJcodeConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = new JcodeHookService().getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.managedHooksPresent).toBe(false)
|
||||
expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
|
||||
expect(status.detail).not.toContain('command outdated')
|
||||
expect(status.detail).not.toContain('script missing')
|
||||
})
|
||||
|
||||
it('preserves unrelated config tables when installing hooks', () => {
|
||||
const configPath = getJcodeConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
@@ -107,6 +225,8 @@ describe('JcodeHookService', () => {
|
||||
expect(before).toContain('turn_end')
|
||||
const status = new JcodeHookService().remove()
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(status.detail).toBeNull()
|
||||
expect(existsSync(getJcodeManagedScriptPath())).toBe(true)
|
||||
const after = readFileSync(getJcodeConfigPath(), 'utf8')
|
||||
expect(after).not.toContain(getJcodeManagedScriptPath())
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
buildWindowsAgentHookPostCommand,
|
||||
createManagedCommandMatcher,
|
||||
writeManagedScript
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
@@ -128,33 +129,42 @@ export class JcodeHookService {
|
||||
}
|
||||
const scriptPresent = existsSync(scriptPath)
|
||||
const managedCommand = getJcodeManagedCommand(scriptPath)
|
||||
const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName())
|
||||
const missing: string[] = []
|
||||
const outdated: string[] = []
|
||||
const userOwned: string[] = []
|
||||
let managedCount = 0
|
||||
for (const event of JCODE_HOOK_EVENTS) {
|
||||
const value = table[event]
|
||||
// Why both forms: installs before the quoting fix stored the bare path, and
|
||||
// install() repoints those — reporting them user-owned would hide the repair.
|
||||
if (value === managedCommand || value === scriptPath) {
|
||||
if (value === managedCommand) {
|
||||
managedCount += 1
|
||||
} else if (isManaged(value)) {
|
||||
outdated.push(event)
|
||||
} else if (value === undefined) {
|
||||
missing.push(event)
|
||||
} else {
|
||||
userOwned.push(event)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = managedCount > 0 || scriptPresent
|
||||
const hasManagedEntries = managedCount > 0 || outdated.length > 0
|
||||
const managedHooksPresent = hasManagedEntries || scriptPresent
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0 && userOwned.length === 0) {
|
||||
if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) {
|
||||
} else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
const parts: string[] = []
|
||||
if (hasManagedEntries && !scriptPresent) {
|
||||
parts.push('Managed hook script missing')
|
||||
}
|
||||
if (outdated.length > 0) {
|
||||
parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`)
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
parts.push(`Managed hook missing for events: ${missing.join(', ')}`)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
agentModelCatalogFingerprintForRecord
|
||||
} from './agent-model-catalog-fingerprint'
|
||||
import { createAgentModelCatalogService } from './agent-model-catalog-service'
|
||||
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
|
||||
import {
|
||||
AGENT_MODEL_CATALOG_FRESH_MS,
|
||||
AgentModelCatalogStore,
|
||||
type AgentModelCatalogSuccess
|
||||
} from './agent-model-catalog-store'
|
||||
|
||||
function record(accountHomePath: string): AgentSessionRecord {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here.
|
||||
@@ -55,7 +59,8 @@ describe('agent model catalog service', () => {
|
||||
probes: { codex: probe }
|
||||
})
|
||||
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
|
||||
origin: 'unknown'
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
// A second read while the probe is in flight must not start another, and a
|
||||
// record-scoped read probes the RECORD's pinned home, not the selection.
|
||||
@@ -81,7 +86,10 @@ describe('agent model catalog service', () => {
|
||||
probes: { codex: probe }
|
||||
})
|
||||
// The record-less read follows the CURRENT selection: unknown, never gpt-old.
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
expect(probe).toHaveBeenCalledWith('/homes/new')
|
||||
await vi.waitFor(async () => {
|
||||
const result = await service.read({ agent: 'codex' })
|
||||
@@ -139,7 +147,8 @@ describe('agent model catalog service', () => {
|
||||
probes: { codex: probe }
|
||||
})
|
||||
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
|
||||
origin: 'unknown'
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
|
||||
// Still a clean unknown — and the failure TTL suppresses a probe storm.
|
||||
@@ -164,6 +173,94 @@ describe('agent model catalog service', () => {
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('a read that waits for the first listing', () => {
|
||||
function deferredListing() {
|
||||
let resolve!: (success: AgentModelCatalogSuccess) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<AgentModelCatalogSuccess>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function coldService(probe: (home: string) => Promise<AgentModelCatalogSuccess>) {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getRecord: () => undefined,
|
||||
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
it('joins the listing the first read started and answers with it', async () => {
|
||||
const pending = deferredListing()
|
||||
const probe = vi.fn(() => pending.promise)
|
||||
const { service } = coldService(probe)
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
const waited = service.read({ agent: 'codex', waitForListing: true })
|
||||
pending.resolve(listing('gpt-listed'))
|
||||
const result = await waited
|
||||
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('answers a plain unknown when the listing fails', async () => {
|
||||
const pending = deferredListing()
|
||||
const { service } = coldService(() => pending.promise)
|
||||
const waited = service.read({ agent: 'codex', waitForListing: true })
|
||||
pending.reject(new Error('spawn failed'))
|
||||
expect(await waited).toEqual({ origin: 'unknown' })
|
||||
})
|
||||
|
||||
it('does not wait or report a listing while a failure is inside its TTL', async () => {
|
||||
const probe = vi.fn(async (): Promise<AgentModelCatalogSuccess> => {
|
||||
throw new Error('spawn failed')
|
||||
})
|
||||
const { store, service } = coldService(probe)
|
||||
store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed')
|
||||
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports no listing where the host has no lister for the account', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getRecord: () => undefined,
|
||||
resolveAccountHome: async () => CODEX_HOME('/homes/selected')
|
||||
})
|
||||
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('serves an aged entry at once and refreshes it behind the answer', async () => {
|
||||
let now = 0
|
||||
const store = new AgentModelCatalogStore({ now: () => now })
|
||||
store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old'))
|
||||
now = AGENT_MODEL_CATALOG_FRESH_MS
|
||||
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getRecord: () => undefined,
|
||||
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
const result = await service.read({ agent: 'codex', waitForListing: true })
|
||||
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a read for the workspace a new chat runs in', () => {
|
||||
function serviceWith(mayOverride: boolean) {
|
||||
const store = new AgentModelCatalogStore()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user