mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/windows-remote-worktree-tcc
# Conflicts: # src/renderer/src/store/slices/ui.ts
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Product source remains owned by its normal reviewers; localization inputs need focused review.
|
||||
/src/renderer/src/i18n/locales/ @brennanb2025
|
||||
/config/scripts/*localization*.mjs @brennanb2025
|
||||
/config/scripts/*locale*.mjs @brennanb2025
|
||||
/i18next.config.ts @brennanb2025
|
||||
@@ -6,6 +6,10 @@ inputs:
|
||||
description: Native runtime to prepare after the script-free install (none, node, or electron).
|
||||
required: false
|
||||
default: none
|
||||
node-version:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -17,11 +21,19 @@ runs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: inputs.node-version == ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup requested Node.js
|
||||
if: inputs.node-version != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
src/main/computer/computer-provider-lifecycle.test.ts
|
||||
|
||||
@@ -62,6 +62,11 @@ jobs:
|
||||
- name: Verify localization catalog
|
||||
run: pnpm run verify:localization-catalog
|
||||
|
||||
# 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
|
||||
run: pnpm run verify:localization-extraction
|
||||
|
||||
- name: Verify localization coverage
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
@@ -179,12 +184,14 @@ jobs:
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -195,6 +202,7 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
@@ -208,7 +216,7 @@ jobs:
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
package:
|
||||
name: package
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -52,11 +52,27 @@ Sync catalog keys after adding or removing `translate(...)` calls:
|
||||
pnpm run sync:localization-catalog
|
||||
```
|
||||
|
||||
The sync command adds missing `en.json` entries from each call's string fallback,
|
||||
copies untranslated English placeholders into other locale catalogs to keep
|
||||
parity, removes locale entries whose English key was deleted, and repairs
|
||||
placeholder mismatches. Run the machine-translation bootstrap commands only when
|
||||
refreshing real translations, not for ordinary UI copy changes.
|
||||
The sync command adds missing `en.json` entries from each call's string fallback.
|
||||
It never edits target catalogs: missing values remain absent and use the existing
|
||||
runtime English fallback. Existing placeholder mismatches fail validation until
|
||||
a localization PR fixes or retires the target entry.
|
||||
|
||||
Run maintained source extraction without committing a second English catalog:
|
||||
|
||||
```sh
|
||||
pnpm run verify:localization-extraction
|
||||
```
|
||||
|
||||
Extraction fails when a statically extracted key is absent from `en.json` or an
|
||||
inline default has incompatible placeholders. Existing unreferenced English keys
|
||||
and wording-only fallback drift are reported as migration debt; the permanent
|
||||
bilingual translation source will reconcile them without a large disposition
|
||||
database. Reviewed and stale translation state is likewise deferred to that
|
||||
source rather than inferred permanently from Git history.
|
||||
|
||||
The legacy free-endpoint bootstrap and whole-catalog repair scripts intentionally
|
||||
have no package-script entry points. Ordinary product and localization work must
|
||||
not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
@@ -97,8 +113,8 @@ Recommended migration order:
|
||||
The final gate should combine three checks:
|
||||
|
||||
1. Scanner coverage: no unclassified localizable candidates remain.
|
||||
2. Catalog coverage: every supported locale has the same keys as English, with
|
||||
matching interpolation variables.
|
||||
2. Catalog correctness: existing translations have matching interpolation
|
||||
variables; missing target entries are reported rather than rejected.
|
||||
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
|
||||
obvious English leftovers or layout clipping in core screens.
|
||||
|
||||
|
||||
+705
-856
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
'src/main/computer/computer-provider-lifecycle.test.ts',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('computer-use modifier safety', () => {
|
||||
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
|
||||
const mouseInput = sourceBetween(
|
||||
macOS,
|
||||
'private static func mouse(',
|
||||
'private static func keyEvent('
|
||||
)
|
||||
|
||||
expect(mouseInput).toContain('event.flags = flags')
|
||||
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
|
||||
expect(clickInput).not.toContain('down: true')
|
||||
})
|
||||
|
||||
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const modifiedClick = sourceBetween(
|
||||
windows,
|
||||
'public static void SendModifiedClick',
|
||||
'private static INPUT KeyboardInput'
|
||||
)
|
||||
const mouseClick = sourceBetween(
|
||||
windows,
|
||||
'function Send-OrcaMouseClick',
|
||||
'function Send-OrcaDrag'
|
||||
)
|
||||
|
||||
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
|
||||
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
|
||||
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
|
||||
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
|
||||
expect(modifiedClick).not.toContain('int count')
|
||||
expect(mouseClick).toMatch(
|
||||
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
|
||||
)
|
||||
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
|
||||
expect(windows).not.toContain('keybd_event')
|
||||
})
|
||||
|
||||
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
|
||||
const linux = source('native/computer-use-linux/runtime.py')
|
||||
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
|
||||
|
||||
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
|
||||
expect(modifiedClick).toContain('is_wayland')
|
||||
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
|
||||
expect(modifiedClick).toContain('finally:')
|
||||
expect(modifiedClick).toContain('check=False')
|
||||
expect(modifiedClick).toContain('timeout=5')
|
||||
expect(modifiedClick).toContain('timeout=2')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
|
||||
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
|
||||
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
|
||||
it('requires atomic modifier-click actions in the source and bundled guide', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
|
||||
expect(skill).toContain('click --modifiers <chord>')
|
||||
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use install stub', () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('localization package scripts', () => {
|
||||
const scripts = JSON.parse(readFileSync('package.json', 'utf8')).scripts
|
||||
|
||||
it('keeps safe catalog and extraction verification available', () => {
|
||||
expect(scripts['verify:localization-catalog']).toBeDefined()
|
||||
expect(scripts['sync:localization-catalog']).toBeDefined()
|
||||
expect(scripts['verify:localization-extraction']).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not expose whole-catalog translation and repair commands', () => {
|
||||
expect(scripts['bootstrap:locale-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:zh-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:ko-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:ja-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:es-catalog']).toBeUndefined()
|
||||
expect(scripts['repair:locale-catalog']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across sixteen runners', () => {
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
||||
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
|
||||
@@ -227,44 +227,6 @@ function setCatalogEntry(catalog, key, value) {
|
||||
cursor[parts.at(-1)] = value
|
||||
}
|
||||
|
||||
function deleteCatalogEntry(catalog, key) {
|
||||
const parts = key.split('.')
|
||||
const stack = []
|
||||
let cursor = catalog
|
||||
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (
|
||||
typeof cursor?.[part] !== 'object' ||
|
||||
cursor[part] === null ||
|
||||
Array.isArray(cursor[part])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
stack.push([cursor, part])
|
||||
cursor = cursor[part]
|
||||
}
|
||||
|
||||
const leafKey = parts.at(-1)
|
||||
if (!Object.hasOwn(cursor, leafKey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
delete cursor[leafKey]
|
||||
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
||||
const [parent, part] = stack[index]
|
||||
const child = parent[part]
|
||||
if (
|
||||
typeof child === 'object' &&
|
||||
child !== null &&
|
||||
!Array.isArray(child) &&
|
||||
Object.keys(child).length === 0
|
||||
) {
|
||||
delete parent[part]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
||||
const enEntries = flattenCatalogEntries(enCatalog)
|
||||
const localeEntries = flattenCatalogEntries(localeCatalog)
|
||||
@@ -286,30 +248,6 @@ function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
||||
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
|
||||
}
|
||||
|
||||
function repairLocaleParity(enCatalog, localeCatalog) {
|
||||
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
let changed = 0
|
||||
|
||||
for (const key of missingInLocale) {
|
||||
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
||||
changed += 1
|
||||
}
|
||||
|
||||
for (const key of extraInLocale) {
|
||||
if (deleteCatalogEntry(localeCatalog, key)) {
|
||||
changed += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of interpolationMismatches) {
|
||||
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
||||
changed += 1
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function referencesMissingFallbacks(missing) {
|
||||
return missing.filter((reference) => typeof reference.fallback !== 'string')
|
||||
}
|
||||
@@ -344,23 +282,18 @@ function applyMissingEnglishEntries(catalog, missing) {
|
||||
return changed
|
||||
}
|
||||
|
||||
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
|
||||
if (
|
||||
missingInLocale.length > 0 ||
|
||||
extraInLocale.length > 0 ||
|
||||
interpolationMismatches.length > 0
|
||||
) {
|
||||
console.error(`Locale catalog parity failed for ${localeName}.json.`)
|
||||
if (missingInLocale.length > 0) {
|
||||
console.error('')
|
||||
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
|
||||
if (missingInLocale.length > 20) {
|
||||
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
|
||||
}
|
||||
}
|
||||
// Why: feature PRs own English declarations; absent target leaves deliberately
|
||||
// use i18next's existing English fallback until a localization PR supplies them.
|
||||
console.log(
|
||||
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
|
||||
)
|
||||
|
||||
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
|
||||
console.error(`Locale catalog validation failed for ${localeName}.json.`)
|
||||
if (extraInLocale.length > 0) {
|
||||
console.error('')
|
||||
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
|
||||
@@ -380,7 +313,7 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
|
||||
console.log(`Verified ${localeEntries.size} existing ${localeName}.json entries.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -527,19 +460,10 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
|
||||
const localeName = fileName.replace(/\.json$/, '')
|
||||
const localeCatalogPath = path.join(localesDir, fileName)
|
||||
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
|
||||
if (options.fix) {
|
||||
const repaired = repairLocaleParity(catalog, localeCatalog)
|
||||
if (repaired > 0) {
|
||||
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
|
||||
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
|
||||
}
|
||||
}
|
||||
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
|
||||
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
|
||||
if (exitCode !== 0) {
|
||||
if (!options.fix) {
|
||||
console.error('')
|
||||
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
|
||||
}
|
||||
console.error('')
|
||||
console.error('Fix or retire the existing target entry in a localization PR.')
|
||||
return exitCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
|
||||
}
|
||||
|
||||
describe('verify-localization-catalog', () => {
|
||||
it('bootstraps missing catalog entries from string fallbacks', async () => {
|
||||
it('bootstraps English entries without fabricating target translations', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
|
||||
@@ -45,12 +45,10 @@ describe('verify-localization-catalog', () => {
|
||||
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
})
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
})
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({})
|
||||
})
|
||||
|
||||
it('repairs stale locale keys and interpolation mismatches', async () => {
|
||||
it('never overwrites mismatched translations or removes target-only entries', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
||||
@@ -63,13 +61,29 @@ describe('verify-localization-catalog', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
||||
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
auto: {
|
||||
example: { greeting: 'Hola' },
|
||||
stale: { removed: 'Viejo' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts sparse target catalogs when existing placeholders match', async () => {
|
||||
const { root } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
||||
enCatalog: {
|
||||
auto: { example: { greeting: 'Hello {{name}}', untranslated: 'English only' } }
|
||||
},
|
||||
esCatalog: { auto: { example: { greeting: 'Hola {{name}}' } } }
|
||||
})
|
||||
|
||||
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('does not invent values for keys without string fallbacks', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
|
||||
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
||||
|
||||
function flattenCatalog(value, prefix = '', entries = new Map()) {
|
||||
if (typeof value === 'string') {
|
||||
entries.set(prefix, value)
|
||||
return entries
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return entries
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
|
||||
}
|
||||
|
||||
export function compareExtraction(extractedCatalog, englishCatalog) {
|
||||
const extracted = flattenCatalog(extractedCatalog)
|
||||
const english = flattenCatalog(englishCatalog)
|
||||
const dynamicDefaults = [...extracted.entries()]
|
||||
.filter(([, value]) => value.length === 0)
|
||||
.map(([key]) => key)
|
||||
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
|
||||
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
|
||||
const fallbackDrift = []
|
||||
const placeholderMismatches = []
|
||||
|
||||
for (const [key, extractedValue] of extracted) {
|
||||
const englishValue = english.get(key)
|
||||
if (
|
||||
extractedValue.length === 0 ||
|
||||
englishValue === undefined ||
|
||||
englishValue === extractedValue
|
||||
) {
|
||||
continue
|
||||
}
|
||||
fallbackDrift.push(key)
|
||||
if (placeholders(extractedValue) !== placeholders(englishValue)) {
|
||||
placeholderMismatches.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extracted,
|
||||
dynamicDefaults,
|
||||
missingFromEnglish,
|
||||
orphans,
|
||||
fallbackDrift,
|
||||
placeholderMismatches
|
||||
}
|
||||
}
|
||||
|
||||
function printKeys(label, keys) {
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
console.error(`${label}:`)
|
||||
for (const key of keys.slice(0, 20)) {
|
||||
console.error(` ${key}`)
|
||||
}
|
||||
if (keys.length > 20) {
|
||||
console.error(` ...and ${keys.length - 20} more`)
|
||||
}
|
||||
}
|
||||
|
||||
async function extractToTemporaryCatalog(root, tempDir) {
|
||||
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
|
||||
const outputPattern = path.join(tempDir, '{{language}}.json')
|
||||
// Why: extraction output is evidence for this check, not another committed
|
||||
// catalog that feature authors must keep synchronized.
|
||||
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
}
|
||||
})
|
||||
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
|
||||
|
||||
try {
|
||||
const [extractedCatalog, englishCatalog] = await Promise.all([
|
||||
extractToTemporaryCatalog(root, tempDir),
|
||||
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
|
||||
])
|
||||
const result = compareExtraction(extractedCatalog, englishCatalog)
|
||||
|
||||
console.log(
|
||||
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ.`
|
||||
)
|
||||
|
||||
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
|
||||
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
|
||||
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compareExtraction } from './verify-localization-extraction.mjs'
|
||||
|
||||
describe('verify-localization-extraction', () => {
|
||||
it('reports legacy drift without requiring a committed disposition database', () => {
|
||||
const result = compareExtraction(
|
||||
{ menu: { open: 'Open now {{name}}' } },
|
||||
{ menu: { open: 'Open {{name}}', legacy: 'Legacy copy' } }
|
||||
)
|
||||
|
||||
expect(result.orphans).toEqual(['menu.legacy'])
|
||||
expect(result.fallbackDrift).toEqual(['menu.open'])
|
||||
expect(result.placeholderMismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('reports dynamic defaults without treating empty extractor values as catalog copy', () => {
|
||||
const result = compareExtraction(
|
||||
{ menu: { dynamic: '' } },
|
||||
{ menu: { dynamic: '{{count}} items' } }
|
||||
)
|
||||
|
||||
expect(result.dynamicDefaults).toEqual(['menu.dynamic'])
|
||||
expect(result.fallbackDrift).toEqual([])
|
||||
expect(result.placeholderMismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects undeclared keys even when their defaults are dynamic', () => {
|
||||
const result = compareExtraction({ menu: { dynamic: '' } }, {})
|
||||
|
||||
expect(result.missingFromEnglish).toEqual(['menu.dynamic'])
|
||||
})
|
||||
|
||||
it('rejects missing English declarations and incompatible placeholders', () => {
|
||||
const result = compareExtraction(
|
||||
{
|
||||
menu: {
|
||||
missing: 'Missing',
|
||||
open: 'Open {{name}}'
|
||||
}
|
||||
},
|
||||
{ menu: { open: 'Open {{path}}' } }
|
||||
)
|
||||
|
||||
expect(result.missingFromEnglish).toEqual(['menu.missing'])
|
||||
expect(result.placeholderMismatches).toEqual(['menu.open'])
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,8 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'i18next-cli'
|
||||
|
||||
const output =
|
||||
process.env.ORCA_I18N_EXTRACTION_OUTPUT ?? 'tmp/localization-extraction/{{language}}.json'
|
||||
|
||||
export default defineConfig({
|
||||
locales: ['en'],
|
||||
extract: {
|
||||
input: ['src/**/*.{js,jsx,ts,tsx,mts,cts}'],
|
||||
ignore: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/__tests__/**',
|
||||
'**/__snapshots__/**',
|
||||
'**/assets/**'
|
||||
],
|
||||
output,
|
||||
defaultNS: false,
|
||||
functions: ['t', '*.t', 'translate', 'translateMain'],
|
||||
useTranslationNames: ['useTranslation'],
|
||||
sort: true,
|
||||
disablePlurals: true,
|
||||
removeUnusedKeys: true
|
||||
}
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
|
||||
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
@@ -124,7 +125,8 @@ export function handleRequest(
|
||||
handleMockGitRequest(request, respond, success) ||
|
||||
handleMockFilePreviewRequest(request, respond, success, error) ||
|
||||
handleMockAccountRequest(request, respond, success, error) ||
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws)
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws) ||
|
||||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
|
||||
// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it
|
||||
// must stay stable for the process and change on restart like a real publisher —
|
||||
// hence a uuid, not a clock read two restarts could land on.
|
||||
// The `mobile-local:` prefix is reserved for phone-local writes — never use it.
|
||||
const PUBLICATION_EPOCH = `mock-server:${randomUUID()}`
|
||||
const GROUP_ID = 'group-1'
|
||||
const PARENT_TAB_ID = 'tab-1'
|
||||
// The host only ever publishes terminal-layout UUIDs here; pane-key parsing
|
||||
// rejects any other shape, so a placeholder would mask pane-attribution bugs.
|
||||
const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
// The host publishes terminal surfaces as `${parentTabId}::${leafId}`.
|
||||
const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}`
|
||||
|
||||
/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full
|
||||
* `session.tabs.list` contract so mock-server repros of tab, split-pane, and
|
||||
* pane-attribution bugs aren't shape-incomplete. */
|
||||
function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: PUBLICATION_EPOCH,
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: GROUP_ID,
|
||||
activeTabId: SURFACE_TAB_ID,
|
||||
activeTabType: 'terminal',
|
||||
// Groups track top-level tabs, so they carry parentTabId, not surface ids.
|
||||
tabGroups: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
activeTabId: PARENT_TAB_ID,
|
||||
tabOrder: [PARENT_TAB_ID],
|
||||
recentTabIds: [PARENT_TAB_ID]
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: SURFACE_TAB_ID,
|
||||
title: 'zsh',
|
||||
parentTabId: PARENT_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Default session-tabs backend: without it the session screen hangs on
|
||||
* 'Loading tabs'. Returns false for methods it does not own. */
|
||||
export function handleMockSessionTabsRequest(
|
||||
request: RpcRequest,
|
||||
respond: (response: RpcResponse) => void,
|
||||
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
|
||||
// Shared with `terminal.list` so both surfaces agree on which worktree an
|
||||
// absent or `id:`-prefixed selector means.
|
||||
resolveWorktreeId: (selector: unknown) => string | undefined
|
||||
): boolean {
|
||||
if (request.method !== 'session.tabs.list') {
|
||||
return false
|
||||
}
|
||||
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
|
||||
respond(success(request.id, createMockSessionTabs(worktreeId)))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import {
|
||||
handleRequest,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from '../scripts/mock-server-rpc-handlers'
|
||||
|
||||
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
|
||||
let response: RpcResponse | undefined
|
||||
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
|
||||
handleRequest(
|
||||
request,
|
||||
(nextResponse) => {
|
||||
response = nextResponse
|
||||
},
|
||||
{} as WebSocket
|
||||
)
|
||||
expect(response).toBeDefined()
|
||||
return response!
|
||||
}
|
||||
|
||||
function listSessionTabs(worktree: string): RpcResponse {
|
||||
return callRpc('session.tabs.list', { worktree })
|
||||
}
|
||||
|
||||
describe('mock server session tabs fixture', () => {
|
||||
it('returns a contract-complete terminal surface for the requested worktree', () => {
|
||||
const response = listSessionTabs('id:repo-1::worktree-1')
|
||||
|
||||
expect(response.result).toEqual({
|
||||
worktree: 'repo-1::worktree-1',
|
||||
publicationEpoch: expect.stringMatching(/^mock-server:/),
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1'],
|
||||
recentTabIds: ['tab-1']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
title: 'zsh',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a bare worktree selector through unprefixed', () => {
|
||||
const response = listSessionTabs('repo-1::worktree-1')
|
||||
|
||||
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
|
||||
})
|
||||
|
||||
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
|
||||
const terminals = callRpc('terminal.list').result as {
|
||||
terminals: { worktreeId: string }[]
|
||||
}
|
||||
const expected = terminals.terminals[0]?.worktreeId
|
||||
expect(expected).toBeTruthy()
|
||||
|
||||
const tabs = callRpc('session.tabs.list').result as { worktree: string }
|
||||
expect(tabs.worktree).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -822,19 +822,60 @@ def require_non_empty_string(value, name):
|
||||
return str(value)
|
||||
|
||||
|
||||
def click_at(x, y, button, count):
|
||||
def click_at(x, y, button, count, modifiers=None):
|
||||
button = (button or "left").lower()
|
||||
buttons = {"left": ("b1p", "b1r"), "right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}
|
||||
if button not in buttons:
|
||||
raise RuntimeError(f"unsupported mouse button: {button}")
|
||||
parsed_count = require_positive_integer(1 if count is None else count, "click_count")
|
||||
modifier_keys = click_modifier_keys(modifiers)
|
||||
if modifier_keys:
|
||||
modified_click_at(x, y, button, parsed_count, modifier_keys)
|
||||
return
|
||||
down, up = buttons[button]
|
||||
for _ in range(require_positive_integer(1 if count is None else count, "click_count")):
|
||||
for _ in range(parsed_count):
|
||||
Atspi.generate_mouse_event(round(x), round(y), "abs")
|
||||
Atspi.generate_mouse_event(round(x), round(y), down)
|
||||
time.sleep(0.03)
|
||||
Atspi.generate_mouse_event(round(x), round(y), up)
|
||||
|
||||
|
||||
def click_modifier_keys(raw):
|
||||
if raw is None:
|
||||
return []
|
||||
aliases = {
|
||||
"ctrl": "ctrl", "control": "ctrl", "cmdorctrl": "ctrl", "commandorcontrol": "ctrl",
|
||||
"shift": "shift", "alt": "alt", "option": "alt",
|
||||
"meta": "super", "super": "super", "win": "super", "cmd": "super", "command": "super",
|
||||
}
|
||||
parts = [part.strip().lower() for part in str(raw).split("+")]
|
||||
if not parts or any(not part or part not in aliases for part in parts):
|
||||
raise RuntimeError("click modifiers require modifier keys only")
|
||||
return list(dict.fromkeys(aliases[part] for part in parts))
|
||||
|
||||
|
||||
def modified_click_at(x, y, button, count, modifier_keys):
|
||||
xdotool = shutil.which("xdotool")
|
||||
is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
|
||||
if not xdotool or is_wayland:
|
||||
raise RuntimeError("modified clicks require xdotool on an X11 session")
|
||||
button_number = {"left": "1", "middle": "2", "right": "3"}[button]
|
||||
command = [xdotool, "mousemove", "--sync", str(round(x)), str(round(y))]
|
||||
for modifier in modifier_keys:
|
||||
command.extend(["keydown", modifier])
|
||||
command.extend(["click", "--repeat", str(count), "--delay", "35", button_number])
|
||||
for modifier in reversed(modifier_keys):
|
||||
command.extend(["keyup", modifier])
|
||||
try:
|
||||
subprocess.run(command, check=True, timeout=5)
|
||||
finally:
|
||||
subprocess.run(
|
||||
[xdotool, *[item for modifier in reversed(modifier_keys) for item in ("keyup", modifier)]],
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
|
||||
def scroll_at(x, y, direction, pages):
|
||||
if direction is None or str(direction).strip() == "":
|
||||
raise RuntimeError("direction is required")
|
||||
@@ -1024,12 +1065,14 @@ def run_operation(operation):
|
||||
if operation.get("click_count") is not None
|
||||
else 1
|
||||
)
|
||||
handled = operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
|
||||
has_modifiers = bool(str(operation.get("modifiers") or "").strip())
|
||||
handled = not has_modifiers and operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
|
||||
if not handled:
|
||||
click_at(
|
||||
*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node),
|
||||
operation.get("mouse_button", "left"),
|
||||
click_count,
|
||||
operation.get("modifiers"),
|
||||
)
|
||||
action = {"path": "synthetic", "actionName": None, "fallbackReason": "actionUnsupported"}
|
||||
else:
|
||||
|
||||
@@ -729,12 +729,13 @@ final class Provider {
|
||||
let snapshot = try currentSnapshot(params: params)
|
||||
let button = params["mouseButton"]?.string ?? "left"
|
||||
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
|
||||
let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string)
|
||||
// Why: agents expect a click into a target app to make the next
|
||||
// keyboard action safe, even when the click uses an AX action path.
|
||||
recoverWindow(snapshot.app)
|
||||
if let elementIndex = try optionalInteger(params, "elementIndex") {
|
||||
let record = try element(snapshot, elementIndex)
|
||||
if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
|
||||
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
|
||||
return actionMetadata(path: "accessibility", actionName: actionName)
|
||||
}
|
||||
if let point = center(record.localFrame, in: snapshot.windowBounds) {
|
||||
@@ -742,7 +743,8 @@ final class Provider {
|
||||
pid: snapshot.app.pid,
|
||||
at: point,
|
||||
button: mouseButton(button),
|
||||
count: count
|
||||
count: count,
|
||||
modifiers: modifiers
|
||||
)
|
||||
return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported")
|
||||
}
|
||||
@@ -753,7 +755,8 @@ final class Provider {
|
||||
pid: snapshot.app.pid,
|
||||
at: point,
|
||||
button: mouseButton(button),
|
||||
count: count
|
||||
count: count,
|
||||
modifiers: modifiers
|
||||
)
|
||||
return actionMetadata(path: "synthetic")
|
||||
}
|
||||
@@ -2282,14 +2285,23 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
|
||||
}
|
||||
|
||||
private enum Input {
|
||||
static func click(pid: pid_t, at point: CGPoint, button: MouseButton, count: Int) throws {
|
||||
static func click(
|
||||
pid: pid_t,
|
||||
at point: CGPoint,
|
||||
button: MouseButton,
|
||||
count: Int,
|
||||
modifiers: [KeyModifier]
|
||||
) throws {
|
||||
guard let source = CGEventSource(stateID: .combinedSessionState) else {
|
||||
throw ProviderError.coded("accessibility_error", "failed to create event source")
|
||||
}
|
||||
let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in
|
||||
result.insert(modifier.flag)
|
||||
}
|
||||
for _ in 0..<max(count, 1) {
|
||||
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, pid: pid)
|
||||
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, pid: pid)
|
||||
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, pid: pid)
|
||||
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
|
||||
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
|
||||
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,16 +2355,20 @@ private enum Input {
|
||||
static func pressKey(_ key: String, pid: pid_t) throws {
|
||||
let parsed = try KeyMap.parse(key)
|
||||
var flags = CGEventFlags()
|
||||
var pressedModifiers: [KeyModifier] = []
|
||||
defer {
|
||||
for modifier in pressedModifiers.reversed() {
|
||||
flags.remove(modifier.flag)
|
||||
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
|
||||
}
|
||||
}
|
||||
for modifier in parsed.modifiers {
|
||||
flags.insert(modifier.flag)
|
||||
try keyEvent(modifier.keyCode, down: true, flags: flags, pid: pid)
|
||||
pressedModifiers.append(modifier)
|
||||
}
|
||||
try keyEvent(parsed.keyCode, down: true, flags: flags, pid: pid)
|
||||
try keyEvent(parsed.keyCode, down: false, flags: flags, pid: pid)
|
||||
for modifier in parsed.modifiers.reversed() {
|
||||
try keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
|
||||
flags.remove(modifier.flag)
|
||||
}
|
||||
}
|
||||
|
||||
static func pasteText(_ text: String, pid: pid_t) throws {
|
||||
@@ -2377,10 +2393,18 @@ private enum Input {
|
||||
try pressKey("cmd+v", pid: pid)
|
||||
}
|
||||
|
||||
private static func mouse(_ type: CGEventType, source: CGEventSource, point: CGPoint, button: CGMouseButton, pid: pid_t) throws {
|
||||
private static func mouse(
|
||||
_ type: CGEventType,
|
||||
source: CGEventSource,
|
||||
point: CGPoint,
|
||||
button: CGMouseButton,
|
||||
flags: CGEventFlags = [],
|
||||
pid: pid_t
|
||||
) throws {
|
||||
guard let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: point, mouseButton: button) else {
|
||||
throw ProviderError.coded("accessibility_error", "failed to create mouse event")
|
||||
}
|
||||
event.flags = flags
|
||||
event.postToPid(pid)
|
||||
}
|
||||
|
||||
@@ -2497,16 +2521,9 @@ private enum KeyMap {
|
||||
var modifiers: [KeyModifier] = []
|
||||
var keyName: String?
|
||||
for part in parts {
|
||||
switch part {
|
||||
case "cmd", "command", "meta", "super", "cmdorctrl", "commandorcontrol":
|
||||
modifiers.append(KeyModifier(keyCode: 55, flag: .maskCommand))
|
||||
case "ctrl", "control":
|
||||
modifiers.append(KeyModifier(keyCode: 59, flag: .maskControl))
|
||||
case "alt", "option":
|
||||
modifiers.append(KeyModifier(keyCode: 58, flag: .maskAlternate))
|
||||
case "shift":
|
||||
modifiers.append(KeyModifier(keyCode: 56, flag: .maskShift))
|
||||
default:
|
||||
if let modifier = modifier(part) {
|
||||
modifiers.append(modifier)
|
||||
} else {
|
||||
keyName = part
|
||||
}
|
||||
}
|
||||
@@ -2516,6 +2533,38 @@ private enum KeyMap {
|
||||
return ParsedKey(keyCode: keyCode, modifiers: modifiers)
|
||||
}
|
||||
|
||||
static func parseModifiers(_ spec: String?) throws -> [KeyModifier] {
|
||||
guard let spec else {
|
||||
return []
|
||||
}
|
||||
let parts = spec.split(separator: "+", omittingEmptySubsequences: false)
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||
guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
|
||||
throw ProviderError.coded("invalid_argument", "click modifiers require modifier keys only")
|
||||
}
|
||||
return try parts.map { part in
|
||||
guard let modifier = modifier(part) else {
|
||||
throw ProviderError.coded("invalid_argument", "unsupported click modifier '\(part)'")
|
||||
}
|
||||
return modifier
|
||||
}
|
||||
}
|
||||
|
||||
private static func modifier(_ part: String) -> KeyModifier? {
|
||||
switch part {
|
||||
case "cmd", "command", "meta", "super", "win", "cmdorctrl", "commandorcontrol":
|
||||
return KeyModifier(keyCode: 55, flag: .maskCommand)
|
||||
case "ctrl", "control":
|
||||
return KeyModifier(keyCode: 59, flag: .maskControl)
|
||||
case "alt", "option":
|
||||
return KeyModifier(keyCode: 58, flag: .maskAlternate)
|
||||
case "shift":
|
||||
return KeyModifier(keyCode: 56, flag: .maskShift)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let codes: [String: CGKeyCode] = [
|
||||
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
|
||||
"b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19,
|
||||
|
||||
+28
@@ -18,4 +18,32 @@ final class AgentEntrypointSourceSafetyTests: XCTestCase {
|
||||
XCTAssertFalse(source.contains("unlink(tokenPath)"))
|
||||
XCTAssertFalse(source.contains("unlink(socketPath)"))
|
||||
}
|
||||
|
||||
func testSyntheticModifiersHaveGuaranteedReleaseAndModifiedClicksUseFlags() throws {
|
||||
let source = try agentEntrypointSource()
|
||||
|
||||
XCTAssertTrue(source.contains("var pressedModifiers: [KeyModifier] = []"))
|
||||
XCTAssertTrue(source.contains(
|
||||
"""
|
||||
defer {
|
||||
for modifier in pressedModifiers.reversed() {
|
||||
flags.remove(modifier.flag)
|
||||
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
|
||||
"""
|
||||
))
|
||||
XCTAssertTrue(source.contains("event.flags = flags\n event.postToPid(pid)"))
|
||||
}
|
||||
|
||||
private func agentEntrypointSource() throws -> String {
|
||||
let testFile = URL(fileURLWithPath: #filePath)
|
||||
let packageRoot = testFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let mainPath = packageRoot
|
||||
.appendingPathComponent("Sources")
|
||||
.appendingPathComponent("OrcaComputerUseMacOS")
|
||||
.appendingPathComponent("main.swift")
|
||||
return try String(contentsOf: mainPath, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class OrcaDesktopWin32 {
|
||||
@@ -33,6 +34,39 @@ public static class OrcaDesktopWin32 {
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT {
|
||||
public uint type;
|
||||
public INPUTUNION data;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public struct INPUTUNION {
|
||||
[FieldOffset(0)]
|
||||
public MOUSEINPUT mouse;
|
||||
[FieldOffset(0)]
|
||||
public KEYBDINPUT keyboard;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT {
|
||||
public int dx;
|
||||
public int dy;
|
||||
public uint mouseData;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public UIntPtr extraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct KEYBDINPUT {
|
||||
public ushort virtualKey;
|
||||
public ushort scanCode;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public UIntPtr extraInfo;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
|
||||
|
||||
@@ -56,6 +90,54 @@ public static class OrcaDesktopWin32 {
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint SendInput(uint count, INPUT[] inputs, int size);
|
||||
|
||||
public static void SendModifiedClick(byte[] modifiers, uint mouseDown, uint mouseUp) {
|
||||
const uint keyboardInput = 1;
|
||||
const uint mouseInput = 0;
|
||||
const uint keyUp = 0x0002;
|
||||
var inputs = new List<INPUT>();
|
||||
foreach (var modifier in modifiers) {
|
||||
inputs.Add(KeyboardInput(keyboardInput, modifier, 0));
|
||||
}
|
||||
inputs.Add(MouseInput(mouseInput, mouseDown));
|
||||
inputs.Add(MouseInput(mouseInput, mouseUp));
|
||||
for (var index = modifiers.Length - 1; index >= 0; index--) {
|
||||
inputs.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
|
||||
}
|
||||
var values = inputs.ToArray();
|
||||
var sent = SendInput((uint)values.Length, values, Marshal.SizeOf(typeof(INPUT)));
|
||||
if (sent != (uint)values.Length) {
|
||||
var releases = new List<INPUT>();
|
||||
releases.Add(MouseInput(mouseInput, mouseUp));
|
||||
for (var index = modifiers.Length - 1; index >= 0; index--) {
|
||||
releases.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
|
||||
}
|
||||
var releaseValues = releases.ToArray();
|
||||
SendInput((uint)releaseValues.Length, releaseValues, Marshal.SizeOf(typeof(INPUT)));
|
||||
throw new InvalidOperationException("SendInput did not complete the modified click");
|
||||
}
|
||||
}
|
||||
|
||||
private static INPUT KeyboardInput(uint type, byte virtualKey, uint flags) {
|
||||
return new INPUT {
|
||||
type = type,
|
||||
data = new INPUTUNION {
|
||||
keyboard = new KEYBDINPUT { virtualKey = virtualKey, flags = flags }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static INPUT MouseInput(uint type, uint flags) {
|
||||
return new INPUT {
|
||||
type = type,
|
||||
data = new INPUTUNION {
|
||||
mouse = new MOUSEINPUT { flags = flags }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
@@ -912,7 +994,7 @@ function Get-OrcaElementScreenPoint($Element) {
|
||||
$null
|
||||
}
|
||||
|
||||
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count) {
|
||||
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count, [string]$Modifiers) {
|
||||
[void][OrcaDesktopWin32]::SetForegroundWindow($WindowHandle)
|
||||
[void][OrcaDesktopWin32]::SetCursorPos($ScreenX, $ScreenY)
|
||||
$buttonName = if ([string]::IsNullOrWhiteSpace($Button)) { "left" } else { $Button.ToLowerInvariant() }
|
||||
@@ -923,10 +1005,23 @@ function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY
|
||||
default { throw "unsupported mouse button: $Button" }
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt (Get-OrcaPositiveInteger $Count "click_count"); $i++) {
|
||||
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 35
|
||||
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
|
||||
$modifierKeys = @(Get-OrcaClickModifierVirtualKeys $Modifiers)
|
||||
$clickCount = Get-OrcaPositiveInteger $Count "click_count"
|
||||
if ($modifierKeys.Count -eq 0) {
|
||||
for ($i = 0; $i -lt $clickCount; $i++) {
|
||||
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 35
|
||||
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
|
||||
}
|
||||
return
|
||||
}
|
||||
for ($i = 0; $i -lt $clickCount; $i++) {
|
||||
[OrcaDesktopWin32]::SendModifiedClick(
|
||||
[byte[]]$modifierKeys,
|
||||
[uint32]$down,
|
||||
[uint32]$up
|
||||
)
|
||||
if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,6 +1085,16 @@ function Get-OrcaModifierVirtualKey([string]$Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OrcaClickModifierVirtualKeys([string]$Modifiers) {
|
||||
if ([string]::IsNullOrWhiteSpace($Modifiers)) { return @() }
|
||||
$parts = @($Modifiers.Split("+") | ForEach-Object { $_.Trim() })
|
||||
$emptyParts = @($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($parts.Count -eq 0 -or $emptyParts.Count -gt 0) {
|
||||
throw "Click modifiers require modifier keys only"
|
||||
}
|
||||
@($parts | ForEach-Object { Get-OrcaModifierVirtualKey $_ })
|
||||
}
|
||||
|
||||
function Send-OrcaHotkey([IntPtr]$WindowHandle, [string]$KeySpec) {
|
||||
$parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" }
|
||||
@@ -1106,13 +1211,14 @@ function Invoke-OrcaOperation($Operation) {
|
||||
Restore-OrcaWindow $process
|
||||
$handledByPattern = $false
|
||||
$clickCount = Get-OrcaPositiveInteger $Operation.click_count "click_count"
|
||||
if ($null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
|
||||
$hasModifiers = -not [string]::IsNullOrWhiteSpace([string]$Operation.modifiers)
|
||||
if (-not $hasModifiers -and $null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
|
||||
$handledByPattern = Invoke-OrcaPrimaryAction $element
|
||||
}
|
||||
if (-not $handledByPattern) {
|
||||
$point = Get-OrcaElementScreenPoint $element
|
||||
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
|
||||
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount
|
||||
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount $Operation.modifiers
|
||||
$action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = "actionUnsupported" }
|
||||
} else {
|
||||
$action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null }
|
||||
|
||||
+4
-7
@@ -11,7 +11,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
|
||||
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
|
||||
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
|
||||
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
|
||||
@@ -60,12 +60,7 @@
|
||||
"verify:cli-bin": "node config/scripts/verify-cli-bin.mjs",
|
||||
"verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs",
|
||||
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
|
||||
"bootstrap:locale-catalog": "node config/scripts/bootstrap-locale-catalog.mjs",
|
||||
"bootstrap:zh-catalog": "node config/scripts/bootstrap-zh-catalog.mjs",
|
||||
"bootstrap:ko-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ko",
|
||||
"bootstrap:ja-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ja",
|
||||
"bootstrap:es-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale es",
|
||||
"repair:locale-catalog": "node config/scripts/repair-locale-catalog.mjs",
|
||||
"verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs",
|
||||
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check",
|
||||
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
|
||||
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
|
||||
@@ -111,6 +106,7 @@
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
@@ -198,6 +194,7 @@
|
||||
"happy-dom": "^20.9.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"i18next-cli": "1.65.0",
|
||||
"katex": "^0.16.45",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lowlight": "^3.3.0",
|
||||
|
||||
Generated
+483
-17
@@ -248,7 +248,7 @@ importers:
|
||||
version: 26.15.3(dmg-builder@26.15.3)
|
||||
electron-vite:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
emoji-picker-react:
|
||||
specifier: ^4.19.1
|
||||
version: 4.19.1(react@19.2.7)
|
||||
@@ -264,6 +264,9 @@ importers:
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
i18next-cli:
|
||||
specifier: 1.65.0
|
||||
version: 1.65.0(@types/node@25.9.5)(react-dom@19.2.7(react@19.2.7))(typescript@7.0.2)
|
||||
katex:
|
||||
specifier: ^0.16.45
|
||||
version: 0.16.45
|
||||
@@ -575,6 +578,12 @@ packages:
|
||||
'@chevrotain/types@11.1.2':
|
||||
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
|
||||
|
||||
'@croct/json5-parser@0.2.2':
|
||||
resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==}
|
||||
|
||||
'@croct/json@2.1.0':
|
||||
resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@@ -867,6 +876,15 @@ packages:
|
||||
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/checkbox@5.2.1':
|
||||
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.1.1':
|
||||
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -885,10 +903,100 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/editor@5.2.2':
|
||||
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/expand@5.1.1':
|
||||
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@3.0.3':
|
||||
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.7':
|
||||
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/input@5.1.2':
|
||||
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/number@4.1.1':
|
||||
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/password@5.1.1':
|
||||
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/prompts@8.5.2':
|
||||
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/rawlist@5.3.1':
|
||||
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/search@4.2.1':
|
||||
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/select@5.2.1':
|
||||
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.7':
|
||||
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -2429,6 +2537,99 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-darwin-x64@1.15.46':
|
||||
resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.46':
|
||||
resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.46':
|
||||
resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.46':
|
||||
resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.46':
|
||||
resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.46':
|
||||
resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.46':
|
||||
resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core@1.15.46':
|
||||
resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@swc/helpers': '>=0.5.17'
|
||||
peerDependenciesMeta:
|
||||
'@swc/helpers':
|
||||
optional: true
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3461,6 +3662,13 @@ packages:
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
chardet@2.2.0:
|
||||
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4316,6 +4524,10 @@ packages:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
glob@13.0.6:
|
||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
@@ -4473,6 +4685,15 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next-cli@1.65.0:
|
||||
resolution: {integrity: sha512-sak+2Ry4P7wtl7xMAZg2sWG2vup1lRHFBKA7h5IeEqFUog51QEgeYUhHxd2x85+MvS4BhVOZZs833clnd1WgYA==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
i18next-resources-for-ts@2.1.0:
|
||||
resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
|
||||
hasBin: true
|
||||
|
||||
i18next@26.3.1:
|
||||
resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
|
||||
peerDependencies:
|
||||
@@ -4515,6 +4736,15 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
inquirer@14.0.2:
|
||||
resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
inspect-webkit@0.0.5:
|
||||
resolution: {integrity: sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==}
|
||||
hasBin: true
|
||||
@@ -5390,6 +5620,10 @@ packages:
|
||||
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
path-scurry@2.0.2:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
|
||||
@@ -5693,6 +5927,10 @@ packages:
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readdirp@5.0.0:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
recast@0.23.11:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -5856,6 +6094,10 @@ packages:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
run-async@4.0.6:
|
||||
resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -6543,6 +6785,11 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6819,6 +7066,12 @@ snapshots:
|
||||
|
||||
'@chevrotain/types@11.1.2': {}
|
||||
|
||||
'@croct/json5-parser@0.2.2':
|
||||
dependencies:
|
||||
'@croct/json': 2.1.0
|
||||
|
||||
'@croct/json@2.1.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -7094,8 +7347,16 @@ snapshots:
|
||||
'@iconify/types': 2.0.0
|
||||
mlly: 1.8.2
|
||||
|
||||
'@inquirer/ansi@2.0.7':
|
||||
optional: true
|
||||
'@inquirer/ansi@2.0.7': {}
|
||||
|
||||
'@inquirer/checkbox@5.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/confirm@6.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
@@ -7103,7 +7364,6 @@ snapshots:
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
@@ -7116,15 +7376,95 @@ snapshots:
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.7':
|
||||
optional: true
|
||||
'@inquirer/editor@5.2.2(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/external-editor': 3.0.3(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/expand@5.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/external-editor@3.0.3(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
chardet: 2.2.0
|
||||
iconv-lite: 0.7.2
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/figures@2.0.7': {}
|
||||
|
||||
'@inquirer/input@5.1.2(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/number@4.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/password@5.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/prompts@8.5.2(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/checkbox': 5.2.1(@types/node@25.9.5)
|
||||
'@inquirer/confirm': 6.1.1(@types/node@25.9.5)
|
||||
'@inquirer/editor': 5.2.2(@types/node@25.9.5)
|
||||
'@inquirer/expand': 5.1.1(@types/node@25.9.5)
|
||||
'@inquirer/input': 5.1.2(@types/node@25.9.5)
|
||||
'@inquirer/number': 4.1.1(@types/node@25.9.5)
|
||||
'@inquirer/password': 5.1.1(@types/node@25.9.5)
|
||||
'@inquirer/rawlist': 5.3.1(@types/node@25.9.5)
|
||||
'@inquirer/search': 4.2.1(@types/node@25.9.5)
|
||||
'@inquirer/select': 5.2.1(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/rawlist@5.3.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/search@4.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/select@5.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/type@4.0.7(@types/node@25.9.5)':
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
optional: true
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
@@ -8446,6 +8786,66 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-darwin-x64@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.46':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.27
|
||||
optionalDependencies:
|
||||
'@swc/core-darwin-arm64': 1.15.46
|
||||
'@swc/core-darwin-x64': 1.15.46
|
||||
'@swc/core-linux-arm-gnueabihf': 1.15.46
|
||||
'@swc/core-linux-arm64-gnu': 1.15.46
|
||||
'@swc/core-linux-arm64-musl': 1.15.46
|
||||
'@swc/core-linux-ppc64-gnu': 1.15.46
|
||||
'@swc/core-linux-s390x-gnu': 1.15.46
|
||||
'@swc/core-linux-x64-gnu': 1.15.46
|
||||
'@swc/core-linux-x64-musl': 1.15.46
|
||||
'@swc/core-win32-arm64-msvc': 1.15.46
|
||||
'@swc/core-win32-ia32-msvc': 1.15.46
|
||||
'@swc/core-win32-x64-msvc': 1.15.46
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
@@ -9487,6 +9887,12 @@ snapshots:
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
chardet@2.2.0: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
chromium-pickle-js@0.2.0: {}
|
||||
@@ -9512,8 +9918,7 @@ snapshots:
|
||||
slice-ansi: 8.0.0
|
||||
string-width: 8.2.1
|
||||
|
||||
cli-width@4.1.0:
|
||||
optional: true
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
@@ -10002,7 +10407,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron-vite@5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)):
|
||||
electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -10011,6 +10416,8 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
picocolors: 1.1.1
|
||||
vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.15.46
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10252,20 +10659,17 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
optional: true
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 3.0.3
|
||||
optional: true
|
||||
|
||||
fast-uri@3.1.4: {}
|
||||
|
||||
fast-wrap-ansi@0.2.2:
|
||||
dependencies:
|
||||
fast-string-width: 3.0.2
|
||||
optional: true
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
@@ -10417,6 +10821,12 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob@13.0.6:
|
||||
dependencies:
|
||||
minimatch: 10.2.5
|
||||
minipass: 7.1.3
|
||||
path-scurry: 2.0.2
|
||||
|
||||
glob@7.2.3:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
@@ -10672,6 +11082,41 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next-cli@1.65.0(@types/node@25.9.5)(react-dom@19.2.7(react@19.2.7))(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@croct/json5-parser': 0.2.2
|
||||
'@swc/core': 1.15.46
|
||||
chokidar: 5.0.0
|
||||
commander: 14.0.3
|
||||
execa: 9.6.1
|
||||
glob: 13.0.6
|
||||
i18next: 26.3.1(typescript@7.0.2)
|
||||
i18next-resources-for-ts: 2.1.0
|
||||
inquirer: 14.0.2(@types/node@25.9.5)
|
||||
jiti: 2.7.0
|
||||
jsonc-parser: 3.3.1
|
||||
magic-string: 0.30.21
|
||||
minimatch: 10.2.5
|
||||
ora: 9.4.0
|
||||
react: 19.2.7
|
||||
react-i18next: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
- '@types/node'
|
||||
- react-dom
|
||||
- react-native
|
||||
- typescript
|
||||
|
||||
i18next-resources-for-ts@2.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@swc/core': 1.15.46
|
||||
chokidar: 5.0.0
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
i18next@26.3.1(typescript@7.0.2):
|
||||
optionalDependencies:
|
||||
typescript: 7.0.2
|
||||
@@ -10704,6 +11149,17 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
inquirer@14.0.2(@types/node@25.9.5):
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/prompts': 8.5.2(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
mute-stream: 3.0.0
|
||||
run-async: 4.0.6
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
inspect-webkit@0.0.5(typescript@7.0.2):
|
||||
dependencies:
|
||||
typescript: 7.0.2
|
||||
@@ -11517,8 +11973,7 @@ snapshots:
|
||||
- '@types/node'
|
||||
optional: true
|
||||
|
||||
mute-stream@3.0.0:
|
||||
optional: true
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nan@2.26.2:
|
||||
optional: true
|
||||
@@ -11794,6 +12249,11 @@ snapshots:
|
||||
|
||||
path-key@4.0.0: {}
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.5.1
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
optional: true
|
||||
|
||||
@@ -12177,6 +12637,8 @@ snapshots:
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
recast@0.23.11:
|
||||
dependencies:
|
||||
ast-types: 0.16.1
|
||||
@@ -12396,6 +12858,8 @@ snapshots:
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-async@4.0.6: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -13082,6 +13546,8 @@ snapshots:
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
|
||||
@@ -69,6 +69,7 @@ ORCA computer get-app-state --app <app> --json
|
||||
ORCA computer get-app-state --app <app> --restore-window --json
|
||||
ORCA computer click --app <app> --element-index <index> --json
|
||||
ORCA computer click --app <app> --x 100 --y 100 --json
|
||||
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
|
||||
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
|
||||
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
|
||||
ORCA computer type-text --app <app> --text "text" --json
|
||||
@@ -95,6 +96,7 @@ printf '%s' "$TEXT" | ORCA computer set-value --app <app> --element-index <index
|
||||
- After any UI-changing action, use the returned state or rerun `get-app-state` before choosing the next element index.
|
||||
- Use `type-text` only after focusing a field and confirming the app has a focused text receiver; synthetic keyboard delivery is reported as unverified, so inspect the returned state before assuming text landed.
|
||||
- Use `press-key` for single/navigation keys such as Return, Escape, Tab, and arrows. Use `hotkey` only for one modifier chord plus one key, such as `CmdOrCtrl+A` or `CmdOrCtrl+Shift+P`; prefer `CmdOrCtrl+...` for cross-platform combos.
|
||||
- Use `click --modifiers <chord>` for modifier-clicks. Never synthesize separate modifier-down and modifier-up commands around a click; interruption can leave a modifier logically held.
|
||||
- Some actions work in background apps, but this is app-dependent. If success does not change the UI, refresh state and choose a more semantic action or restore/focus the window.
|
||||
- Prefer `set-value` for text fields that expose values; it can report verified value writes when the provider can read the refreshed value.
|
||||
- Coordinates are window-local; use coordinates from the latest screenshot/state for the same target window.
|
||||
|
||||
@@ -143,12 +143,12 @@ ORCA worktree create --name task --setup skip --json
|
||||
ORCA worktree create --name task --run-hooks --json
|
||||
```
|
||||
|
||||
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: *"`--agent` launches the selected agent in the first terminal"*); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
|
||||
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: _"`--agent` launches the selected agent in the first terminal"_); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
|
||||
- **Prefer agent-first create for agent workers.** `orca worktree create --agent <id> --prompt "..."` puts the agent in the worktree's first terminal without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Without configured default tabs, the bare-create fallback shell plus a later `terminal create --command <agent>` is an anti-pattern for ordinary agent worktrees — use `--agent` instead of “create worktree, then open agent.” Configured default tabs are intentional surfaces; never treat one as disposable without verifying that it is an unused shell.
|
||||
- After create, use exactly one agent handle: `startupTerminal.handle` from the create response when present, or the matching result from `orca terminal list --worktree id:<repoId>::<newWorktreePath> --json` (or `name:<displayName>`) when the response omits it. If a handle later returns `terminal_handle_stale`, re-list it; never dual-send to old and replacement handles.
|
||||
- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.
|
||||
- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.
|
||||
- `--agent`, `--activate`, and `--run-hooks` reveal the new worktree. Plain create stays in the background.
|
||||
- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.
|
||||
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals when `--agent` already owns the first tab.
|
||||
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "<requested-agent>"` and `orca terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.
|
||||
- `worktree create` creates a new checkout. For a fresh agent in the **current** checkout (no new worktree), use `orca terminal create --worktree active --command "codex" --json` — that path does not create a second worktree shell.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
computerUseClickModifiersValidationMessage,
|
||||
computerUseHotkeyValidationMessage,
|
||||
computerUsePressKeyValidationMessage
|
||||
} from '../../shared/computer-use-key-spec'
|
||||
@@ -60,16 +61,26 @@ export function getComputerClickActionFlags(flags: Map<string, string | boolean>
|
||||
y?: number
|
||||
clickCount?: number
|
||||
mouseButton?: string
|
||||
modifiers?: string
|
||||
} {
|
||||
const rawModifiers = flags.get('modifiers')
|
||||
const modifiers = typeof rawModifiers === 'string' ? rawModifiers : undefined
|
||||
const result = {
|
||||
elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'),
|
||||
x: getOptionalNumberFlag(flags, 'x'),
|
||||
y: getOptionalNumberFlag(flags, 'y'),
|
||||
clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'),
|
||||
mouseButton: getOptionalStringFlag(flags, 'mouse-button')
|
||||
mouseButton: getOptionalStringFlag(flags, 'mouse-button'),
|
||||
modifiers
|
||||
}
|
||||
validateElementOrCoordinates('Click', result.elementIndex, result.x, result.y)
|
||||
validateMouseButton(result.mouseButton)
|
||||
if (modifiers !== undefined) {
|
||||
const message = computerUseClickModifiersValidationMessage(modifiers)
|
||||
if (message) {
|
||||
throw new RuntimeClientError('invalid_argument', message)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ describe('orca computer action CLI routing', () => {
|
||||
'Finder',
|
||||
'--element-index',
|
||||
'3',
|
||||
'--modifiers',
|
||||
'CmdOrCtrl+Shift',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
@@ -73,6 +75,7 @@ describe('orca computer action CLI routing', () => {
|
||||
y: undefined,
|
||||
clickCount: undefined,
|
||||
mouseButton: undefined,
|
||||
modifiers: 'CmdOrCtrl+Shift',
|
||||
noScreenshot: undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,6 +289,43 @@ describe('orca computer action CLI validation', () => {
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = undefined
|
||||
|
||||
await main(
|
||||
[
|
||||
'computer',
|
||||
'click',
|
||||
'--app',
|
||||
'Finder',
|
||||
'--element-index',
|
||||
'1',
|
||||
'--modifiers',
|
||||
'CmdOrCtrl+A'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'Click modifiers accept modifier keys only'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = undefined
|
||||
|
||||
await main(
|
||||
['computer', 'click', '--app', 'Finder', '--element-index', '1', '--modifiers', ''],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'Click modifiers accept modifier keys only'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = undefined
|
||||
|
||||
await main(
|
||||
['computer', 'scroll', '--app', 'Finder', '--element-index', '1', '--direction', 'diagonal'],
|
||||
'/tmp/repo/src'
|
||||
|
||||
@@ -72,6 +72,14 @@ describe('orca computer observation CLI handlers', () => {
|
||||
expect(pressKeyOutput).toContain('Single key, e.g. Return, Escape, Tab, Left, or PageUp')
|
||||
})
|
||||
|
||||
it('documents atomic modified clicks', async () => {
|
||||
await main(['computer', 'click', '--help'], '/tmp/repo')
|
||||
|
||||
const output = vi.mocked(console.log).mock.calls[0][0]
|
||||
expect(output).toContain('--modifiers <chord>')
|
||||
expect(output).toContain('Modifier keys held only for this click')
|
||||
})
|
||||
|
||||
it('passes list-apps through without resolving a worktree', async () => {
|
||||
queueFixtures(callMock, okFixture('req_apps', { apps: [] }))
|
||||
|
||||
|
||||
@@ -237,8 +237,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
...linearIssueLink,
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
runHooks: flags.get('run-hooks') === true,
|
||||
activate:
|
||||
flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(startupAgent),
|
||||
activate: flags.get('activate') === true || flags.get('run-hooks') === true,
|
||||
...(setupDecision ? { setupDecision } : {}),
|
||||
parentWorktree: explicitParentWorktree,
|
||||
...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}),
|
||||
|
||||
@@ -523,6 +523,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
limit: '--limit <n> Maximum number of rows to return',
|
||||
mode: '--mode <mode> Mode such as edit, diff, or both',
|
||||
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
|
||||
modifiers: '--modifiers <chord> Modifier keys held only for this click',
|
||||
name: '--name <name> Name for the new worktree or automation',
|
||||
'no-parent': '--no-parent Force no parent lineage for unrelated work',
|
||||
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
|
||||
|
||||
@@ -2826,7 +2826,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes agent prompt and setup policy through worktree.create', async () => {
|
||||
it('starts an agent worktree in the background unless activation is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
@@ -2864,7 +2864,7 @@ describe('orca cli worktree awareness', () => {
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
runHooks: false,
|
||||
activate: true,
|
||||
activate: false,
|
||||
setupDecision: 'run',
|
||||
parentWorktree: undefined,
|
||||
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
||||
@@ -2876,7 +2876,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('infers the repo from the current worktree on worktree.create', async () => {
|
||||
it('infers the repo and honors explicit activation on worktree.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
@@ -2898,6 +2898,7 @@ describe('orca cli worktree awareness', () => {
|
||||
'codex',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--activate',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
|
||||
@@ -44,16 +44,17 @@ export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['computer', 'click'],
|
||||
summary: 'Click an app element or window coordinate',
|
||||
summary: 'Click an app element or window coordinate, optionally with modifiers',
|
||||
usage:
|
||||
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--restore-window] [--no-screenshot] [--json]',
|
||||
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--modifiers <modifier-chord>] [--restore-window] [--no-screenshot] [--json]',
|
||||
allowedFlags: [
|
||||
...COMPUTER_ACTION_FLAGS,
|
||||
'element-index',
|
||||
'x',
|
||||
'y',
|
||||
'click-count',
|
||||
'mouse-button'
|
||||
'mouse-button',
|
||||
'modifiers'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,14 +16,20 @@ type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.f
|
||||
|
||||
function fakeChild(): FakeChild {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stdout: EventEmitter & {
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
}
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn> }
|
||||
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stdout = Object.assign(new EventEmitter(), {
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn()
|
||||
})
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = { write: vi.fn(() => true) }
|
||||
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
|
||||
child.kill = vi.fn()
|
||||
return child as unknown as FakeChild
|
||||
}
|
||||
@@ -62,6 +68,10 @@ describe('waitForWslRelaySentinel', () => {
|
||||
const transport = await promise
|
||||
expect(typeof transport.write).toBe('function')
|
||||
expect(typeof transport.onData).toBe('function')
|
||||
transport.pauseReads?.()
|
||||
transport.resumeReads?.()
|
||||
expect(child.stdout.pause).toHaveBeenCalledOnce()
|
||||
expect(child.stdout.resume).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
|
||||
@@ -149,4 +159,25 @@ describe('waitForWslRelaySentinel', () => {
|
||||
expect(err.message).toContain('E_FAIL')
|
||||
expect(err.message).not.toContain(String.fromCharCode(0))
|
||||
})
|
||||
|
||||
it('forwards write(false), callback settlement, and drain from WSL stdin', async () => {
|
||||
const child = fakeChild()
|
||||
const callback = vi.fn()
|
||||
const drain = vi.fn()
|
||||
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
|
||||
writeMock.mockImplementation((_data, onWritten) => {
|
||||
onWritten(null)
|
||||
return false
|
||||
})
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
emitStdout(child, RELAY_SENTINEL)
|
||||
const transport = await promise
|
||||
|
||||
transport.onDrain?.(drain)
|
||||
expect(transport.write(Buffer.from('frame'), callback)).toBe(false)
|
||||
expect(callback).toHaveBeenCalledWith({ ok: true })
|
||||
child.stdin.emit('drain')
|
||||
expect(drain).toHaveBeenCalledOnce()
|
||||
expect(transport.supportsWriteSettlement).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,12 +123,15 @@ export function waitForWslRelaySentinel(
|
||||
pendingChunks.push(trailing)
|
||||
}
|
||||
const transport: MultiplexerTransport = {
|
||||
write: (data) => {
|
||||
try {
|
||||
child.stdin.write(data)
|
||||
} catch {
|
||||
// Channel already closing — mux close handling takes over.
|
||||
}
|
||||
write: (data, onSettled) => {
|
||||
return child.stdin.write(data, (error?: Error | null) => {
|
||||
onSettled?.(error ? { ok: false, error } : { ok: true })
|
||||
})
|
||||
},
|
||||
supportsWriteSettlement: true,
|
||||
onDrain: (cb) => {
|
||||
child.stdin.on('drain', cb)
|
||||
return () => child.stdin.off('drain', cb)
|
||||
},
|
||||
onData: (cb) => {
|
||||
dataCallbacks.push(cb)
|
||||
@@ -143,6 +146,8 @@ export function waitForWslRelaySentinel(
|
||||
}
|
||||
},
|
||||
onClose: (cb) => closeCallbacks.push(cb),
|
||||
pauseReads: () => child.stdout.pause(),
|
||||
resumeReads: () => child.stdout.resume(),
|
||||
close: () => child.kill()
|
||||
}
|
||||
resolve(transport)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const processMocks = vi.hoisted(() => ({
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
spawn: processMocks.spawn
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => 'C:\\orca-review-11407' }
|
||||
}))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveClaudeCommand: () => 'claude.exe'
|
||||
}))
|
||||
|
||||
vi.mock('./keychain', () => ({
|
||||
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
deleteManagedClaudeKeychainCredentials: vi.fn(),
|
||||
readActiveClaudeKeychainCredentials: vi.fn(),
|
||||
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
readManagedClaudeKeychainCredentials: vi.fn(),
|
||||
writeActiveClaudeKeychainCredentials: vi.fn(),
|
||||
writeManagedClaudeKeychainCredentials: vi.fn()
|
||||
}))
|
||||
|
||||
type CommandConfig = {
|
||||
windowsPath: string
|
||||
linuxPath: string | null
|
||||
wslDistro: string | null
|
||||
}
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pid: number
|
||||
}
|
||||
|
||||
type CommandRunner = {
|
||||
runClaudeCommand(
|
||||
args: string[],
|
||||
config: CommandConfig,
|
||||
timeoutMs: number,
|
||||
options?: { keepStdinOpen?: boolean; signal?: AbortSignal }
|
||||
): Promise<string>
|
||||
}
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
function createChild(): FakeChild {
|
||||
const child = new EventEmitter() as FakeChild
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
child.pid = 11407
|
||||
return child
|
||||
}
|
||||
|
||||
async function createRunner(): Promise<CommandRunner> {
|
||||
const { ClaudeAccountService } = await import('./service')
|
||||
return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner
|
||||
}
|
||||
|
||||
async function flushPromiseCallbacks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('native Windows Claude login completion oracle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
processMocks.spawn.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => {
|
||||
setPlatform('win32')
|
||||
const child = createChild()
|
||||
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
|
||||
const destroyStdout = vi.spyOn(child.stdout, 'destroy')
|
||||
const destroyStderr = vi.spyOn(child.stderr, 'destroy')
|
||||
const abortController = new AbortController()
|
||||
const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener')
|
||||
processMocks.spawn.mockReturnValue(child)
|
||||
const runner = await createRunner()
|
||||
let completions = 0
|
||||
const command = runner
|
||||
.runClaudeCommand(
|
||||
['auth', 'login', '--claudeai'],
|
||||
{ windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null },
|
||||
1000,
|
||||
{ keepStdinOpen: true, signal: abortController.signal }
|
||||
)
|
||||
.then(() => {
|
||||
completions += 1
|
||||
})
|
||||
|
||||
child.emit('exit', 0)
|
||||
await flushPromiseCallbacks()
|
||||
|
||||
try {
|
||||
expect(completions).toBe(1)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStdout).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStderr).toHaveBeenCalledTimes(1)
|
||||
expect(removeAbortListener).toHaveBeenCalledTimes(1)
|
||||
|
||||
child.emit('close', 0)
|
||||
await vi.advanceTimersByTimeAsync(1001)
|
||||
|
||||
expect(completions).toBe(1)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStdout).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStderr).toHaveBeenCalledTimes(1)
|
||||
expect(removeAbortListener).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
child.emit('close', 0)
|
||||
await command
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-Windows auth login',
|
||||
platform: 'linux' as const,
|
||||
args: ['auth', 'login', '--claudeai'],
|
||||
config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'unrelated native Windows command containing login',
|
||||
platform: 'win32' as const,
|
||||
args: ['config', 'login'],
|
||||
config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'WSL auth login',
|
||||
platform: 'win32' as const,
|
||||
args: ['auth', 'login', '--claudeai'],
|
||||
config: {
|
||||
windowsPath: 'C:\\isolated-auth',
|
||||
linuxPath: '/home/orca/isolated-auth',
|
||||
wslDistro: 'Ubuntu-Oracle'
|
||||
}
|
||||
}
|
||||
])('preserves close completion for $name', async ({ platform, args, config }) => {
|
||||
setPlatform(platform)
|
||||
const child = createChild()
|
||||
processMocks.spawn.mockReturnValue(child)
|
||||
const runner = await createRunner()
|
||||
let completions = 0
|
||||
const command = runner.runClaudeCommand(args, config, 1000).then(() => {
|
||||
completions += 1
|
||||
})
|
||||
|
||||
child.emit('exit', 0)
|
||||
await flushPromiseCallbacks()
|
||||
|
||||
expect(completions).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(1)
|
||||
|
||||
child.emit('close', 0)
|
||||
await command
|
||||
|
||||
expect(completions).toBe(1)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -967,6 +967,13 @@ export class ClaudeAccountService {
|
||||
rejectPromise(new Error('Claude command failed to open output streams.'))
|
||||
return
|
||||
}
|
||||
const completesOnExit =
|
||||
process.platform === 'win32' &&
|
||||
configDir.linuxPath === null &&
|
||||
configDir.wslDistro === null &&
|
||||
args[0] === 'auth' &&
|
||||
args[1] === 'login'
|
||||
const completionEvent = completesOnExit ? 'exit' : 'close'
|
||||
|
||||
let settled = false
|
||||
let output = ''
|
||||
@@ -990,11 +997,15 @@ export class ClaudeAccountService {
|
||||
stdout.off('data', appendOutput)
|
||||
stderr.off('data', appendOutput)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
child.off(completionEvent, onDone)
|
||||
options?.signal?.removeEventListener('abort', onAbort)
|
||||
if (options?.keepStdinOpen) {
|
||||
child.stdin?.destroy()
|
||||
}
|
||||
if (completesOnExit) {
|
||||
stdout.destroy()
|
||||
stderr.destroy()
|
||||
}
|
||||
}
|
||||
const settle = (callback: () => void): void => {
|
||||
if (settled) {
|
||||
@@ -1062,7 +1073,7 @@ export class ClaudeAccountService {
|
||||
}
|
||||
settle(() => rejectPromise(error))
|
||||
}
|
||||
const onClose = (code: number | null): void => {
|
||||
const onDone = (code: number | null): void => {
|
||||
if (terminationPending) {
|
||||
return
|
||||
}
|
||||
@@ -1085,7 +1096,9 @@ export class ClaudeAccountService {
|
||||
stdout.on('data', appendOutput)
|
||||
stderr.on('data', appendOutput)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
// Native Windows browsers can inherit these pipes and indefinitely delay close.
|
||||
child.on(completionEvent, onDone)
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort()
|
||||
} else {
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import type * as Scanner from './scanner'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
|
||||
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
|
||||
// every open would hide a regression back to multiple full-cache rewrites per scan.
|
||||
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
|
||||
writeGate: {
|
||||
blocked: false,
|
||||
waiters: [] as (() => void)[]
|
||||
}
|
||||
}))
|
||||
|
||||
import { ClaudeUsageStore } from './store'
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
if (args[1] !== 'w') {
|
||||
return actual.open(...args)
|
||||
}
|
||||
writeOpens.value += 1
|
||||
writeOpens.inFlight += 1
|
||||
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
|
||||
try {
|
||||
if (writeGate.blocked) {
|
||||
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
|
||||
}
|
||||
return await actual.open(...args)
|
||||
} finally {
|
||||
writeOpens.inFlight -= 1
|
||||
}
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./scanner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Scanner>()),
|
||||
scanClaudeUsageFiles: vi.fn()
|
||||
}))
|
||||
|
||||
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
|
||||
import { scanClaudeUsageFiles } from './scanner'
|
||||
|
||||
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
|
||||
const store = new ClaudeUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
|
||||
@@ -34,11 +80,32 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): Claude
|
||||
}
|
||||
|
||||
describe('ClaudeUsageStore', () => {
|
||||
let tempUserData: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initClaudeUsagePath()
|
||||
writeOpens.value = 0
|
||||
writeOpens.inFlight = 0
|
||||
writeOpens.maxConcurrent = 0
|
||||
writeGate.blocked = false
|
||||
writeGate.waiters = []
|
||||
vi.mocked(scanClaudeUsageFiles).mockReset()
|
||||
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-09T12:00:00.000-04:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
@@ -549,4 +616,96 @@ describe('ClaudeUsageStore', () => {
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('persists setEnabled via async durable write without leaving tmp files', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.setEnabled(true)
|
||||
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
|
||||
)
|
||||
expect(persisted.scanState.enabled).toBe(true)
|
||||
// Pretty-print preserved for human inspection of the analytics cache.
|
||||
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
|
||||
})
|
||||
|
||||
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
const internals = store as unknown as {
|
||||
writeToDisk: () => Promise<void>
|
||||
state: ClaudeUsagePersistedState
|
||||
}
|
||||
|
||||
writeGate.blocked = true
|
||||
const first = internals.writeToDisk()
|
||||
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
|
||||
|
||||
internals.state.scanState.enabled = false
|
||||
writeGate.blocked = false
|
||||
const second = internals.writeToDisk()
|
||||
writeGate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
.enabled
|
||||
).toBe(false)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
|
||||
expect(writeOpens.maxConcurrent).toBe(1)
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one full-cache write', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
).toMatchObject({
|
||||
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
})
|
||||
|
||||
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
|
||||
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
|
||||
writeFileSync(orphan, '{}')
|
||||
|
||||
createStoreWithState({})
|
||||
await vi.waitFor(() =>
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
|
||||
import type {
|
||||
ClaudeUsageBreakdownKind,
|
||||
ClaudeUsageBreakdownRow,
|
||||
@@ -331,6 +332,9 @@ export class ClaudeUsageStore {
|
||||
private state: ClaudeUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
|
||||
// and vetoes superseded renames.
|
||||
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
@@ -375,23 +379,19 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): void {
|
||||
const usageFile = getClaudeUsageFile()
|
||||
const dir = dirname(usageFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
// Why: scans can refresh while the app is in active use. Use the same
|
||||
// atomic temp-file pattern as the main store so a crash or concurrent write
|
||||
// cannot leave a truncated analytics file as the common failure mode.
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
private writeToDisk(): Promise<void> {
|
||||
// Pretty-print preserved: humans inspect this analytics cache on disk.
|
||||
return this.writer.write(() => JSON.stringify(this.state, null, 2))
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
flush(): Promise<void> {
|
||||
return this.writer.flush()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
@@ -441,8 +441,11 @@ export class ClaudeUsageStore {
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
|
||||
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
|
||||
// result changed. The completion/failure write below persists the same fields.
|
||||
|
||||
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
const repos = this.store.getRepos()
|
||||
@@ -458,10 +461,12 @@ export class ClaudeUsageStore {
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
|
||||
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as Fs from 'node:fs'
|
||||
import type {
|
||||
CodexUsageDailyAggregate,
|
||||
CodexUsagePersistedFile,
|
||||
@@ -11,8 +11,15 @@ import type {
|
||||
CodexUsageSession
|
||||
} from './types'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
|
||||
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
|
||||
// every open would hide a regression back to multiple full-cache rewrites per scan.
|
||||
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
|
||||
writeGate: {
|
||||
blocked: false,
|
||||
waiters: [] as (() => void)[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -21,11 +28,26 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof Fs>('fs')
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
writeFileSync: vi.fn(actual.writeFileSync)
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
if (args[1] !== 'w') {
|
||||
return actual.open(...args)
|
||||
}
|
||||
writeOpens.value += 1
|
||||
writeOpens.inFlight += 1
|
||||
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
|
||||
try {
|
||||
if (writeGate.blocked) {
|
||||
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
|
||||
}
|
||||
return await actual.open(...args)
|
||||
} finally {
|
||||
writeOpens.inFlight -= 1
|
||||
}
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
@@ -97,7 +119,11 @@ describe('CodexUsageStore', () => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initCodexUsagePath()
|
||||
vi.mocked(writeFileSync).mockClear()
|
||||
writeOpens.value = 0
|
||||
writeOpens.inFlight = 0
|
||||
writeOpens.maxConcurrent = 0
|
||||
writeGate.blocked = false
|
||||
writeGate.waiters = []
|
||||
vi.mocked(scanCodexUsageFiles).mockReset()
|
||||
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
|
||||
vi.useFakeTimers()
|
||||
@@ -109,7 +135,7 @@ describe('CodexUsageStore', () => {
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one compact disk write', async () => {
|
||||
it('persists a successful refresh with one compact async disk write', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
@@ -122,7 +148,9 @@ describe('CodexUsageStore', () => {
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledTimes(1)
|
||||
// Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards.
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
|
||||
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
|
||||
expect(persistedJson).not.toContain('\n')
|
||||
@@ -155,13 +183,57 @@ describe('CodexUsageStore', () => {
|
||||
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
expect(writeOpens.value).toBe(0)
|
||||
|
||||
pendingScan.resolve(createEmptyScanResult())
|
||||
await refreshPromise
|
||||
|
||||
expect(store.getScanState().isScanning).toBe(false)
|
||||
expect(writeFileSync).toHaveBeenCalledTimes(1)
|
||||
expect(writeOpens.value).toBe(1)
|
||||
})
|
||||
|
||||
it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
const internals = store as unknown as {
|
||||
writeToDisk: () => Promise<void>
|
||||
state: CodexUsagePersistedState
|
||||
}
|
||||
|
||||
writeGate.blocked = true
|
||||
const first = internals.writeToDisk()
|
||||
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
|
||||
|
||||
internals.state.scanState.enabled = false
|
||||
writeGate.blocked = false
|
||||
const second = internals.writeToDisk()
|
||||
writeGate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState
|
||||
.enabled
|
||||
).toBe(false)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
|
||||
expect(writeOpens.maxConcurrent).toBe(1)
|
||||
})
|
||||
|
||||
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
|
||||
const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp')
|
||||
writeFileSync(orphan, '{}')
|
||||
|
||||
createStoreWithState({})
|
||||
await vi.waitFor(() =>
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */
|
||||
import { app } from 'electron'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
|
||||
import type {
|
||||
CodexUsageBreakdownKind,
|
||||
CodexUsageBreakdownRow,
|
||||
@@ -366,6 +367,9 @@ export class CodexUsageStore {
|
||||
private state: CodexUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
// Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes
|
||||
// and vetoes superseded renames.
|
||||
private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile)
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
@@ -393,20 +397,19 @@ export class CodexUsageStore {
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): void {
|
||||
const usageFile = getCodexUsageFile()
|
||||
const dir = dirname(usageFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
private writeToDisk(): Promise<void> {
|
||||
// Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan.
|
||||
return this.writer.write(() => JSON.stringify(this.state))
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
flush(): Promise<void> {
|
||||
return this.writer.flush()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<CodexUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
@@ -456,7 +459,8 @@ export class CodexUsageStore {
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
// Why: start-only writes rewrite the full usage cache before scan results change.
|
||||
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
|
||||
// result changed. The completion/failure write below persists the same fields.
|
||||
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
@@ -473,10 +477,12 @@ export class CodexUsageStore {
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
|
||||
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
computerUseClickModifiersValidationMessage,
|
||||
computerUseHotkeyValidationMessage,
|
||||
computerUsePressKeyValidationMessage
|
||||
} from '../../shared/computer-use-key-spec'
|
||||
@@ -27,6 +28,7 @@ export async function validateComputerProviderActionParams(
|
||||
validateElementOrCoordinates('Click', params)
|
||||
validatePositiveInteger(params, 'clickCount')
|
||||
validateMouseButton(params)
|
||||
validateClickModifiers(params)
|
||||
return app
|
||||
case 'performSecondaryAction':
|
||||
requireNonNegativeInteger(params, 'elementIndex')
|
||||
@@ -159,6 +161,17 @@ function validateMouseButton(params: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateClickModifiers(params: Record<string, unknown>): void {
|
||||
if (params.modifiers === undefined) {
|
||||
return
|
||||
}
|
||||
const modifiers = requireNonEmptyString(params, 'modifiers')
|
||||
const message = computerUseClickModifiersValidationMessage(modifiers)
|
||||
if (message) {
|
||||
throw new RuntimeClientError('invalid_argument', message)
|
||||
}
|
||||
}
|
||||
|
||||
function validateScrollDirection(params: Record<string, unknown>): void {
|
||||
const direction = requireNonEmptyString(params, 'direction')
|
||||
if (direction !== 'up' && direction !== 'down' && direction !== 'left' && direction !== 'right') {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ComputerProviderSupervisorClient,
|
||||
DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS
|
||||
} from './computer-provider-supervisor-client'
|
||||
import {
|
||||
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
type ComputerProviderSupervisorRequest
|
||||
} from './computer-provider-supervisor-protocol'
|
||||
|
||||
describe('ComputerProviderSupervisorClient', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts only the fixed macOS operation and observes early helper termination', async () => {
|
||||
const sent: ComputerProviderSupervisorRequest[] = []
|
||||
const client = new ComputerProviderSupervisorClient((message, callback) => {
|
||||
sent.push(message)
|
||||
callback(null)
|
||||
})
|
||||
|
||||
const start = client.startMacOSProvider()
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 1,
|
||||
method: 'macos.start',
|
||||
params: {}
|
||||
}
|
||||
])
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'event',
|
||||
event: 'macos.sessionTerminated',
|
||||
sessionId: 'session-1',
|
||||
error: { code: 'accessibility_error', message: 'helper exited before connect' }
|
||||
})
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: {
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-1'
|
||||
}
|
||||
})
|
||||
|
||||
const started = await start
|
||||
expect(started).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-1'
|
||||
})
|
||||
await expect(started.termination).rejects.toThrow('helper exited before connect')
|
||||
})
|
||||
|
||||
it('uses fixed claim and release requests for a returned session id', async () => {
|
||||
const sent: ComputerProviderSupervisorRequest[] = []
|
||||
const client = new ComputerProviderSupervisorClient((message, callback) => {
|
||||
sent.push(message)
|
||||
callback(null)
|
||||
})
|
||||
|
||||
const claim = client.claimMacOSProvider('session-1')
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: { claimed: true }
|
||||
})
|
||||
await claim
|
||||
|
||||
const release = client.releaseMacOSProvider('session-1')
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 2,
|
||||
ok: true,
|
||||
result: { released: true }
|
||||
})
|
||||
await release
|
||||
|
||||
expect(sent.map(({ method, params }) => ({ method, params }))).toEqual([
|
||||
{ method: 'macos.claim', params: { sessionId: 'session-1' } },
|
||||
{ method: 'macos.release', params: { sessionId: 'session-1' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('executes only a fixed desktop request and validates its result', async () => {
|
||||
const sent: ComputerProviderSupervisorRequest[] = []
|
||||
const client = new ComputerProviderSupervisorClient((message, callback) => {
|
||||
sent.push(message)
|
||||
callback(null)
|
||||
})
|
||||
|
||||
const execution = client.executeDesktopProvider({ tool: 'list_apps' })
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 1,
|
||||
method: 'desktop.execute',
|
||||
params: { request: { tool: 'list_apps' } }
|
||||
}
|
||||
])
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: { stdout: '{"ok":true}', stderr: '', error: null }
|
||||
})
|
||||
|
||||
await expect(execution).resolves.toEqual({
|
||||
stdout: '{"ok":true}',
|
||||
stderr: '',
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed desktop results from the parent boundary', async () => {
|
||||
const client = new ComputerProviderSupervisorClient(() => {})
|
||||
const execution = client.executeDesktopProvider({ tool: 'list_apps' })
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: { stdout: Buffer.from('unbounded binary'), stderr: '', error: null }
|
||||
})
|
||||
|
||||
await expect(execution).rejects.toThrow('invalid desktop provider result')
|
||||
})
|
||||
|
||||
it('rejects immediately when supervisor IPC delivery fails', async () => {
|
||||
const client = new ComputerProviderSupervisorClient((_message, callback) => {
|
||||
callback(new Error('ipc channel closed'))
|
||||
})
|
||||
|
||||
await expect(client.startMacOSProvider()).rejects.toThrow('ipc channel closed')
|
||||
})
|
||||
|
||||
it('rejects malformed session metadata from the parent boundary', async () => {
|
||||
const client = new ComputerProviderSupervisorClient(() => {})
|
||||
const start = client.startMacOSProvider()
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: { sessionId: 'session-1', socketPath: '', socketToken: '' }
|
||||
})
|
||||
|
||||
await expect(start).rejects.toThrow('returned an invalid macOS session')
|
||||
})
|
||||
|
||||
it('preserves typed supervisor error responses', async () => {
|
||||
const client = new ComputerProviderSupervisorClient(() => {})
|
||||
const start = client.startMacOSProvider()
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: false,
|
||||
error: { code: 'provider_missing', message: 'helper is unavailable' }
|
||||
})
|
||||
|
||||
await expect(start).rejects.toMatchObject({
|
||||
code: 'provider_missing',
|
||||
message: 'helper is unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds terminated sessions whose start responses never arrived', async () => {
|
||||
const client = new ComputerProviderSupervisorClient(() => {})
|
||||
for (let session = 1; session <= 33; session++) {
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'event',
|
||||
event: 'macos.sessionTerminated',
|
||||
sessionId: `session-${session}`,
|
||||
error: { code: 'accessibility_error', message: `early-${session}` }
|
||||
})
|
||||
}
|
||||
|
||||
const oldestStart = client.startMacOSProvider()
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: {
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-1'
|
||||
}
|
||||
})
|
||||
const oldest = await oldestStart
|
||||
let oldestSettled = false
|
||||
void oldest.termination.catch(() => {
|
||||
oldestSettled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(oldestSettled).toBe(false)
|
||||
|
||||
const newestStart = client.startMacOSProvider()
|
||||
client.handleMessage({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 2,
|
||||
ok: true,
|
||||
result: {
|
||||
sessionId: 'session-33',
|
||||
socketPath: '/tmp/session-33/provider.sock',
|
||||
socketToken: 'token-33'
|
||||
}
|
||||
})
|
||||
const newest = await newestStart
|
||||
await expect(newest.termination).rejects.toThrow('early-33')
|
||||
client.shutdown()
|
||||
await expect(oldest.termination).rejects.toThrow('channel shut down')
|
||||
})
|
||||
|
||||
it('bounds an unanswered supervisor request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = new ComputerProviderSupervisorClient(() => {})
|
||||
|
||||
const start = client.startMacOSProvider()
|
||||
const rejection = expect(start).rejects.toThrow(
|
||||
'computer provider supervisor macos.start timed out'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await rejection
|
||||
})
|
||||
|
||||
it('allows the desktop supervisor deadline and reap grace to finish', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = new ComputerProviderSupervisorClient(() => {})
|
||||
|
||||
const execution = client.executeDesktopProvider({ tool: 'list_apps' })
|
||||
const rejection = expect(execution).rejects.toThrow(
|
||||
'computer provider supervisor desktop.execute timed out'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS - 1)
|
||||
let settled = false
|
||||
void execution.catch(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await rejection
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
isComputerProviderSupervisorMessage,
|
||||
isStartedSupervisedMacOSProvider,
|
||||
isSupervisedDesktopProviderResult,
|
||||
type ComputerProviderSupervisorRequest,
|
||||
type StartedSupervisedMacOSProvider,
|
||||
type SupervisedDesktopProviderResult
|
||||
} from './computer-provider-supervisor-protocol'
|
||||
import type { BridgeRequest } from './desktop-script-provider-types'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
const SUPERVISOR_REQUEST_TIMEOUT_MS = 10_000
|
||||
export const DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS = 40_000
|
||||
const MAX_EARLY_MACOS_TERMINATIONS = 32
|
||||
|
||||
type PendingSupervisorRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
type MacOSSessionState = {
|
||||
termination: Promise<never>
|
||||
rejectTermination: (error: Error) => void
|
||||
}
|
||||
|
||||
type SupervisorSend = (
|
||||
message: ComputerProviderSupervisorRequest,
|
||||
callback: (error: Error | null) => void
|
||||
) => void
|
||||
|
||||
export type SupervisedMacOSProviderSession = StartedSupervisedMacOSProvider & {
|
||||
termination: Promise<never>
|
||||
}
|
||||
|
||||
export class ComputerProviderSupervisorClient {
|
||||
private nextId = 1
|
||||
private readonly pending = new Map<number, PendingSupervisorRequest>()
|
||||
private readonly macOSSessions = new Map<string, MacOSSessionState>()
|
||||
private readonly earlyMacOSTerminations = new Map<string, RuntimeClientError>()
|
||||
|
||||
constructor(private readonly send: SupervisorSend) {}
|
||||
|
||||
async startMacOSProvider(): Promise<SupervisedMacOSProviderSession> {
|
||||
const result = await this.request('macos.start', {})
|
||||
if (!isStartedSupervisedMacOSProvider(result)) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer provider supervisor returned an invalid macOS session'
|
||||
)
|
||||
}
|
||||
const state = this.createMacOSSessionState()
|
||||
this.macOSSessions.set(result.sessionId, state)
|
||||
const earlyTermination = this.earlyMacOSTerminations.get(result.sessionId)
|
||||
if (earlyTermination) {
|
||||
this.earlyMacOSTerminations.delete(result.sessionId)
|
||||
state.rejectTermination(earlyTermination)
|
||||
}
|
||||
return { ...result, termination: state.termination }
|
||||
}
|
||||
|
||||
async claimMacOSProvider(sessionId: string): Promise<void> {
|
||||
await this.request('macos.claim', { sessionId })
|
||||
}
|
||||
|
||||
async releaseMacOSProvider(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await this.request('macos.release', { sessionId })
|
||||
} finally {
|
||||
this.macOSSessions.delete(sessionId)
|
||||
this.earlyMacOSTerminations.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async executeDesktopProvider(request: BridgeRequest): Promise<SupervisedDesktopProviderResult> {
|
||||
const result = await this.request('desktop.execute', { request })
|
||||
if (!isSupervisedDesktopProviderResult(result)) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer provider supervisor returned an invalid desktop provider result'
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
handleMessage(message: unknown): boolean {
|
||||
if (!isComputerProviderSupervisorMessage(message)) {
|
||||
return false
|
||||
}
|
||||
if (message.kind === 'event') {
|
||||
const error = new RuntimeClientError(message.error.code, message.error.message)
|
||||
const session = this.macOSSessions.get(message.sessionId)
|
||||
if (session) {
|
||||
session.rejectTermination(error)
|
||||
} else {
|
||||
if (
|
||||
!this.earlyMacOSTerminations.has(message.sessionId) &&
|
||||
this.earlyMacOSTerminations.size >= MAX_EARLY_MACOS_TERMINATIONS
|
||||
) {
|
||||
const oldestSessionId = this.earlyMacOSTerminations.keys().next().value
|
||||
if (oldestSessionId) {
|
||||
this.earlyMacOSTerminations.delete(oldestSessionId)
|
||||
}
|
||||
}
|
||||
this.earlyMacOSTerminations.set(message.sessionId, error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) {
|
||||
return true
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
this.pending.delete(message.id)
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result)
|
||||
} else {
|
||||
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
const error = new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer provider supervisor channel shut down'
|
||||
)
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(error)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
for (const session of this.macOSSessions.values()) {
|
||||
session.rejectTermination(error)
|
||||
}
|
||||
this.macOSSessions.clear()
|
||||
this.earlyMacOSTerminations.clear()
|
||||
}
|
||||
|
||||
private request(
|
||||
method: ComputerProviderSupervisorRequest['method'],
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const id = this.nextId++
|
||||
const request = {
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id,
|
||||
method,
|
||||
params
|
||||
} as ComputerProviderSupervisorRequest
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutMs =
|
||||
method === 'desktop.execute'
|
||||
? DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS
|
||||
: SUPERVISOR_REQUEST_TIMEOUT_MS
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'action_timeout',
|
||||
`computer provider supervisor ${method} timed out`
|
||||
)
|
||||
)
|
||||
}, timeoutMs)
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
try {
|
||||
this.send(request, (error) => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
this.pending.delete(id)
|
||||
pending.reject(new RuntimeClientError('accessibility_error', error.message))
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(id)
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createMacOSSessionState(): MacOSSessionState {
|
||||
let rejectTermination = (_error: Error): void => {}
|
||||
const termination = new Promise<never>((_resolve, reject) => {
|
||||
rejectTermination = reject
|
||||
})
|
||||
void termination.catch(() => undefined)
|
||||
return { termination, rejectTermination }
|
||||
}
|
||||
}
|
||||
|
||||
const client = new ComputerProviderSupervisorClient((message, callback) => {
|
||||
if (!process.send || !process.connected) {
|
||||
callback(new Error('computer provider supervisor IPC is unavailable'))
|
||||
return
|
||||
}
|
||||
process.send(message, callback)
|
||||
})
|
||||
|
||||
export function startSupervisedMacOSProvider(): Promise<SupervisedMacOSProviderSession> {
|
||||
return client.startMacOSProvider()
|
||||
}
|
||||
|
||||
export function claimSupervisedMacOSProvider(sessionId: string): Promise<void> {
|
||||
return client.claimMacOSProvider(sessionId)
|
||||
}
|
||||
|
||||
export function releaseSupervisedMacOSProvider(sessionId: string): Promise<void> {
|
||||
return client.releaseMacOSProvider(sessionId)
|
||||
}
|
||||
|
||||
export function executeSupervisedDesktopProvider(
|
||||
request: BridgeRequest
|
||||
): Promise<SupervisedDesktopProviderResult> {
|
||||
return client.executeDesktopProvider(request)
|
||||
}
|
||||
|
||||
export function handleComputerProviderSupervisorMessage(message: unknown): boolean {
|
||||
return client.handleMessage(message)
|
||||
}
|
||||
|
||||
export function shutdownComputerProviderSupervisorClient(): void {
|
||||
client.shutdown()
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ComputerProviderSupervisorHost } from './computer-provider-supervisor-host'
|
||||
import { COMPUTER_PROVIDER_SUPERVISOR_CHANNEL } from './computer-provider-supervisor-protocol'
|
||||
|
||||
function macOSSupervisor() {
|
||||
return {
|
||||
start: vi.fn(() => ({
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-1'
|
||||
})),
|
||||
claim: vi.fn(),
|
||||
release: vi.fn(),
|
||||
shutdown: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function desktopSupervisor() {
|
||||
return {
|
||||
execute: vi.fn(async () => ({ stdout: '{"ok":true}', stderr: '', error: null })),
|
||||
shutdown: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ComputerProviderSupervisorHost', () => {
|
||||
it('routes only fixed domain operations and returns their bounded result', async () => {
|
||||
const macOS = macOSSupervisor()
|
||||
const host = new ComputerProviderSupervisorHost(macOS as never)
|
||||
const sent: unknown[] = []
|
||||
host.attach((message) => sent.push(message))
|
||||
|
||||
expect(
|
||||
host.handle({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 1,
|
||||
method: 'macos.start',
|
||||
params: {}
|
||||
})
|
||||
).toBe(true)
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(1))
|
||||
|
||||
expect(macOS.start).toHaveBeenCalledTimes(1)
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
id: 1,
|
||||
ok: true,
|
||||
result: {
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-1'
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('routes validated desktop requests without accepting invocation fields', async () => {
|
||||
const macOS = macOSSupervisor()
|
||||
const desktop = desktopSupervisor()
|
||||
const host = new ComputerProviderSupervisorHost(macOS as never, desktop as never)
|
||||
const sent: unknown[] = []
|
||||
host.attach((message) => sent.push(message))
|
||||
|
||||
expect(
|
||||
host.handle({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 2,
|
||||
method: 'desktop.execute',
|
||||
params: { request: { tool: 'list_apps' } }
|
||||
})
|
||||
).toBe(true)
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(1))
|
||||
|
||||
expect(desktop.execute).toHaveBeenCalledWith({ tool: 'list_apps' })
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
ok: true,
|
||||
result: { stdout: '{"ok":true}', stderr: '', error: null }
|
||||
})
|
||||
])
|
||||
|
||||
expect(
|
||||
host.handle({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 3,
|
||||
method: 'desktop.execute',
|
||||
params: {
|
||||
request: { tool: 'list_apps' },
|
||||
command: '/tmp/untrusted',
|
||||
args: ['--anything']
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
expect(desktop.execute).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not deliver a stale operation response to a replacement owner', async () => {
|
||||
type DesktopResult = { stdout: string; stderr: string; error: null }
|
||||
let resolveExecution = (_result: DesktopResult): void => {}
|
||||
const desktop = desktopSupervisor()
|
||||
desktop.execute.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<DesktopResult>((resolve) => {
|
||||
resolveExecution = resolve
|
||||
})
|
||||
)
|
||||
const host = new ComputerProviderSupervisorHost(macOSSupervisor() as never, desktop as never)
|
||||
const originalSend = vi.fn()
|
||||
const replacementSend = vi.fn()
|
||||
host.attach(originalSend)
|
||||
host.handle({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 4,
|
||||
method: 'desktop.execute',
|
||||
params: { request: { tool: 'list_apps' } }
|
||||
})
|
||||
|
||||
host.shutdown()
|
||||
host.attach(replacementSend)
|
||||
resolveExecution({ stdout: '{"ok":true}', stderr: '', error: null })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(originalSend).not.toHaveBeenCalled()
|
||||
expect(replacementSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects arbitrary executable and argument fields at the protocol boundary', () => {
|
||||
const macOS = macOSSupervisor()
|
||||
const host = new ComputerProviderSupervisorHost(macOS as never)
|
||||
|
||||
expect(
|
||||
host.handle({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 1,
|
||||
method: 'macos.start',
|
||||
params: { executablePath: '/tmp/untrusted', args: ['--anything'] }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(macOS.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills all sessions and detaches delivery when the owner shuts down', () => {
|
||||
const macOS = macOSSupervisor()
|
||||
const desktop = desktopSupervisor()
|
||||
const host = new ComputerProviderSupervisorHost(macOS as never, desktop as never)
|
||||
const send = vi.fn()
|
||||
host.attach(send)
|
||||
|
||||
host.shutdown()
|
||||
|
||||
expect(macOS.shutdown).toHaveBeenCalledTimes(1)
|
||||
expect(desktop.shutdown).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
host.handle({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 2,
|
||||
method: 'macos.start',
|
||||
params: {}
|
||||
})
|
||||
).toBe(false)
|
||||
expect(macOS.start).not.toHaveBeenCalled()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
isComputerProviderSupervisorRequest,
|
||||
type ComputerProviderSupervisorMessage,
|
||||
type ComputerProviderSupervisorRequest,
|
||||
type ComputerProviderSupervisorResponse
|
||||
} from './computer-provider-supervisor-protocol'
|
||||
import { DesktopScriptProviderSupervisor } from './desktop-script-provider-supervisor'
|
||||
import { MacOSNativeProviderSupervisor } from './macos-native-provider-supervisor'
|
||||
|
||||
type SupervisorMessageSender = (message: ComputerProviderSupervisorMessage) => void
|
||||
type SupervisorResponsePayload =
|
||||
| { id: number; ok: true; result: unknown }
|
||||
| { id: number; ok: false; error: { code: string; message: string } }
|
||||
|
||||
export class ComputerProviderSupervisorHost {
|
||||
private sender: SupervisorMessageSender | null = null
|
||||
private ownerGeneration = 0
|
||||
private readonly macOS: MacOSNativeProviderSupervisor
|
||||
private readonly desktop: DesktopScriptProviderSupervisor
|
||||
|
||||
constructor(macOS?: MacOSNativeProviderSupervisor, desktop?: DesktopScriptProviderSupervisor) {
|
||||
this.macOS = macOS ?? new MacOSNativeProviderSupervisor((event) => this.sender?.(event))
|
||||
this.desktop = desktop ?? new DesktopScriptProviderSupervisor()
|
||||
}
|
||||
|
||||
attach(sender: SupervisorMessageSender): void {
|
||||
this.ownerGeneration++
|
||||
this.sender = sender
|
||||
}
|
||||
|
||||
handle(message: unknown): boolean {
|
||||
if (!this.sender || !isComputerProviderSupervisorRequest(message)) {
|
||||
return false
|
||||
}
|
||||
const ownerGeneration = this.ownerGeneration
|
||||
void this.dispatch(message).then(
|
||||
(result) => this.send(ownerGeneration, { id: message.id, ok: true, result }),
|
||||
(error) =>
|
||||
this.send(ownerGeneration, {
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: errorPayload(error)
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.ownerGeneration++
|
||||
this.sender = null
|
||||
this.macOS.shutdown()
|
||||
this.desktop.shutdown()
|
||||
}
|
||||
|
||||
private async dispatch(request: ComputerProviderSupervisorRequest): Promise<unknown> {
|
||||
switch (request.method) {
|
||||
case 'macos.start':
|
||||
return this.macOS.start()
|
||||
case 'macos.claim':
|
||||
this.macOS.claim(request.params.sessionId)
|
||||
return { claimed: true }
|
||||
case 'macos.release':
|
||||
this.macOS.release(request.params.sessionId)
|
||||
return { released: true }
|
||||
case 'desktop.execute':
|
||||
return this.desktop.execute(request.params.request)
|
||||
}
|
||||
}
|
||||
|
||||
private send(ownerGeneration: number, response: SupervisorResponsePayload): void {
|
||||
if (ownerGeneration !== this.ownerGeneration) {
|
||||
return
|
||||
}
|
||||
this.sender?.({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'response',
|
||||
...response
|
||||
} as ComputerProviderSupervisorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
function errorPayload(error: unknown): { code: string; message: string } {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
typeof (error as { code: unknown }).code === 'string'
|
||||
) {
|
||||
return { code: (error as { code: string }).code, message: error.message }
|
||||
}
|
||||
return {
|
||||
code: 'accessibility_error',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text'
|
||||
import {
|
||||
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
isComputerProviderSupervisorRequest,
|
||||
isSupervisedDesktopProviderResult
|
||||
} from './computer-provider-supervisor-protocol'
|
||||
import { DESKTOP_PROVIDER_REQUEST_MAX_BYTES } from './desktop-script-provider-request-validation'
|
||||
|
||||
function desktopRequest(request: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'request',
|
||||
id: 1,
|
||||
method: 'desktop.execute',
|
||||
params: { request }
|
||||
}
|
||||
}
|
||||
|
||||
describe('computer provider supervisor protocol', () => {
|
||||
it('accepts exact desktop tool requests with validated nested element state', () => {
|
||||
expect(
|
||||
isComputerProviderSupervisorRequest(
|
||||
desktopRequest({
|
||||
tool: 'click',
|
||||
app: 'Text Editor',
|
||||
element: {
|
||||
index: 0,
|
||||
runtimeId: [42, 7],
|
||||
name: 'Save',
|
||||
frame: { x: 1, y: 2, width: 30, height: 10 },
|
||||
actions: ['invoke']
|
||||
},
|
||||
click_count: 1,
|
||||
mouse_button: 'left',
|
||||
modifiers: 'CmdOrCtrl+Shift',
|
||||
windowBounds: null
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects command, path, argument, environment, and envelope injection', () => {
|
||||
for (const injected of [
|
||||
{ command: 'sh' },
|
||||
{ executablePath: '/tmp/provider' },
|
||||
{ scriptPath: '/tmp/runtime.py' },
|
||||
{ args: ['--arbitrary'] },
|
||||
{ env: { PATH: '/tmp' } },
|
||||
{ timeout: 1 }
|
||||
]) {
|
||||
expect(
|
||||
isComputerProviderSupervisorRequest(desktopRequest({ tool: 'list_apps', ...injected }))
|
||||
).toBe(false)
|
||||
}
|
||||
expect(
|
||||
isComputerProviderSupervisorRequest({
|
||||
...desktopRequest({ tool: 'list_apps' }),
|
||||
command: 'sh'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed and incomplete action requests', () => {
|
||||
const rejected = [
|
||||
{ tool: 'unknown' },
|
||||
{ tool: 'click', app: 'Text Editor' },
|
||||
{ tool: 'click', app: 'Text Editor', x: 1 },
|
||||
{ tool: 'click', app: 'Text Editor', x: Number.NaN, y: 2 },
|
||||
{ tool: 'click', app: 'Text Editor', x: 1, y: 2, modifiers: 'A' },
|
||||
{ tool: 'scroll', app: 'Text Editor', x: 1, y: 2, direction: 'diagonal' },
|
||||
{
|
||||
tool: 'drag',
|
||||
app: 'Text Editor',
|
||||
from_x: 1,
|
||||
from_y: 2,
|
||||
to_x: 3
|
||||
},
|
||||
{
|
||||
tool: 'set_value',
|
||||
app: 'Text Editor',
|
||||
element: { index: -1 },
|
||||
value: 'draft'
|
||||
},
|
||||
{
|
||||
tool: 'click',
|
||||
app: 'Text Editor',
|
||||
element: { index: 0, runtimeId: [1, 'untrusted'] }
|
||||
},
|
||||
{
|
||||
tool: 'get_app_state',
|
||||
app: 'Text Editor',
|
||||
windowId: 1,
|
||||
windowIndex: 2
|
||||
}
|
||||
]
|
||||
for (const request of rejected) {
|
||||
expect(isComputerProviderSupervisorRequest(desktopRequest(request))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces the existing paste limit independently of the envelope cap', () => {
|
||||
const text = 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
|
||||
|
||||
expect(
|
||||
isComputerProviderSupervisorRequest(
|
||||
desktopRequest({ tool: 'paste_text', app: 'Text Editor', text })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects oversized and non-serializable requests', () => {
|
||||
expect(
|
||||
isComputerProviderSupervisorRequest(
|
||||
desktopRequest({
|
||||
tool: 'type_text',
|
||||
app: 'Text Editor',
|
||||
text: 'x'.repeat(DESKTOP_PROVIDER_REQUEST_MAX_BYTES)
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
const cyclic = { tool: 'get_app_state' } as Record<string, unknown>
|
||||
cyclic.app = cyclic
|
||||
expect(isComputerProviderSupervisorRequest(desktopRequest(cyclic))).toBe(false)
|
||||
})
|
||||
|
||||
it('validates exact desktop result shapes', () => {
|
||||
expect(
|
||||
isSupervisedDesktopProviderResult({
|
||||
stdout: '{"ok":true}',
|
||||
stderr: '',
|
||||
error: null
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupervisedDesktopProviderResult({
|
||||
stdout: '',
|
||||
stderr: 'failed',
|
||||
error: { message: 'failed', killed: false }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupervisedDesktopProviderResult({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: null,
|
||||
command: 'sh'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { isDesktopScriptProviderRequest } from './desktop-script-provider-request-validation'
|
||||
import type { BridgeRequest } from './desktop-script-provider-types'
|
||||
|
||||
export const COMPUTER_PROVIDER_SUPERVISOR_CHANNEL = 'orca:computer-provider-supervisor'
|
||||
|
||||
export type ComputerProviderSupervisorRequest =
|
||||
| {
|
||||
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
|
||||
kind: 'request'
|
||||
id: number
|
||||
method: 'macos.start'
|
||||
params: Record<string, never>
|
||||
}
|
||||
| {
|
||||
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
|
||||
kind: 'request'
|
||||
id: number
|
||||
method: 'macos.claim' | 'macos.release'
|
||||
params: { sessionId: string }
|
||||
}
|
||||
| {
|
||||
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
|
||||
kind: 'request'
|
||||
id: number
|
||||
method: 'desktop.execute'
|
||||
params: { request: BridgeRequest }
|
||||
}
|
||||
|
||||
export type ComputerProviderSupervisorResponse =
|
||||
| {
|
||||
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
|
||||
kind: 'response'
|
||||
id: number
|
||||
ok: true
|
||||
result: unknown
|
||||
}
|
||||
| {
|
||||
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
|
||||
kind: 'response'
|
||||
id: number
|
||||
ok: false
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
|
||||
export type ComputerProviderSupervisorEvent = {
|
||||
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
|
||||
kind: 'event'
|
||||
event: 'macos.sessionTerminated'
|
||||
sessionId: string
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
|
||||
export type ComputerProviderSupervisorMessage =
|
||||
| ComputerProviderSupervisorResponse
|
||||
| ComputerProviderSupervisorEvent
|
||||
|
||||
export type StartedSupervisedMacOSProvider = {
|
||||
sessionId: string
|
||||
socketPath: string
|
||||
socketToken: string
|
||||
}
|
||||
|
||||
export type SupervisedDesktopProviderResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
error: { message: string; killed: boolean } | null
|
||||
}
|
||||
|
||||
const REQUEST_KEYS = new Set(['channel', 'kind', 'id', 'method', 'params'])
|
||||
|
||||
export function isComputerProviderSupervisorRequest(
|
||||
value: unknown
|
||||
): value is ComputerProviderSupervisorRequest {
|
||||
const record = messageRecord(value)
|
||||
if (
|
||||
!record ||
|
||||
record.channel !== COMPUTER_PROVIDER_SUPERVISOR_CHANNEL ||
|
||||
record.kind !== 'request' ||
|
||||
!Number.isSafeInteger(record.id) ||
|
||||
typeof record.method !== 'string' ||
|
||||
!hasOnlyKeys(record, REQUEST_KEYS)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const params = messageRecord(record.params)
|
||||
if (!params) {
|
||||
return false
|
||||
}
|
||||
if (record.method === 'macos.start') {
|
||||
return Object.keys(params).length === 0
|
||||
}
|
||||
if (record.method === 'macos.claim' || record.method === 'macos.release') {
|
||||
return (
|
||||
typeof params.sessionId === 'string' &&
|
||||
params.sessionId.length > 0 &&
|
||||
Object.keys(params).length === 1
|
||||
)
|
||||
}
|
||||
if (record.method === 'desktop.execute') {
|
||||
return Object.keys(params).length === 1 && isDesktopScriptProviderRequest(params.request)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isComputerProviderSupervisorMessage(
|
||||
value: unknown
|
||||
): value is ComputerProviderSupervisorMessage {
|
||||
const record = messageRecord(value)
|
||||
if (!record || record.channel !== COMPUTER_PROVIDER_SUPERVISOR_CHANNEL) {
|
||||
return false
|
||||
}
|
||||
if (record.kind === 'event') {
|
||||
return (
|
||||
record.event === 'macos.sessionTerminated' &&
|
||||
typeof record.sessionId === 'string' &&
|
||||
isErrorPayload(record.error)
|
||||
)
|
||||
}
|
||||
if (record.kind !== 'response' || !Number.isSafeInteger(record.id)) {
|
||||
return false
|
||||
}
|
||||
return record.ok === true || (record.ok === false && isErrorPayload(record.error))
|
||||
}
|
||||
|
||||
export function isStartedSupervisedMacOSProvider(
|
||||
value: unknown
|
||||
): value is StartedSupervisedMacOSProvider {
|
||||
const record = messageRecord(value)
|
||||
return (
|
||||
!!record &&
|
||||
typeof record.sessionId === 'string' &&
|
||||
record.sessionId.length > 0 &&
|
||||
typeof record.socketPath === 'string' &&
|
||||
record.socketPath.length > 0 &&
|
||||
typeof record.socketToken === 'string' &&
|
||||
record.socketToken.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function isSupervisedDesktopProviderResult(
|
||||
value: unknown
|
||||
): value is SupervisedDesktopProviderResult {
|
||||
const record = messageRecord(value)
|
||||
if (
|
||||
!record ||
|
||||
!hasOnlyKeys(record, new Set(['stdout', 'stderr', 'error'])) ||
|
||||
typeof record.stdout !== 'string' ||
|
||||
typeof record.stderr !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (record.error === null) {
|
||||
return true
|
||||
}
|
||||
const error = messageRecord(record.error)
|
||||
return (
|
||||
!!error &&
|
||||
hasOnlyKeys(error, new Set(['message', 'killed'])) &&
|
||||
typeof error.message === 'string' &&
|
||||
typeof error.killed === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isErrorPayload(value: unknown): value is { code: string; message: string } {
|
||||
const record = messageRecord(value)
|
||||
return !!record && typeof record.code === 'string' && typeof record.message === 'string'
|
||||
}
|
||||
|
||||
function messageRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, allowed: Set<string>): boolean {
|
||||
return Object.keys(value).every((key) => allowed.has(key))
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { ComputerProviderSupervisorHost } from './computer-provider-supervisor-host'
|
||||
import type { ComputerProviderSupervisorMessage } from './computer-provider-supervisor-protocol'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
export type ComputerSidecarMethod =
|
||||
| 'capabilities'
|
||||
| 'listApps'
|
||||
| 'listWindows'
|
||||
| 'getAppState'
|
||||
| 'click'
|
||||
| 'performSecondaryAction'
|
||||
| 'scroll'
|
||||
| 'drag'
|
||||
| 'typeText'
|
||||
| 'pressKey'
|
||||
| 'hotkey'
|
||||
| 'pasteText'
|
||||
| 'setValue'
|
||||
|
||||
type ComputerSidecarRequest = {
|
||||
id: number
|
||||
method: ComputerSidecarMethod
|
||||
params: unknown
|
||||
}
|
||||
|
||||
type ComputerSidecarResponse =
|
||||
| { id: number; ok: true; result: unknown }
|
||||
| { id: number; ok: false; error: { code: string; message: string } }
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
export const COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS = 5_000
|
||||
|
||||
// Why: stale children need an error listener without retaining their former owner.
|
||||
function ignoreStaleChildError(): void {}
|
||||
|
||||
function terminateSidecarChild(child: ChildProcess): void {
|
||||
let exited = false
|
||||
let forceKillTimer: NodeJS.Timeout | null = null
|
||||
const onExit = (): void => {
|
||||
exited = true
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
forceKillTimer = null
|
||||
}
|
||||
}
|
||||
child.once('exit', onExit)
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {}
|
||||
if (exited) {
|
||||
return
|
||||
}
|
||||
forceKillTimer = setTimeout(() => {
|
||||
forceKillTimer = null
|
||||
child.off('exit', onExit)
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {}
|
||||
}, COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS)
|
||||
forceKillTimer.unref()
|
||||
}
|
||||
|
||||
export class ComputerSidecarProcess {
|
||||
private child: ChildProcess | null = null
|
||||
private childListenerCleanup: (() => void) | null = null
|
||||
private readonly providerSupervisor = new ComputerProviderSupervisorHost()
|
||||
private nextId = 1
|
||||
private pending = new Map<number, PendingRequest>()
|
||||
private queueTail: Promise<void> | null = null
|
||||
private queueGeneration = 0
|
||||
|
||||
constructor(private readonly entryPath: string = getComputerSidecarEntryPath()) {}
|
||||
|
||||
call(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
|
||||
const generation = this.queueGeneration
|
||||
const run = () => {
|
||||
if (generation !== this.queueGeneration) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer sidecar queue was invalidated; retry the computer-use request'
|
||||
)
|
||||
}
|
||||
return this.send(method, params)
|
||||
}
|
||||
const result = this.queueTail ? this.queueTail.then(run, run) : run()
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
this.queueTail = tail
|
||||
void tail.finally(() => {
|
||||
if (this.queueTail === tail) {
|
||||
this.queueTail = null
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
this.queueGeneration++
|
||||
this.cleanupActiveChildListeners()
|
||||
this.providerSupervisor.shutdown()
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down'))
|
||||
this.pending.delete(id)
|
||||
}
|
||||
if (child) {
|
||||
terminateSidecarChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
private send(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
|
||||
const child = this.ensureStarted()
|
||||
if (!child.send) {
|
||||
const error = new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer sidecar IPC is unavailable'
|
||||
)
|
||||
this.failActiveChild(child, error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
const id = this.nextId++
|
||||
const request: ComputerSidecarRequest = { id, method, params }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
this.shutdown()
|
||||
reject(new RuntimeClientError('action_timeout', `computer sidecar ${method} timed out`))
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
child.send(request, (error) => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
const wrapped = new RuntimeClientError('accessibility_error', error.message)
|
||||
if (this.child === child) {
|
||||
this.failActiveChild(child, wrapped)
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(id)
|
||||
reject(wrapped)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private ensureStarted(): ChildProcess {
|
||||
if (this.child && !this.child.killed) {
|
||||
return this.child
|
||||
}
|
||||
this.cleanupActiveChildListeners()
|
||||
this.providerSupervisor.shutdown()
|
||||
this.child = null
|
||||
|
||||
const child = fork(this.entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_COMPUTER_SIDECAR: '1'
|
||||
},
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
|
||||
const onMessage = (message: unknown) => {
|
||||
if (this.child !== child || this.providerSupervisor.handle(message)) {
|
||||
return
|
||||
}
|
||||
this.handleResponse(message)
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
|
||||
this.handleExit(child, code, signal)
|
||||
const onError = (error: Error) => this.handleError(child, error)
|
||||
|
||||
child.on('message', onMessage)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
this.childListenerCleanup = () => {
|
||||
child.off('message', onMessage)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
child.off('error', ignoreStaleChildError)
|
||||
child.on('error', ignoreStaleChildError)
|
||||
}
|
||||
this.child = child
|
||||
this.providerSupervisor.attach((message) =>
|
||||
this.sendComputerProviderSupervisorMessage(child, message)
|
||||
)
|
||||
return child
|
||||
}
|
||||
|
||||
private handleResponse(message: unknown): void {
|
||||
if (!isSidecarResponse(message)) {
|
||||
return
|
||||
}
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
this.pending.delete(message.id)
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result)
|
||||
return
|
||||
}
|
||||
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
|
||||
}
|
||||
|
||||
private handleExit(
|
||||
child: ChildProcess,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null
|
||||
): void {
|
||||
if (this.child !== child) {
|
||||
return
|
||||
}
|
||||
this.cleanupActiveChildListeners()
|
||||
this.providerSupervisor.shutdown()
|
||||
this.child = null
|
||||
this.queueGeneration++
|
||||
const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`
|
||||
this.rejectPending(
|
||||
new RuntimeClientError('accessibility_error', `computer sidecar exited with ${detail}`)
|
||||
)
|
||||
}
|
||||
|
||||
private handleError(child: ChildProcess, error: Error): void {
|
||||
if (this.child !== child) {
|
||||
return
|
||||
}
|
||||
this.cleanupActiveChildListeners()
|
||||
this.failActiveChild(child, new RuntimeClientError('accessibility_error', error.message))
|
||||
}
|
||||
|
||||
private failActiveChild(child: ChildProcess, error: RuntimeClientError): void {
|
||||
this.cleanupActiveChildListeners()
|
||||
this.child = null
|
||||
this.queueGeneration++
|
||||
this.providerSupervisor.shutdown()
|
||||
terminateSidecarChild(child)
|
||||
this.rejectPending(error)
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(error)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupActiveChildListeners(): void {
|
||||
const cleanup = this.childListenerCleanup
|
||||
this.childListenerCleanup = null
|
||||
cleanup?.()
|
||||
}
|
||||
|
||||
private sendComputerProviderSupervisorMessage(
|
||||
child: ChildProcess,
|
||||
message: ComputerProviderSupervisorMessage
|
||||
): void {
|
||||
if (this.child !== child || !child.send) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
child.send(message, (error) => {
|
||||
if (!error || this.child !== child) {
|
||||
return
|
||||
}
|
||||
this.failActiveChild(child, new RuntimeClientError('accessibility_error', error.message))
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.child === child) {
|
||||
this.failActiveChild(
|
||||
child,
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getComputerSidecarEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
const appPath = app?.getAppPath() ?? process.cwd()
|
||||
const isPackaged = app?.isPackaged ?? false
|
||||
// Why: ELECTRON_RUN_AS_NODE bypasses Electron's packaged asar require integration.
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
return join(basePath, 'out', 'main', 'computer-sidecar.js')
|
||||
}
|
||||
|
||||
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
|
||||
try {
|
||||
return require('electron').app
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isSidecarResponse(message: unknown): message is ComputerSidecarResponse {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return false
|
||||
}
|
||||
const record = message as Record<string, unknown>
|
||||
return typeof record.id === 'number' && typeof record.ok === 'boolean'
|
||||
}
|
||||
@@ -186,10 +186,18 @@ describe('DesktopScriptProviderClient actions', () => {
|
||||
ok: true,
|
||||
capabilities: sampleCapabilities()
|
||||
})
|
||||
mockBridgeResponse({
|
||||
ok: true,
|
||||
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
|
||||
})
|
||||
mockBridgeResponse(
|
||||
{
|
||||
ok: true,
|
||||
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
|
||||
},
|
||||
(operation) => {
|
||||
expect(operation).toMatchObject({
|
||||
tool: 'click',
|
||||
modifiers: 'CmdOrCtrl+Shift'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
|
||||
|
||||
@@ -197,6 +205,7 @@ describe('DesktopScriptProviderClient actions', () => {
|
||||
const result = await client.action('click', {
|
||||
app: 'Text Editor',
|
||||
elementIndex: 0,
|
||||
modifiers: 'CmdOrCtrl+Shift',
|
||||
noScreenshot: true
|
||||
})
|
||||
|
||||
|
||||
@@ -1,106 +1,21 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { executeSupervisedDesktopProvider } from './computer-provider-supervisor-client'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
import type { DesktopScriptPlatform } from './desktop-script-provider-paths'
|
||||
import type { BridgeRequest } from './desktop-script-provider-types'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const FORCE_KILL_GRACE_MS = 1_000
|
||||
|
||||
export function execBridge(
|
||||
platform: DesktopScriptPlatform,
|
||||
scriptPath: string,
|
||||
operationPath: string
|
||||
export async function execBridge(
|
||||
request: BridgeRequest
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const command = platform === 'windows' ? 'powershell.exe' : 'python3'
|
||||
const args =
|
||||
platform === 'windows'
|
||||
? [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
scriptPath,
|
||||
operationPath
|
||||
]
|
||||
: [scriptPath, operationPath]
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const result = await executeSupervisedDesktopProvider(withoutUndefinedValues(request))
|
||||
if (result.error) {
|
||||
throw mapBridgeError(result.error.message)
|
||||
}
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
const clearForceKillTimer = (): void => {
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
forceKillTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const finish = (
|
||||
error: Error | null,
|
||||
result?: { stdout: string; stderr: string },
|
||||
options: { keepForceKillTimer?: boolean } = {}
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (!options.keepForceKillTimer) {
|
||||
clearForceKillTimer()
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(result ?? { stdout: '', stderr: '' })
|
||||
}
|
||||
|
||||
// Why: native automation can hang inside platform APIs; reject promptly,
|
||||
// then escalate cleanup if the bridge ignores the graceful termination.
|
||||
const timeout = setTimeout(() => {
|
||||
child?.kill('SIGTERM')
|
||||
forceKillTimer = setTimeout(() => {
|
||||
child?.kill('SIGKILL')
|
||||
forceKillTimer = null
|
||||
}, FORCE_KILL_GRACE_MS)
|
||||
finish(
|
||||
new RuntimeClientError(
|
||||
'action_timeout',
|
||||
`desktop provider timed out after ${REQUEST_TIMEOUT_MS}ms`
|
||||
),
|
||||
undefined,
|
||||
{ keepForceKillTimer: true }
|
||||
)
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
child = execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const message = stderr.trim() || stdout.trim() || error.message
|
||||
finish(
|
||||
error.killed
|
||||
? new RuntimeClientError('action_timeout', message)
|
||||
: mapBridgeError(message)
|
||||
)
|
||||
return
|
||||
}
|
||||
finish(null, { stdout, stderr })
|
||||
}
|
||||
)
|
||||
child?.once('exit', clearForceKillTimer)
|
||||
} catch (error) {
|
||||
finish(mapBridgeError(error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
})
|
||||
function withoutUndefinedValues(request: BridgeRequest): BridgeRequest {
|
||||
return Object.fromEntries(
|
||||
Object.entries(request).filter(([, value]) => value !== undefined)
|
||||
) as BridgeRequest
|
||||
}
|
||||
|
||||
export function mapBridgeError(message: string): RuntimeClientError {
|
||||
@@ -112,7 +27,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
|
||||
return new RuntimeClientError('app_blocked', text)
|
||||
}
|
||||
if (
|
||||
/unsupported capability|hotkey.*require|paste_text requires|GDK is required for non-character key synthesis/i.test(
|
||||
/unsupported capability|hotkey.*require|paste_text requires|modified clicks require xdotool|GDK is required for non-character key synthesis/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
ComputerActionResult,
|
||||
ComputerListAppsResult,
|
||||
@@ -27,8 +24,7 @@ import {
|
||||
} from './desktop-script-provider-params'
|
||||
import {
|
||||
desktopScriptPlatform,
|
||||
resolveDesktopScriptProviderPath,
|
||||
type DesktopScriptPlatform
|
||||
resolveDesktopScriptProviderPath
|
||||
} from './desktop-script-provider-paths'
|
||||
import type {
|
||||
BridgeRequest,
|
||||
@@ -49,11 +45,6 @@ export class DesktopScriptProviderClient {
|
||||
readonly snapshots = this.snapshotStore.snapshots
|
||||
private providerCapabilities: ComputerProviderCapabilities | null = null
|
||||
|
||||
constructor(
|
||||
private readonly platform: DesktopScriptPlatform = requiredPlatform(),
|
||||
private readonly scriptPath: string = requiredScriptPath()
|
||||
) {}
|
||||
|
||||
shutdown(): void {
|
||||
this.snapshotStore.clear()
|
||||
this.providerCapabilities = null
|
||||
@@ -152,6 +143,7 @@ export class DesktopScriptProviderClient {
|
||||
to_y: optionalNumberParam(params, 'toY'),
|
||||
click_count: optionalNumberParam(params, 'clickCount'),
|
||||
mouse_button: optionalStringParam(params, 'mouseButton'),
|
||||
modifiers: optionalStringParam(params, 'modifiers'),
|
||||
action: optionalStringParam(params, 'action'),
|
||||
direction: optionalStringParam(params, 'direction'),
|
||||
pages: optionalNumberParam(params, 'pages'),
|
||||
@@ -202,27 +194,20 @@ export class DesktopScriptProviderClient {
|
||||
}
|
||||
|
||||
private async callBridge(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-'))
|
||||
const operationPath = join(operationDirectory, 'operation.json')
|
||||
const { stdout, stderr } = await execBridge(request)
|
||||
let response: BridgeResponse
|
||||
try {
|
||||
await writeFile(operationPath, JSON.stringify(request), { encoding: 'utf8', mode: 0o600 })
|
||||
const { stdout, stderr } = await execBridge(this.platform, this.scriptPath, operationPath)
|
||||
let response: BridgeResponse
|
||||
try {
|
||||
response = JSON.parse(stdout) as BridgeResponse
|
||||
} catch (error) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw mapBridgeError(response.error ?? stderr)
|
||||
}
|
||||
return response
|
||||
} finally {
|
||||
await rm(operationDirectory, { force: true, recursive: true })
|
||||
response = JSON.parse(stdout) as BridgeResponse
|
||||
} catch (error) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw mapBridgeError(response.error ?? stderr)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private async readCapabilities(): Promise<ComputerProviderCapabilities> {
|
||||
@@ -253,22 +238,3 @@ export class DesktopScriptProviderClient {
|
||||
return renderSnapshot(response.snapshot, noScreenshot)
|
||||
}
|
||||
}
|
||||
|
||||
function requiredPlatform(): DesktopScriptPlatform {
|
||||
const platform = desktopScriptPlatform()
|
||||
if (!platform) {
|
||||
throw new RuntimeClientError('accessibility_error', 'desktop script provider is not available')
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
function requiredScriptPath(): string {
|
||||
const scriptPath = resolveDesktopScriptProviderPath()
|
||||
if (!scriptPath) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'desktop script provider script was not found'
|
||||
)
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createDesktopScriptProviderClient,
|
||||
expectDesktopProviderSubprocessStarted,
|
||||
mockDesktopProviderSubprocessThatIgnoresTimeout,
|
||||
mockBridgeExecutionError,
|
||||
mockBridgeResponse,
|
||||
resetDesktopScriptProviderTestHarness,
|
||||
sampleBridgeSnapshot,
|
||||
@@ -117,43 +116,11 @@ describe('DesktopScriptProviderClient errors and capabilities', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when the desktop provider subprocess ignores the exec timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const kill = vi.fn()
|
||||
const once = vi.fn()
|
||||
mockDesktopProviderSubprocessThatIgnoresTimeout({ kill, once })
|
||||
it('maps supervised provider process failures', async () => {
|
||||
mockBridgeExecutionError('appNotFound("Missing")')
|
||||
|
||||
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
|
||||
const promise = client.listApps()
|
||||
let settled = false
|
||||
void promise.catch(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.waitFor(expectDesktopProviderSubprocessStarted, { timeout: 1_000 })
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
expect(settled).toBe(true)
|
||||
expect(kill).toHaveBeenCalledWith('SIGTERM')
|
||||
await expect(promise).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
})
|
||||
|
||||
it('force-kills a timed-out desktop provider subprocess that has not exited', async () => {
|
||||
vi.useFakeTimers()
|
||||
const kill = vi.fn()
|
||||
const once = vi.fn()
|
||||
mockDesktopProviderSubprocessThatIgnoresTimeout({ kill, once })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
|
||||
const promise = client.listApps().catch(() => undefined)
|
||||
|
||||
await vi.waitFor(expectDesktopProviderSubprocessStarted, { timeout: 1_000 })
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
expect(kill).toHaveBeenCalledWith('SIGTERM')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(kill).toHaveBeenCalledWith('SIGKILL')
|
||||
await promise
|
||||
await expect(client.listApps()).rejects.toMatchObject({ code: 'app_not_found' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { execFile, type ExecFileException } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { SupervisedDesktopProviderResult } from './computer-provider-supervisor-protocol'
|
||||
import {
|
||||
desktopScriptPlatform,
|
||||
resolveDesktopScriptProviderPath,
|
||||
type DesktopScriptPlatform
|
||||
} from './desktop-script-provider-paths'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
export const DESKTOP_PROVIDER_MAX_BUFFER_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export type DesktopProviderExecutionPlan = {
|
||||
platform: DesktopScriptPlatform
|
||||
command: string
|
||||
scriptPath: string
|
||||
env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export type DesktopScriptProviderSupervisorDeps = {
|
||||
platform: () => DesktopScriptPlatform | null
|
||||
resolveScriptPath: (platform: DesktopScriptPlatform) => string | null
|
||||
execFile: typeof execFile
|
||||
randomUUID: () => string
|
||||
temporaryDirectory: () => string
|
||||
mkdtemp: typeof mkdtemp
|
||||
chmod: typeof chmod
|
||||
writeFile: typeof writeFile
|
||||
rmSync: typeof rmSync
|
||||
setTimer: (callback: () => void, timeoutMs: number) => NodeJS.Timeout
|
||||
clearTimer: (timer: NodeJS.Timeout) => void
|
||||
}
|
||||
|
||||
export function desktopProviderExecutionPlan(
|
||||
deps: DesktopScriptProviderSupervisorDeps
|
||||
): DesktopProviderExecutionPlan {
|
||||
const platform = deps.platform()
|
||||
if (!platform) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'desktop script provider is not available on this platform'
|
||||
)
|
||||
}
|
||||
const scriptPath = deps.resolveScriptPath(platform)
|
||||
if (!scriptPath) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'desktop script provider script was not found'
|
||||
)
|
||||
}
|
||||
return {
|
||||
platform,
|
||||
command: platform === 'windows' ? 'powershell.exe' : 'python3',
|
||||
scriptPath,
|
||||
env: { ...process.env }
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopProviderCommandArgs(
|
||||
plan: DesktopProviderExecutionPlan,
|
||||
operationPath: string
|
||||
): string[] {
|
||||
return plan.platform === 'windows'
|
||||
? [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
plan.scriptPath,
|
||||
operationPath
|
||||
]
|
||||
: [plan.scriptPath, operationPath]
|
||||
}
|
||||
|
||||
export function desktopProviderExecutionResult(
|
||||
error: ExecFileException | null,
|
||||
stdout: string,
|
||||
stderr: string
|
||||
): SupervisedDesktopProviderResult {
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
error: error
|
||||
? {
|
||||
message: stderr.trim() || stdout.trim() || error.message,
|
||||
killed: error.killed === true
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export function createDesktopScriptProviderSupervisorDeps(): DesktopScriptProviderSupervisorDeps {
|
||||
return {
|
||||
platform: desktopScriptPlatform,
|
||||
resolveScriptPath: resolveDesktopScriptProviderPath,
|
||||
execFile,
|
||||
randomUUID,
|
||||
temporaryDirectory: tmpdir,
|
||||
mkdtemp,
|
||||
chmod,
|
||||
writeFile,
|
||||
rmSync,
|
||||
setTimer: setTimeout,
|
||||
clearTimer: clearTimeout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text'
|
||||
import {
|
||||
computerUseClickModifiersValidationMessage,
|
||||
computerUseHotkeyValidationMessage,
|
||||
computerUsePressKeyValidationMessage
|
||||
} from '../../shared/computer-use-key-spec'
|
||||
import type { BridgeElement, BridgeFrame, BridgeRequest } from './desktop-script-provider-types'
|
||||
|
||||
export const DESKTOP_PROVIDER_REQUEST_MAX_BYTES = 17 * 1024 * 1024
|
||||
const SERIALIZED_REQUESTS = new WeakMap<object, string>()
|
||||
|
||||
const APP_TARGET_KEYS = ['tool', 'app', 'windowId', 'windowIndex', 'noScreenshot', 'restoreWindow']
|
||||
const ACTION_BASE_KEYS = [...APP_TARGET_KEYS, 'windowBounds']
|
||||
const TOOL_KEYS = new Map<string, Set<string>>([
|
||||
['handshake', keys('tool')],
|
||||
['list_apps', keys('tool')],
|
||||
['list_windows', keys('tool', 'app')],
|
||||
['get_app_state', keys(...APP_TARGET_KEYS)],
|
||||
[
|
||||
'click',
|
||||
keys(...ACTION_BASE_KEYS, 'element', 'x', 'y', 'click_count', 'mouse_button', 'modifiers')
|
||||
],
|
||||
['perform_secondary_action', keys(...ACTION_BASE_KEYS, 'element', 'action')],
|
||||
['scroll', keys(...ACTION_BASE_KEYS, 'element', 'x', 'y', 'direction', 'pages')],
|
||||
[
|
||||
'drag',
|
||||
keys(...ACTION_BASE_KEYS, 'fromElement', 'toElement', 'from_x', 'from_y', 'to_x', 'to_y')
|
||||
],
|
||||
['type_text', keys(...ACTION_BASE_KEYS, 'text')],
|
||||
['press_key', keys(...ACTION_BASE_KEYS, 'key')],
|
||||
['hotkey', keys(...ACTION_BASE_KEYS, 'key')],
|
||||
['paste_text', keys(...ACTION_BASE_KEYS, 'text')],
|
||||
['set_value', keys(...ACTION_BASE_KEYS, 'element', 'value')]
|
||||
])
|
||||
const ELEMENT_KEYS = keys(
|
||||
'index',
|
||||
'runtimeId',
|
||||
'automationId',
|
||||
'name',
|
||||
'controlType',
|
||||
'localizedControlType',
|
||||
'className',
|
||||
'value',
|
||||
'isSelected',
|
||||
'nativeWindowHandle',
|
||||
'frame',
|
||||
'actions'
|
||||
)
|
||||
const FRAME_KEYS = keys('x', 'y', 'width', 'height')
|
||||
|
||||
export function isDesktopScriptProviderRequest(value: unknown): value is BridgeRequest {
|
||||
const request = record(value)
|
||||
if (!request || typeof request.tool !== 'string') {
|
||||
return false
|
||||
}
|
||||
const allowedKeys = TOOL_KEYS.get(request.tool)
|
||||
if (!allowedKeys || !hasOnlyKeys(request, allowedKeys) || !isWithinRequestLimit(request)) {
|
||||
return false
|
||||
}
|
||||
if (request.tool === 'handshake' || request.tool === 'list_apps') {
|
||||
return true
|
||||
}
|
||||
if (!isNonEmptyString(request.app)) {
|
||||
return false
|
||||
}
|
||||
if (request.tool === 'list_windows') {
|
||||
return true
|
||||
}
|
||||
if (!hasValidAppTarget(request)) {
|
||||
return false
|
||||
}
|
||||
if (request.tool === 'get_app_state') {
|
||||
return true
|
||||
}
|
||||
if (!isOptionalFrameOrNull(request.windowBounds)) {
|
||||
return false
|
||||
}
|
||||
return hasValidAction(request)
|
||||
}
|
||||
|
||||
export function serializeDesktopScriptProviderRequest(request: BridgeRequest): string {
|
||||
const cached = SERIALIZED_REQUESTS.get(request)
|
||||
if (cached !== undefined) {
|
||||
SERIALIZED_REQUESTS.delete(request)
|
||||
return cached
|
||||
}
|
||||
return JSON.stringify(request)
|
||||
}
|
||||
|
||||
function hasValidAppTarget(request: Record<string, unknown>): boolean {
|
||||
return (
|
||||
isOptionalFiniteNumber(request.windowId) &&
|
||||
isOptionalFiniteNumber(request.windowIndex) &&
|
||||
!(request.windowId !== undefined && request.windowIndex !== undefined) &&
|
||||
isOptionalBoolean(request.noScreenshot) &&
|
||||
isOptionalBoolean(request.restoreWindow)
|
||||
)
|
||||
}
|
||||
|
||||
function hasValidAction(request: Record<string, unknown>): boolean {
|
||||
switch (request.tool) {
|
||||
case 'click':
|
||||
return (
|
||||
isElementOrCoordinateTarget(request, 'element', 'x', 'y') &&
|
||||
isOptionalPositiveInteger(request.click_count) &&
|
||||
isOptionalEnum(request.mouse_button, ['left', 'right', 'middle']) &&
|
||||
isValidClickModifiers(request.modifiers)
|
||||
)
|
||||
case 'perform_secondary_action':
|
||||
return isElement(request.element) && isNonEmptyString(request.action)
|
||||
case 'scroll':
|
||||
return (
|
||||
isElementOrCoordinateTarget(request, 'element', 'x', 'y') &&
|
||||
isOptionalEnum(request.direction, ['up', 'down', 'left', 'right'], true) &&
|
||||
isOptionalPositiveNumber(request.pages)
|
||||
)
|
||||
case 'drag':
|
||||
return isDragTarget(request)
|
||||
case 'type_text':
|
||||
return isNonEmptyString(request.text)
|
||||
case 'press_key':
|
||||
return (
|
||||
isNonEmptyString(request.key) && computerUsePressKeyValidationMessage(request.key) === null
|
||||
)
|
||||
case 'hotkey':
|
||||
return (
|
||||
isNonEmptyString(request.key) && computerUseHotkeyValidationMessage(request.key) === null
|
||||
)
|
||||
case 'paste_text':
|
||||
return (
|
||||
isNonEmptyString(request.text) &&
|
||||
Buffer.byteLength(request.text, 'utf8') <= CLIPBOARD_TEXT_WRITE_MAX_BYTES
|
||||
)
|
||||
case 'set_value':
|
||||
return isElement(request.element) && typeof request.value === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isValidClickModifiers(value: unknown): value is string | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
(isNonEmptyString(value) && computerUseClickModifiersValidationMessage(value) === null)
|
||||
)
|
||||
}
|
||||
|
||||
function isElementOrCoordinateTarget(
|
||||
request: Record<string, unknown>,
|
||||
elementKey: string,
|
||||
xKey: string,
|
||||
yKey: string
|
||||
): boolean {
|
||||
const hasElement = request[elementKey] !== undefined
|
||||
const hasX = request[xKey] !== undefined
|
||||
const hasY = request[yKey] !== undefined
|
||||
return (
|
||||
hasElement !== (hasX && hasY) &&
|
||||
hasX === hasY &&
|
||||
(!hasElement || isElement(request[elementKey])) &&
|
||||
isOptionalFiniteNumber(request[xKey]) &&
|
||||
isOptionalFiniteNumber(request[yKey])
|
||||
)
|
||||
}
|
||||
|
||||
function isDragTarget(request: Record<string, unknown>): boolean {
|
||||
const hasElements = request.fromElement !== undefined || request.toElement !== undefined
|
||||
const coordinates = [request.from_x, request.from_y, request.to_x, request.to_y]
|
||||
const hasCoordinates = coordinates.some((value) => value !== undefined)
|
||||
if (hasElements === hasCoordinates) {
|
||||
return false
|
||||
}
|
||||
return hasElements
|
||||
? isElement(request.fromElement) && isElement(request.toElement)
|
||||
: coordinates.every((value) => typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
function isElement(value: unknown): value is BridgeElement {
|
||||
const element = record(value)
|
||||
return (
|
||||
!!element &&
|
||||
hasOnlyKeys(element, ELEMENT_KEYS) &&
|
||||
Number.isSafeInteger(element.index) &&
|
||||
(element.index as number) >= 0 &&
|
||||
(element.runtimeId === undefined ||
|
||||
(Array.isArray(element.runtimeId) &&
|
||||
element.runtimeId.every((part) => Number.isSafeInteger(part)))) &&
|
||||
isOptionalString(element.automationId) &&
|
||||
isOptionalString(element.name) &&
|
||||
isOptionalString(element.controlType) &&
|
||||
isOptionalString(element.localizedControlType) &&
|
||||
isOptionalString(element.className) &&
|
||||
isOptionalString(element.value) &&
|
||||
isOptionalBoolean(element.isSelected) &&
|
||||
isOptionalFiniteNumber(element.nativeWindowHandle) &&
|
||||
isOptionalFrameOrNull(element.frame) &&
|
||||
(element.actions === undefined ||
|
||||
(Array.isArray(element.actions) &&
|
||||
element.actions.every((action) => typeof action === 'string')))
|
||||
)
|
||||
}
|
||||
|
||||
function isOptionalFrameOrNull(value: unknown): value is BridgeFrame | null | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return true
|
||||
}
|
||||
const frame = record(value)
|
||||
return (
|
||||
!!frame &&
|
||||
hasOnlyKeys(frame, FRAME_KEYS) &&
|
||||
Number.isFinite(frame.x) &&
|
||||
Number.isFinite(frame.y) &&
|
||||
Number.isFinite(frame.width) &&
|
||||
Number.isFinite(frame.height)
|
||||
)
|
||||
}
|
||||
|
||||
function isWithinRequestLimit(request: Record<string, unknown>): boolean {
|
||||
try {
|
||||
const serialized = JSON.stringify(request)
|
||||
const withinLimit =
|
||||
typeof serialized === 'string' &&
|
||||
Buffer.byteLength(serialized, 'utf8') <= DESKTOP_PROVIDER_REQUEST_MAX_BYTES
|
||||
if (withinLimit) {
|
||||
SERIALIZED_REQUESTS.set(request, serialized)
|
||||
}
|
||||
return withinLimit
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isOptionalEnum(
|
||||
value: unknown,
|
||||
allowed: readonly string[],
|
||||
required = false
|
||||
): value is string | undefined {
|
||||
return value === undefined ? !required : typeof value === 'string' && allowed.includes(value)
|
||||
}
|
||||
|
||||
function isOptionalPositiveInteger(value: unknown): value is number | undefined {
|
||||
return value === undefined || (Number.isSafeInteger(value) && (value as number) > 0)
|
||||
}
|
||||
|
||||
function isOptionalPositiveNumber(value: unknown): value is number | undefined {
|
||||
return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value > 0)
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, allowed: Set<string>): boolean {
|
||||
return Object.keys(value).every((key) => allowed.has(key))
|
||||
}
|
||||
|
||||
function keys(...values: string[]): Set<string> {
|
||||
return new Set(values)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): value is string | undefined {
|
||||
return value === undefined || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isOptionalFiniteNumber(value: unknown): value is number | undefined {
|
||||
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
function isOptionalBoolean(value: unknown): value is boolean | undefined {
|
||||
return value === undefined || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { ExecFileException } from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
|
||||
type DesktopScriptProviderSupervisorDeps
|
||||
} from './desktop-script-provider-process-invocation'
|
||||
import {
|
||||
DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS,
|
||||
DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS,
|
||||
DesktopScriptProviderSupervisor
|
||||
} from './desktop-script-provider-supervisor'
|
||||
|
||||
type ExecCallback = (error: ExecFileException | null, stdout: string, stderr: string) => void
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
pid: number | undefined = 4321
|
||||
kill = vi.fn(() => true)
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve = (_value: T): void => {}
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function flushPreparation(): Promise<void> {
|
||||
for (let turn = 0; turn < 8; turn++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('DesktopScriptProviderSupervisor', () => {
|
||||
let callback: ExecCallback | null
|
||||
let child: FakeChild
|
||||
let deps: DesktopScriptProviderSupervisorDeps
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
callback = null
|
||||
child = new FakeChild()
|
||||
deps = {
|
||||
platform: vi.fn<DesktopScriptProviderSupervisorDeps['platform']>(() => 'linux'),
|
||||
resolveScriptPath: vi.fn(() => '/fixed/runtime.py'),
|
||||
execFile: vi.fn((_command, _args, _options, execCallback) => {
|
||||
callback = execCallback as ExecCallback
|
||||
return child as never
|
||||
}) as never,
|
||||
randomUUID: vi.fn(() => 'operation-1'),
|
||||
temporaryDirectory: vi.fn(() => '/private/tmp'),
|
||||
mkdtemp: vi.fn(
|
||||
async () => '/private/tmp/orca-computer-use-operation'
|
||||
) as unknown as DesktopScriptProviderSupervisorDeps['mkdtemp'],
|
||||
chmod: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
rmSync: vi.fn(),
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('constructs the fixed Linux invocation and waits for callback plus exit', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
await flushPreparation()
|
||||
|
||||
const operationPath = join('/private/tmp/orca-computer-use-operation', 'operation.json')
|
||||
expect(deps.mkdtemp).toHaveBeenCalledWith(join('/private/tmp', 'orca-computer-use-'))
|
||||
expect(deps.chmod).toHaveBeenCalledWith('/private/tmp/orca-computer-use-operation', 0o700)
|
||||
expect(deps.writeFile).toHaveBeenCalledWith(operationPath, '{"tool":"list_apps"}', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
expect(deps.execFile).toHaveBeenCalledWith(
|
||||
'python3',
|
||||
['/fixed/runtime.py', operationPath],
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
maxBuffer: DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
|
||||
windowsHide: true
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
callback!(null, '{"ok":true}', '')
|
||||
let settled = false
|
||||
void result.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
expect(deps.rmSync).not.toHaveBeenCalled()
|
||||
|
||||
child.emit('exit', 0, null)
|
||||
await expect(result).resolves.toEqual({
|
||||
stdout: '{"ok":true}',
|
||||
stderr: '',
|
||||
error: null
|
||||
})
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-operation', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for the exec callback when exit arrives first', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
await flushPreparation()
|
||||
|
||||
child.emit('exit', 0, null)
|
||||
let settled = false
|
||||
void result.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
expect(deps.rmSync).not.toHaveBeenCalled()
|
||||
|
||||
callback!(null, '{"ok":true}', '')
|
||||
await expect(result).resolves.toMatchObject({ error: null })
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the main-selected platform, script, and environment plan', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const first = supervisor.execute({ tool: 'list_apps' })
|
||||
await flushPreparation()
|
||||
callback!(null, '{"ok":true}', '')
|
||||
child.emit('exit', 0, null)
|
||||
await first
|
||||
|
||||
const second = supervisor.execute({ tool: 'list_apps' })
|
||||
await flushPreparation()
|
||||
callback!(null, '{"ok":true}', '')
|
||||
child.emit('exit', 0, null)
|
||||
await second
|
||||
|
||||
expect(deps.platform).toHaveBeenCalledTimes(1)
|
||||
expect(deps.resolveScriptPath).toHaveBeenCalledTimes(1)
|
||||
expect(deps.execFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('escalates a Linux deadline while retaining state until confirmed exit', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
await flushPreparation()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS)
|
||||
await rejection
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(deps.rmSync).not.toHaveBeenCalled()
|
||||
expect(child.listenerCount('exit')).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
|
||||
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
expect(deps.rmSync).not.toHaveBeenCalled()
|
||||
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels Linux force escalation after the timed-out child exits', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
await flushPreparation()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS)
|
||||
await rejection
|
||||
child.emit('exit', null, 'SIGTERM')
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
|
||||
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses forceful Windows termination without POSIX signal escalation', async () => {
|
||||
vi.mocked(deps.platform).mockReturnValue('windows')
|
||||
vi.mocked(deps.resolveScriptPath).mockReturnValue('/fixed/runtime.ps1')
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
await flushPreparation()
|
||||
|
||||
expect(deps.execFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
'/fixed/runtime.ps1',
|
||||
join('/private/tmp/orca-computer-use-operation', 'operation.json')
|
||||
],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS)
|
||||
await rejection
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
child.emit('exit', null, 'SIGTERM')
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels an operation before directory creation completes', async () => {
|
||||
const directory = deferred<string>()
|
||||
vi.mocked(deps.mkdtemp).mockReturnValueOnce(directory.promise)
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toThrow('supervisor shut down')
|
||||
|
||||
supervisor.shutdown()
|
||||
await rejection
|
||||
expect(deps.execFile).not.toHaveBeenCalled()
|
||||
|
||||
directory.resolve('/private/tmp/orca-computer-use-late')
|
||||
await flushPreparation()
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-late', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 100
|
||||
})
|
||||
expect(deps.execFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels after directory creation without spawning after an async setup resumes', async () => {
|
||||
const chmod = deferred<void>()
|
||||
vi.mocked(deps.chmod).mockReturnValueOnce(chmod.promise)
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toThrow('supervisor shut down')
|
||||
await flushPreparation()
|
||||
expect(deps.chmod).toHaveBeenCalled()
|
||||
|
||||
supervisor.shutdown()
|
||||
await rejection
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
chmod.resolve()
|
||||
await flushPreparation()
|
||||
expect(deps.writeFile).not.toHaveBeenCalled()
|
||||
expect(deps.execFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains a spawned operation and its file until shutdown confirms exit', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toThrow('supervisor shut down')
|
||||
await flushPreparation()
|
||||
|
||||
supervisor.shutdown()
|
||||
await rejection
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(deps.rmSync).not.toHaveBeenCalled()
|
||||
expect(child.listenerCount('exit')).toBe(1)
|
||||
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('handles a child without a pid and retains an asynchronous error listener', async () => {
|
||||
child.pid = undefined
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
await flushPreparation()
|
||||
|
||||
await expect(result).rejects.toThrow('did not report a pid')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
expect(() => child.emit('error', new Error('spawn failed asynchronously'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a registered child error but cleans up only after exit', async () => {
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
const result = supervisor.execute({ tool: 'list_apps' })
|
||||
const rejection = expect(result).rejects.toThrow('provider failed: handle failed')
|
||||
await flushPreparation()
|
||||
|
||||
child.emit('error', new Error('handle failed'))
|
||||
await rejection
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(deps.rmSync).not.toHaveBeenCalled()
|
||||
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes the private directory when setup fails before spawn', async () => {
|
||||
vi.mocked(deps.writeFile).mockRejectedValueOnce(new Error('operation write failed'))
|
||||
const supervisor = new DesktopScriptProviderSupervisor(deps)
|
||||
|
||||
await expect(supervisor.execute({ tool: 'list_apps' })).rejects.toThrow(
|
||||
'operation write failed'
|
||||
)
|
||||
expect(deps.execFile).not.toHaveBeenCalled()
|
||||
expect(deps.rmSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { SupervisedDesktopProviderResult } from './computer-provider-supervisor-protocol'
|
||||
import {
|
||||
createDesktopScriptProviderSupervisorDeps,
|
||||
desktopProviderCommandArgs,
|
||||
desktopProviderExecutionPlan,
|
||||
desktopProviderExecutionResult,
|
||||
DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
|
||||
type DesktopProviderExecutionPlan,
|
||||
type DesktopScriptProviderSupervisorDeps
|
||||
} from './desktop-script-provider-process-invocation'
|
||||
import { serializeDesktopScriptProviderRequest } from './desktop-script-provider-request-validation'
|
||||
import type { BridgeRequest } from './desktop-script-provider-types'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
export const DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS = 30_000
|
||||
export const DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS = 1_000
|
||||
|
||||
type DesktopProviderOperation = {
|
||||
id: string
|
||||
platform: DesktopProviderExecutionPlan['platform']
|
||||
child: ChildProcess | null
|
||||
directory: string | null
|
||||
settled: boolean
|
||||
terminating: boolean
|
||||
exitConfirmed: boolean
|
||||
callbackResult: SupervisedDesktopProviderResult | null
|
||||
deadlineTimer: NodeJS.Timeout | null
|
||||
forceKillTimer: NodeJS.Timeout | null
|
||||
resolve: (result: SupervisedDesktopProviderResult) => void
|
||||
reject: (error: Error) => void
|
||||
cleanupListeners: () => void
|
||||
}
|
||||
|
||||
function ignoreUnownedChildError(): void {}
|
||||
|
||||
export class DesktopScriptProviderSupervisor {
|
||||
private readonly operations = new Map<string, DesktopProviderOperation>()
|
||||
private executionPlan: DesktopProviderExecutionPlan | null = null
|
||||
|
||||
constructor(
|
||||
private readonly deps: DesktopScriptProviderSupervisorDeps = createDesktopScriptProviderSupervisorDeps()
|
||||
) {}
|
||||
|
||||
execute(request: BridgeRequest): Promise<SupervisedDesktopProviderResult> {
|
||||
const plan = this.executionPlan ?? desktopProviderExecutionPlan(this.deps)
|
||||
this.executionPlan = plan
|
||||
let resolve = (_result: SupervisedDesktopProviderResult): void => {}
|
||||
let reject = (_error: Error): void => {}
|
||||
const result = new Promise<SupervisedDesktopProviderResult>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
const operation: DesktopProviderOperation = {
|
||||
id: this.deps.randomUUID(),
|
||||
platform: plan.platform,
|
||||
child: null,
|
||||
directory: null,
|
||||
settled: false,
|
||||
terminating: false,
|
||||
exitConfirmed: false,
|
||||
callbackResult: null,
|
||||
deadlineTimer: null,
|
||||
forceKillTimer: null,
|
||||
resolve,
|
||||
reject,
|
||||
cleanupListeners: () => {}
|
||||
}
|
||||
this.operations.set(operation.id, operation)
|
||||
void this.prepareAndSpawn(operation, plan, request)
|
||||
return result
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
for (const operation of this.operations.values()) {
|
||||
operation.terminating = true
|
||||
this.clearOperationTimers(operation)
|
||||
this.rejectRequest(
|
||||
operation,
|
||||
new RuntimeClientError('accessibility_error', 'desktop provider supervisor shut down')
|
||||
)
|
||||
if (!operation.child || operation.exitConfirmed) {
|
||||
this.finishOperation(operation)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
operation.child.kill('SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareAndSpawn(
|
||||
operation: DesktopProviderOperation,
|
||||
plan: DesktopProviderExecutionPlan,
|
||||
request: BridgeRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
const directory = await this.deps.mkdtemp(
|
||||
join(this.deps.temporaryDirectory(), 'orca-computer-use-')
|
||||
)
|
||||
operation.directory = directory
|
||||
if (!this.isRegistered(operation) || operation.terminating) {
|
||||
this.removeOperationDirectory(operation)
|
||||
return
|
||||
}
|
||||
await this.deps.chmod(directory, 0o700)
|
||||
if (!this.isRegistered(operation) || operation.terminating) {
|
||||
this.removeOperationDirectory(operation)
|
||||
return
|
||||
}
|
||||
const operationPath = join(directory, 'operation.json')
|
||||
await this.deps.writeFile(operationPath, serializeDesktopScriptProviderRequest(request), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
if (!this.isRegistered(operation) || operation.terminating) {
|
||||
this.removeOperationDirectory(operation)
|
||||
return
|
||||
}
|
||||
this.spawnOperation(operation, plan, operationPath)
|
||||
} catch (error) {
|
||||
if (this.isRegistered(operation)) {
|
||||
this.failBeforeSpawn(operation, error)
|
||||
} else {
|
||||
this.removeOperationDirectory(operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spawnOperation(
|
||||
operation: DesktopProviderOperation,
|
||||
plan: DesktopProviderExecutionPlan,
|
||||
operationPath: string
|
||||
): void {
|
||||
const args = desktopProviderCommandArgs(plan, operationPath)
|
||||
let child: ChildProcess | null = null
|
||||
try {
|
||||
child = this.deps.execFile(
|
||||
plan.command,
|
||||
args,
|
||||
{
|
||||
env: plan.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
|
||||
windowsHide: true
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
operation.callbackResult = desktopProviderExecutionResult(error, stdout, stderr)
|
||||
this.completeAfterCallback(operation)
|
||||
}
|
||||
)
|
||||
child.on('error', ignoreUnownedChildError)
|
||||
if (typeof child.pid !== 'number' || child.pid <= 0) {
|
||||
throw new Error('desktop provider process did not report a pid')
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
child?.kill('SIGKILL')
|
||||
} catch {}
|
||||
this.failBeforeSpawn(operation, error)
|
||||
return
|
||||
}
|
||||
|
||||
operation.child = child
|
||||
const onError = (error: Error): void => this.handleChildError(operation, error)
|
||||
const onExit = (): void => this.handleChildExit(operation)
|
||||
child.on('error', onError)
|
||||
child.once('exit', onExit)
|
||||
child.off('error', ignoreUnownedChildError)
|
||||
operation.cleanupListeners = () => {
|
||||
child.off('error', onError)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', ignoreUnownedChildError)
|
||||
child.on('error', ignoreUnownedChildError)
|
||||
}
|
||||
operation.deadlineTimer = this.deps.setTimer(
|
||||
() => this.handleDeadline(operation),
|
||||
DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
private completeAfterCallback(operation: DesktopProviderOperation): void {
|
||||
if (
|
||||
!this.isRegistered(operation) ||
|
||||
operation.settled ||
|
||||
!operation.exitConfirmed ||
|
||||
!operation.callbackResult
|
||||
) {
|
||||
return
|
||||
}
|
||||
const result = operation.callbackResult
|
||||
operation.settled = true
|
||||
this.finishOperation(operation)
|
||||
operation.resolve(result)
|
||||
}
|
||||
|
||||
private handleChildError(operation: DesktopProviderOperation, error: Error): void {
|
||||
if (!this.isRegistered(operation) || operation.terminating) {
|
||||
return
|
||||
}
|
||||
operation.terminating = true
|
||||
this.clearOperationTimers(operation)
|
||||
this.rejectRequest(
|
||||
operation,
|
||||
new RuntimeClientError('accessibility_error', `desktop provider failed: ${error.message}`)
|
||||
)
|
||||
try {
|
||||
operation.child?.kill('SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private handleChildExit(operation: DesktopProviderOperation): void {
|
||||
if (!this.isRegistered(operation)) {
|
||||
return
|
||||
}
|
||||
operation.exitConfirmed = true
|
||||
if (operation.settled) {
|
||||
this.finishOperation(operation)
|
||||
return
|
||||
}
|
||||
this.completeAfterCallback(operation)
|
||||
}
|
||||
|
||||
private handleDeadline(operation: DesktopProviderOperation): void {
|
||||
if (!this.isRegistered(operation) || operation.terminating) {
|
||||
return
|
||||
}
|
||||
operation.terminating = true
|
||||
operation.deadlineTimer = null
|
||||
this.rejectRequest(
|
||||
operation,
|
||||
new RuntimeClientError(
|
||||
'action_timeout',
|
||||
`desktop provider timed out after ${DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS}ms`
|
||||
)
|
||||
)
|
||||
if (operation.exitConfirmed) {
|
||||
this.finishOperation(operation)
|
||||
return
|
||||
}
|
||||
try {
|
||||
operation.child?.kill(operation.platform === 'windows' ? 'SIGKILL' : 'SIGTERM')
|
||||
} catch {}
|
||||
if (operation.platform === 'windows') {
|
||||
return
|
||||
}
|
||||
operation.forceKillTimer = this.deps.setTimer(() => {
|
||||
operation.forceKillTimer = null
|
||||
try {
|
||||
operation.child?.kill('SIGKILL')
|
||||
} catch {}
|
||||
}, DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
|
||||
}
|
||||
|
||||
private failBeforeSpawn(operation: DesktopProviderOperation, error: unknown): void {
|
||||
if (!this.isRegistered(operation)) {
|
||||
this.removeOperationDirectory(operation)
|
||||
return
|
||||
}
|
||||
this.operations.delete(operation.id)
|
||||
this.removeOperationDirectory(operation)
|
||||
this.rejectRequest(
|
||||
operation,
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private finishOperation(operation: DesktopProviderOperation): void {
|
||||
if (!this.isRegistered(operation)) {
|
||||
return
|
||||
}
|
||||
this.operations.delete(operation.id)
|
||||
this.clearOperationTimers(operation)
|
||||
operation.cleanupListeners()
|
||||
this.removeOperationDirectory(operation)
|
||||
}
|
||||
|
||||
private rejectRequest(operation: DesktopProviderOperation, error: Error): void {
|
||||
if (operation.settled) {
|
||||
return
|
||||
}
|
||||
operation.settled = true
|
||||
operation.reject(error)
|
||||
}
|
||||
|
||||
private clearOperationTimers(operation: DesktopProviderOperation): void {
|
||||
if (operation.deadlineTimer) {
|
||||
this.deps.clearTimer(operation.deadlineTimer)
|
||||
operation.deadlineTimer = null
|
||||
}
|
||||
if (operation.forceKillTimer) {
|
||||
this.deps.clearTimer(operation.forceKillTimer)
|
||||
operation.forceKillTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private removeOperationDirectory(operation: DesktopProviderOperation): void {
|
||||
if (!operation.directory) {
|
||||
return
|
||||
}
|
||||
const directory = operation.directory
|
||||
operation.directory = null
|
||||
try {
|
||||
this.deps.rmSync(directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 100
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private isRegistered(operation: DesktopProviderOperation): boolean {
|
||||
return this.operations.get(operation.id) === operation
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,52 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, operationFiles, mkdtempMock, rmMock, writeFileMock } = vi.hoisted(() => {
|
||||
const files = new Map<string, string>()
|
||||
return {
|
||||
execFileMock: vi.fn(),
|
||||
operationFiles: files,
|
||||
mkdtempMock: vi.fn(async (prefix: string) => `${prefix}${files.size}`),
|
||||
rmMock: vi.fn(async () => undefined),
|
||||
writeFileMock: vi.fn(async (filePath: string, data: string | Buffer) => {
|
||||
files.set(filePath, Buffer.isBuffer(data) ? data.toString('utf8') : data)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock
|
||||
const { executeSupervisedDesktopProviderMock } = vi.hoisted(() => ({
|
||||
executeSupervisedDesktopProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
mkdtemp: mkdtempMock,
|
||||
rm: rmMock,
|
||||
writeFile: writeFileMock
|
||||
vi.mock('./computer-provider-supervisor-client', () => ({
|
||||
executeSupervisedDesktopProvider: executeSupervisedDesktopProviderMock
|
||||
}))
|
||||
|
||||
export async function createDesktopScriptProviderClient(
|
||||
platform: 'linux' | 'windows',
|
||||
executablePath: string
|
||||
_platform: 'linux' | 'windows',
|
||||
_executablePath: string
|
||||
) {
|
||||
const { DesktopScriptProviderClient } = await import('./desktop-script-provider-client')
|
||||
return new DesktopScriptProviderClient(platform, executablePath)
|
||||
return new DesktopScriptProviderClient()
|
||||
}
|
||||
|
||||
export function resetDesktopScriptProviderTestHarness(): void {
|
||||
vi.useRealTimers()
|
||||
execFileMock.mockReset()
|
||||
operationFiles.clear()
|
||||
mkdtempMock.mockClear()
|
||||
rmMock.mockClear()
|
||||
writeFileMock.mockClear()
|
||||
}
|
||||
|
||||
export function mockDesktopProviderSubprocessThatIgnoresTimeout({
|
||||
kill,
|
||||
once
|
||||
}: {
|
||||
kill: (signal: string) => void
|
||||
once: () => void
|
||||
}): void {
|
||||
execFileMock.mockImplementationOnce(() => ({ kill, once }) as never)
|
||||
}
|
||||
|
||||
export function expectDesktopProviderSubprocessStarted(): void {
|
||||
expect(execFileMock).toHaveBeenCalled()
|
||||
executeSupervisedDesktopProviderMock.mockReset()
|
||||
}
|
||||
|
||||
export function expectDesktopProviderSubprocessStartCount(count: number): void {
|
||||
expect(execFileMock).toHaveBeenCalledTimes(count)
|
||||
expect(executeSupervisedDesktopProviderMock).toHaveBeenCalledTimes(count)
|
||||
}
|
||||
|
||||
export function mockBridgeExecutionError(message: string): void {
|
||||
executeSupervisedDesktopProviderMock.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: message,
|
||||
error: { message, killed: false }
|
||||
})
|
||||
}
|
||||
|
||||
export function mockBridgeResponse(
|
||||
response: unknown,
|
||||
inspectOperation?: (operation: Record<string, unknown>) => void
|
||||
): void {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
const operationPath = _args?.at(-1)
|
||||
if (inspectOperation && typeof operationPath === 'string') {
|
||||
const operation = operationFiles.get(operationPath)
|
||||
if (!operation) {
|
||||
throw new Error(`Missing mocked operation file: ${operationPath}`)
|
||||
executeSupervisedDesktopProviderMock.mockImplementationOnce(
|
||||
async (operation: Record<string, unknown>) => {
|
||||
inspectOperation?.(operation)
|
||||
return {
|
||||
stdout: JSON.stringify(response),
|
||||
stderr: '',
|
||||
error: null
|
||||
}
|
||||
inspectOperation(JSON.parse(operation) as Record<string, unknown>)
|
||||
}
|
||||
const done = callback as (error: Error | null, stdout: string, stderr: string) => void
|
||||
done(null, JSON.stringify(response), '')
|
||||
return null as never
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function sampleBridgeSnapshot(name: string, value: string) {
|
||||
|
||||
@@ -134,6 +134,7 @@ export type BridgeRequest = {
|
||||
to_y?: number
|
||||
click_count?: number
|
||||
mouse_button?: string
|
||||
modifiers?: string
|
||||
action?: string
|
||||
direction?: string
|
||||
pages?: number
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
chmodSyncMock,
|
||||
claimSupervisedMacOSProviderMock,
|
||||
connectMacOSProviderSocketMock,
|
||||
mkdtempSyncMock,
|
||||
resolveMacOSComputerUseExecutablePathMock,
|
||||
rmSyncMock,
|
||||
spawnMock,
|
||||
writeFileSyncMock
|
||||
releaseSupervisedMacOSProviderMock,
|
||||
startSupervisedMacOSProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
chmodSyncMock: vi.fn(),
|
||||
claimSupervisedMacOSProviderMock: vi.fn(),
|
||||
connectMacOSProviderSocketMock: vi.fn(),
|
||||
mkdtempSyncMock: vi.fn(),
|
||||
resolveMacOSComputerUseExecutablePathMock: vi.fn(),
|
||||
rmSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn()
|
||||
releaseSupervisedMacOSProviderMock: vi.fn(),
|
||||
startSupervisedMacOSProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
chmodSync: chmodSyncMock,
|
||||
mkdtempSync: mkdtempSyncMock,
|
||||
rmSync: rmSyncMock,
|
||||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('./macos-native-provider-paths', () => ({
|
||||
resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock
|
||||
vi.mock('./computer-provider-supervisor-client', () => ({
|
||||
claimSupervisedMacOSProvider: claimSupervisedMacOSProviderMock,
|
||||
releaseSupervisedMacOSProvider: releaseSupervisedMacOSProviderMock,
|
||||
startSupervisedMacOSProvider: startSupervisedMacOSProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('./macos-native-provider-socket', () => ({
|
||||
@@ -62,11 +46,6 @@ class FakeSocket extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeProvider extends EventEmitter {
|
||||
kill = vi.fn()
|
||||
unref = vi.fn()
|
||||
}
|
||||
|
||||
function pendingConnectThatRejectsOnAbort(signal?: AbortSignal): Promise<never> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
@@ -84,20 +63,22 @@ async function loadClientModule() {
|
||||
|
||||
describe('MacOSNativeProviderClient', () => {
|
||||
const sockets: FakeSocket[] = []
|
||||
const providers: FakeProvider[] = []
|
||||
let nextSessionId = 1
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sockets.length = 0
|
||||
providers.length = 0
|
||||
mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`)
|
||||
resolveMacOSComputerUseExecutablePathMock.mockReturnValue(
|
||||
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
|
||||
)
|
||||
spawnMock.mockImplementation(() => {
|
||||
const provider = new FakeProvider()
|
||||
providers.push(provider)
|
||||
return provider
|
||||
nextSessionId = 1
|
||||
claimSupervisedMacOSProviderMock.mockResolvedValue(undefined)
|
||||
releaseSupervisedMacOSProviderMock.mockResolvedValue(undefined)
|
||||
startSupervisedMacOSProviderMock.mockImplementation(async () => {
|
||||
const sessionId = `session-${nextSessionId++}`
|
||||
return {
|
||||
sessionId,
|
||||
socketPath: `/tmp/${sessionId}/provider.sock`,
|
||||
socketToken: `token-${sessionId}`,
|
||||
termination: new Promise<never>(() => {})
|
||||
}
|
||||
})
|
||||
connectMacOSProviderSocketMock.mockImplementation(async () => {
|
||||
const socket = new FakeSocket()
|
||||
@@ -107,13 +88,10 @@ describe('MacOSNativeProviderClient', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
chmodSyncMock.mockReset()
|
||||
claimSupervisedMacOSProviderMock.mockReset()
|
||||
connectMacOSProviderSocketMock.mockReset()
|
||||
mkdtempSyncMock.mockReset()
|
||||
resolveMacOSComputerUseExecutablePathMock.mockReset()
|
||||
rmSyncMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
writeFileSyncMock.mockReset()
|
||||
releaseSupervisedMacOSProviderMock.mockReset()
|
||||
startSupervisedMacOSProviderMock.mockReset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -167,7 +145,6 @@ describe('MacOSNativeProviderClient', () => {
|
||||
const firstRejection = expect(firstCall).rejects.toThrow('active helper failed')
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1))
|
||||
const firstSocket = sockets[0]!
|
||||
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
|
||||
await vi.waitFor(() => expect(firstSocket.writes).toHaveLength(1))
|
||||
|
||||
firstSocket.emit('error', new Error('active helper failed'))
|
||||
@@ -176,10 +153,7 @@ describe('MacOSNativeProviderClient', () => {
|
||||
expect(firstSocket.listenerCount('data')).toBe(0)
|
||||
expect(firstSocket.listenerCount('close')).toBe(0)
|
||||
expect(firstSocket.listenerCount('error')).toBe(1)
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(firstSocketDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
|
||||
const secondCall = client.capabilities()
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(2))
|
||||
@@ -200,24 +174,25 @@ describe('MacOSNativeProviderClient', () => {
|
||||
})
|
||||
|
||||
it('invalidates the active socket when a helper write fails', async () => {
|
||||
const firstSocket = new FakeSocket()
|
||||
firstSocket.writeError = new Error('write EPIPE')
|
||||
connectMacOSProviderSocketMock.mockImplementationOnce(async () => {
|
||||
sockets.push(firstSocket)
|
||||
return firstSocket
|
||||
})
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
|
||||
const firstCall = client.capabilities()
|
||||
const firstRejection = expect(firstCall).rejects.toThrow('write EPIPE')
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1))
|
||||
const firstSocket = sockets[0]!
|
||||
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
|
||||
firstSocket.writeError = new Error('write EPIPE')
|
||||
|
||||
await expect(firstCall).rejects.toThrow('write EPIPE')
|
||||
await firstRejection
|
||||
expect(firstSocket.destroyed).toBe(true)
|
||||
expect(firstSocket.listenerCount('data')).toBe(0)
|
||||
expect(firstSocket.listenerCount('close')).toBe(0)
|
||||
expect(firstSocket.listenerCount('error')).toBe(1)
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(firstSocketDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
|
||||
const secondCall = client.capabilities()
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(2))
|
||||
@@ -278,6 +253,16 @@ describe('MacOSNativeProviderClient', () => {
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('Click requires')
|
||||
})
|
||||
await expect(
|
||||
client.action('click', {
|
||||
app: 'TextEdit',
|
||||
elementIndex: 0,
|
||||
modifiers: 'CmdOrCtrl+A'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('Click modifiers accept modifier keys only')
|
||||
})
|
||||
await expect(client.action('typeText', { app: 'TextEdit', text: '' })).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('Missing text')
|
||||
@@ -293,9 +278,8 @@ describe('MacOSNativeProviderClient', () => {
|
||||
message: expect.stringContaining('Hotkey requires')
|
||||
})
|
||||
|
||||
expect(providers).toHaveLength(0)
|
||||
expect(sockets).toHaveLength(0)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(startSupervisedMacOSProviderMock).not.toHaveBeenCalled()
|
||||
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -342,7 +326,7 @@ describe('MacOSNativeProviderClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the parent-owned token file after the helper socket connects', async () => {
|
||||
it('claims the parent-owned helper session after the socket connects', async () => {
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
|
||||
@@ -351,11 +335,8 @@ describe('MacOSNativeProviderClient', () => {
|
||||
const socket = sockets[0]!
|
||||
await vi.waitFor(() => expect(socket.writes).toHaveLength(1))
|
||||
const request = JSON.parse(socket.writes[0]!) as { id: number }
|
||||
const socketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
|
||||
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(join(socketDirectory, 'provider.token'), {
|
||||
force: true
|
||||
})
|
||||
expect(claimSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
|
||||
socket.emit(
|
||||
'data',
|
||||
@@ -368,7 +349,7 @@ describe('MacOSNativeProviderClient', () => {
|
||||
await expect(call).resolves.toMatchObject({ protocolVersion: 1 })
|
||||
})
|
||||
|
||||
it('does not let a superseded startup clean up the replacement helper token', async () => {
|
||||
it('does not let a superseded startup release the replacement helper session', async () => {
|
||||
const pendingConnects: {
|
||||
resolve: (socket: FakeSocket) => void
|
||||
}[] = []
|
||||
@@ -383,7 +364,6 @@ describe('MacOSNativeProviderClient', () => {
|
||||
|
||||
const firstCall = client.capabilities()
|
||||
await vi.waitFor(() => expect(pendingConnects).toHaveLength(1))
|
||||
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
|
||||
|
||||
client.shutdown()
|
||||
|
||||
@@ -399,9 +379,8 @@ describe('MacOSNativeProviderClient', () => {
|
||||
|
||||
await expect(firstCall).rejects.toThrow('native macOS provider startup was superseded')
|
||||
expect(firstSocket.destroyed).toBe(true)
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(join(firstSocketDirectory, 'provider.token'), {
|
||||
force: true
|
||||
})
|
||||
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
expect(releaseSupervisedMacOSProviderMock).not.toHaveBeenCalledWith('session-2')
|
||||
|
||||
secondSocket.emit(
|
||||
'data',
|
||||
@@ -414,23 +393,64 @@ describe('MacOSNativeProviderClient', () => {
|
||||
await expect(secondCall).resolves.toMatchObject({ protocolVersion: 1 })
|
||||
})
|
||||
|
||||
it('terminates the helper process when socket startup fails', async () => {
|
||||
const providerKill = vi.fn()
|
||||
spawnMock.mockReturnValueOnce({ unref: vi.fn(), kill: providerKill })
|
||||
it('releases a claimed session when shutdown lands before client adoption', async () => {
|
||||
let resolveClaim = (): void => {}
|
||||
claimSupervisedMacOSProviderMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveClaim = resolve
|
||||
})
|
||||
)
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
|
||||
const call = client.capabilities()
|
||||
await vi.waitFor(() => expect(claimSupervisedMacOSProviderMock).toHaveBeenCalledTimes(1))
|
||||
const socket = sockets[0]!
|
||||
|
||||
resolveClaim()
|
||||
queueMicrotask(() => client.shutdown())
|
||||
|
||||
await expect(call).rejects.toThrow('native macOS provider startup was superseded')
|
||||
expect(socket.destroyed).toBe(true)
|
||||
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('releases the supervised helper session when socket startup fails', async () => {
|
||||
connectMacOSProviderSocketMock.mockRejectedValueOnce(new Error('socket did not open'))
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
|
||||
await expect(client.capabilities()).rejects.toThrow('socket did not open')
|
||||
|
||||
expect(providerKill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(expect.stringContaining('orca-computer-use-'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('rejects helper spawn errors before socket connection and removes temp state', async () => {
|
||||
it('rejects supervisor startup errors before opening a socket', async () => {
|
||||
startSupervisedMacOSProviderMock.mockRejectedValueOnce(
|
||||
new Error('native macOS helper app failed to start: helper missing')
|
||||
)
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
|
||||
await expect(client.capabilities()).rejects.toThrow(
|
||||
'native macOS helper app failed to start: helper missing'
|
||||
)
|
||||
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts a pending socket connection when the supervised helper exits', async () => {
|
||||
let rejectTermination = (_error: Error): void => {}
|
||||
const termination = new Promise<never>((_resolve, reject) => {
|
||||
rejectTermination = reject
|
||||
})
|
||||
void termination.catch(() => undefined)
|
||||
startSupervisedMacOSProviderMock.mockResolvedValueOnce({
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-session-1',
|
||||
termination
|
||||
})
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
connectMacOSProviderSocketMock.mockImplementation((_path, _timeout, signal?: AbortSignal) =>
|
||||
@@ -438,36 +458,13 @@ describe('MacOSNativeProviderClient', () => {
|
||||
)
|
||||
|
||||
const call = client.capabilities()
|
||||
await vi.waitFor(() => expect(providers).toHaveLength(1))
|
||||
const socketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
|
||||
await vi.waitFor(() => expect(connectMacOSProviderSocketMock).toHaveBeenCalledTimes(1))
|
||||
const connectSignal = connectMacOSProviderSocketMock.mock.calls[0]?.[2] as AbortSignal
|
||||
expect(connectSignal.aborted).toBe(false)
|
||||
providers[0]!.emit('error', new Error('helper missing'))
|
||||
rejectTermination(new Error('native macOS helper app exited with code 13'))
|
||||
|
||||
await expect(call).rejects.toThrow('native macOS helper app failed to start: helper missing')
|
||||
await expect(call).rejects.toThrow('native macOS helper app exited with code 13')
|
||||
expect(connectSignal.aborted).toBe(true)
|
||||
expect(providers[0]!.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(socketDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects helper exits before socket connection and aborts the pending connect', async () => {
|
||||
const { MacOSNativeProviderClient } = await loadClientModule()
|
||||
const client = new MacOSNativeProviderClient()
|
||||
connectMacOSProviderSocketMock.mockImplementation((_path, _timeout, signal?: AbortSignal) =>
|
||||
pendingConnectThatRejectsOnAbort(signal)
|
||||
)
|
||||
|
||||
const call = client.capabilities()
|
||||
await vi.waitFor(() => expect(providers).toHaveLength(1))
|
||||
const connectSignal = connectMacOSProviderSocketMock.mock.calls[0]?.[2] as AbortSignal
|
||||
providers[0]!.emit('exit', 13, null)
|
||||
|
||||
await expect(call).rejects.toThrow('native macOS helper app exited before connecting: code 13')
|
||||
expect(connectSignal.aborted).toBe(true)
|
||||
expect(providers[0]!.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import type net from 'node:net'
|
||||
import type {
|
||||
ComputerActionResult,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
type PendingNativeRequest,
|
||||
writeNativeProviderLine
|
||||
} from './macos-native-provider-contract'
|
||||
import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths'
|
||||
import {
|
||||
attachMacOSNativeProviderSocketListeners,
|
||||
consumeNativeProviderLines,
|
||||
releaseMacOSNativeProviderSocketSession,
|
||||
startMacOSNativeProviderSocket
|
||||
} from './macos-native-provider-transport'
|
||||
import { validateComputerProviderActionParams } from './computer-provider-action-validation'
|
||||
@@ -33,7 +32,7 @@ export class MacOSNativeProviderClient {
|
||||
private socket: net.Socket | null = null
|
||||
private socketStartPromise: Promise<net.Socket> | null = null
|
||||
private socketPath: string | null = null
|
||||
private socketDirectory: string | null = null
|
||||
private socketSessionId: string | null = null
|
||||
private socketToken: string | null = null
|
||||
private nextId = 1
|
||||
private pending = new Map<number, PendingNativeRequest>()
|
||||
@@ -84,7 +83,7 @@ export class MacOSNativeProviderClient {
|
||||
)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
this.cleanupSocketDirectory()
|
||||
this.releaseSocketSession()
|
||||
}
|
||||
private async call(method: NativeMethod, params: unknown): Promise<unknown> {
|
||||
if (method !== 'handshake') {
|
||||
@@ -94,11 +93,7 @@ export class MacOSNativeProviderClient {
|
||||
}
|
||||
private async send(method: NativeMethod, params: unknown): Promise<unknown> {
|
||||
const id = this.nextId++
|
||||
const helperExecutablePath = resolveMacOSComputerUseExecutablePath()
|
||||
if (!helperExecutablePath) {
|
||||
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
|
||||
}
|
||||
const transport = await this.ensureSocketStarted(helperExecutablePath)
|
||||
const transport = await this.ensureSocketStarted()
|
||||
const token = this.socketToken
|
||||
const line = `${JSON.stringify({ id, method, params, token })}\n`
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
@@ -167,7 +162,7 @@ export class MacOSNativeProviderClient {
|
||||
private async ensureActionSupported(method: NativeActionMethod): Promise<void> {
|
||||
await this.ensureCapability('actions', macOSActionCapabilityKey(method))
|
||||
}
|
||||
private async ensureSocketStarted(helperExecutablePath: string): Promise<net.Socket> {
|
||||
private async ensureSocketStarted(): Promise<net.Socket> {
|
||||
if (this.socket && !this.socket.destroyed) {
|
||||
return this.socket
|
||||
}
|
||||
@@ -176,7 +171,7 @@ export class MacOSNativeProviderClient {
|
||||
if (this.socketStartPromise) {
|
||||
return await this.socketStartPromise
|
||||
}
|
||||
const socketStartPromise = this.startSocket(helperExecutablePath)
|
||||
const socketStartPromise = this.startSocket()
|
||||
this.socketStartPromise = socketStartPromise
|
||||
try {
|
||||
return await socketStartPromise
|
||||
@@ -186,15 +181,22 @@ export class MacOSNativeProviderClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
private async startSocket(helperExecutablePath: string): Promise<net.Socket> {
|
||||
private async startSocket(): Promise<net.Socket> {
|
||||
const startGeneration = ++this.socketStartGeneration
|
||||
const started = await startMacOSNativeProviderSocket({
|
||||
helperExecutablePath,
|
||||
isCurrent: (socketPath) =>
|
||||
this.socketStartGeneration === startGeneration &&
|
||||
(this.socketPath === null || this.socketPath === socketPath)
|
||||
})
|
||||
this.socketDirectory = started.socketDirectory
|
||||
if (this.socketStartGeneration !== startGeneration) {
|
||||
started.socket.destroy()
|
||||
void releaseMacOSNativeProviderSocketSession(started.sessionId).catch(() => undefined)
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'native macOS provider startup was superseded'
|
||||
)
|
||||
}
|
||||
this.socketSessionId = started.sessionId
|
||||
this.socketPath = started.socketPath
|
||||
this.socketToken = started.socketToken
|
||||
const socket = started.socket
|
||||
@@ -246,7 +248,7 @@ export class MacOSNativeProviderClient {
|
||||
this.cleanupActiveSocketListeners()
|
||||
this.socket = null
|
||||
this.socketBuffer = ''
|
||||
this.cleanupSocketDirectory()
|
||||
this.releaseSocketSession()
|
||||
this.rejectPending(
|
||||
new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed')
|
||||
)
|
||||
@@ -263,7 +265,7 @@ export class MacOSNativeProviderClient {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.cleanupSocketDirectory()
|
||||
this.releaseSocketSession()
|
||||
this.rejectPending(new RuntimeClientError('accessibility_error', error.message))
|
||||
}
|
||||
private invalidateActiveSocketAfterWriteFailure(
|
||||
@@ -279,16 +281,17 @@ export class MacOSNativeProviderClient {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.cleanupSocketDirectory()
|
||||
this.releaseSocketSession()
|
||||
this.rejectPending(error)
|
||||
}
|
||||
private cleanupSocketDirectory(): void {
|
||||
if (!this.socketDirectory) {
|
||||
private releaseSocketSession(): void {
|
||||
if (!this.socketSessionId) {
|
||||
return
|
||||
}
|
||||
rmSync(this.socketDirectory, { recursive: true, force: true })
|
||||
this.socketDirectory = null
|
||||
const sessionId = this.socketSessionId
|
||||
this.socketSessionId = null
|
||||
this.socketPath = null
|
||||
void releaseMacOSNativeProviderSocketSession(sessionId).catch(() => undefined)
|
||||
}
|
||||
private rejectPending(error: Error): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
|
||||
@@ -3,36 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
|
||||
|
||||
const {
|
||||
chmodSyncMock,
|
||||
claimSupervisedMacOSProviderMock,
|
||||
connectMacOSProviderSocketMock,
|
||||
mkdtempSyncMock,
|
||||
resolveMacOSComputerUseExecutablePathMock,
|
||||
rmSyncMock,
|
||||
spawnMock,
|
||||
writeFileSyncMock
|
||||
releaseSupervisedMacOSProviderMock,
|
||||
startSupervisedMacOSProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
chmodSyncMock: vi.fn(),
|
||||
claimSupervisedMacOSProviderMock: vi.fn(),
|
||||
connectMacOSProviderSocketMock: vi.fn(),
|
||||
mkdtempSyncMock: vi.fn(),
|
||||
resolveMacOSComputerUseExecutablePathMock: vi.fn(),
|
||||
rmSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn()
|
||||
releaseSupervisedMacOSProviderMock: vi.fn(),
|
||||
startSupervisedMacOSProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
chmodSync: chmodSyncMock,
|
||||
mkdtempSync: mkdtempSyncMock,
|
||||
rmSync: rmSyncMock,
|
||||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('./macos-native-provider-paths', () => ({
|
||||
resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock
|
||||
vi.mock('./computer-provider-supervisor-client', () => ({
|
||||
claimSupervisedMacOSProvider: claimSupervisedMacOSProviderMock,
|
||||
releaseSupervisedMacOSProvider: releaseSupervisedMacOSProviderMock,
|
||||
startSupervisedMacOSProvider: startSupervisedMacOSProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('./macos-native-provider-socket', () => ({
|
||||
@@ -61,11 +46,6 @@ class FakeSocket extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeProvider extends EventEmitter {
|
||||
kill = vi.fn()
|
||||
unref = vi.fn()
|
||||
}
|
||||
|
||||
async function loadClientModule() {
|
||||
vi.resetModules()
|
||||
return await import('./macos-native-provider-client')
|
||||
@@ -87,20 +67,17 @@ function macOSProviderCapabilities() {
|
||||
|
||||
describe('MacOSNativeProviderClient paste validation', () => {
|
||||
const sockets: FakeSocket[] = []
|
||||
const providers: FakeProvider[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sockets.length = 0
|
||||
providers.length = 0
|
||||
mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`)
|
||||
resolveMacOSComputerUseExecutablePathMock.mockReturnValue(
|
||||
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
|
||||
)
|
||||
spawnMock.mockImplementation(() => {
|
||||
const provider = new FakeProvider()
|
||||
providers.push(provider)
|
||||
return provider
|
||||
claimSupervisedMacOSProviderMock.mockResolvedValue(undefined)
|
||||
releaseSupervisedMacOSProviderMock.mockResolvedValue(undefined)
|
||||
startSupervisedMacOSProviderMock.mockResolvedValue({
|
||||
sessionId: 'session-1',
|
||||
socketPath: '/tmp/session-1/provider.sock',
|
||||
socketToken: 'token-session-1',
|
||||
termination: new Promise<never>(() => {})
|
||||
})
|
||||
connectMacOSProviderSocketMock.mockImplementation(async () => {
|
||||
const socket = new FakeSocket()
|
||||
@@ -110,13 +87,10 @@ describe('MacOSNativeProviderClient paste validation', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
chmodSyncMock.mockReset()
|
||||
claimSupervisedMacOSProviderMock.mockReset()
|
||||
connectMacOSProviderSocketMock.mockReset()
|
||||
mkdtempSyncMock.mockReset()
|
||||
resolveMacOSComputerUseExecutablePathMock.mockReset()
|
||||
rmSyncMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
writeFileSyncMock.mockReset()
|
||||
releaseSupervisedMacOSProviderMock.mockReset()
|
||||
startSupervisedMacOSProviderMock.mockReset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -128,9 +102,8 @@ describe('MacOSNativeProviderClient paste validation', () => {
|
||||
const call = client.action('pasteText', { app: 'TextEdit', text })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(providers).toHaveLength(0)
|
||||
expect(sockets).toHaveLength(0)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(startSupervisedMacOSProviderMock).not.toHaveBeenCalled()
|
||||
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MACOS_HELPER_CLAIM_TIMEOUT_MS,
|
||||
MACOS_HELPER_FORCE_KILL_GRACE_MS,
|
||||
MacOSNativeProviderSupervisor,
|
||||
type MacOSNativeProviderSupervisorDeps
|
||||
} from './macos-native-provider-supervisor'
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
pid: number | undefined = 4321
|
||||
kill = vi.fn(() => true)
|
||||
unref = vi.fn()
|
||||
}
|
||||
|
||||
describe('MacOSNativeProviderSupervisor', () => {
|
||||
let child: FakeChild
|
||||
let deps: MacOSNativeProviderSupervisorDeps
|
||||
let events: unknown[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
child = new FakeChild()
|
||||
events = []
|
||||
let uuid = 0
|
||||
deps = {
|
||||
resolveExecutablePath: vi.fn(
|
||||
() => '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
|
||||
),
|
||||
spawn: vi.fn(() => child as never),
|
||||
randomUUID: vi.fn(() => `uuid-${++uuid}`),
|
||||
mkdtempSync: vi.fn(
|
||||
() => '/private/tmp/orca-computer-use-session'
|
||||
) as unknown as MacOSNativeProviderSupervisorDeps['mkdtempSync'],
|
||||
chmodSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
setTimer: (callback, timeoutMs) => setTimeout(callback, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('registers the helper pid and private session state before returning', () => {
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
|
||||
const started = supervisor.start()
|
||||
|
||||
expect(started).toEqual({
|
||||
sessionId: 'uuid-2',
|
||||
socketPath: join('/private/tmp/orca-computer-use-session', 'provider.sock'),
|
||||
socketToken: 'uuid-1'
|
||||
})
|
||||
expect(deps.spawn).toHaveBeenCalledWith(
|
||||
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos',
|
||||
[
|
||||
'--agent',
|
||||
join('/private/tmp/orca-computer-use-session', 'provider.sock'),
|
||||
'--token-file',
|
||||
join('/private/tmp/orca-computer-use-session', 'provider.token')
|
||||
],
|
||||
{ detached: true, stdio: 'ignore' }
|
||||
)
|
||||
expect(deps.chmodSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', 0o700)
|
||||
expect(deps.writeFileSync).toHaveBeenCalledWith(
|
||||
join('/private/tmp/orca-computer-use-session', 'provider.token'),
|
||||
'uuid-1',
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
)
|
||||
expect(child.unref).toHaveBeenCalledTimes(1)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('claims a live session and removes only its token file', async () => {
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
const started = supervisor.start()
|
||||
|
||||
supervisor.claim(started.sessionId)
|
||||
await vi.advanceTimersByTimeAsync(MACOS_HELPER_CLAIM_TIMEOUT_MS)
|
||||
|
||||
expect(deps.rmSync).toHaveBeenCalledWith(
|
||||
join('/private/tmp/orca-computer-use-session', 'provider.token'),
|
||||
{ force: true }
|
||||
)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('reaps an unclaimed helper and removes private state on the parent deadline', async () => {
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
supervisor.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MACOS_HELPER_CLAIM_TIMEOUT_MS)
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
event: 'macos.sessionTerminated',
|
||||
sessionId: 'uuid-2',
|
||||
error: expect.objectContaining({ message: expect.stringContaining('not claimed') })
|
||||
})
|
||||
])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MACOS_HELPER_FORCE_KILL_GRACE_MS)
|
||||
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
expect(child.listenerCount('exit')).toBe(1)
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('releases a claimed session with graceful-to-force escalation', async () => {
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
const started = supervisor.start()
|
||||
supervisor.claim(started.sessionId)
|
||||
|
||||
supervisor.release(started.sessionId)
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(MACOS_HELPER_FORCE_KILL_GRACE_MS)
|
||||
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
expect(child.listenerCount('exit')).toBe(1)
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('kills every registered helper immediately when its sidecar owner dies', () => {
|
||||
const first = child
|
||||
const second = new FakeChild()
|
||||
second.pid = 4322
|
||||
vi.mocked(deps.spawn)
|
||||
.mockReturnValueOnce(first as never)
|
||||
.mockReturnValueOnce(second as never)
|
||||
vi.mocked(deps.mkdtempSync)
|
||||
.mockReturnValueOnce('/private/tmp/orca-computer-use-first')
|
||||
.mockReturnValueOnce('/private/tmp/orca-computer-use-second')
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
supervisor.start()
|
||||
supervisor.start()
|
||||
|
||||
supervisor.shutdown()
|
||||
|
||||
expect(first.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(second.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(first.listenerCount('exit')).toBe(1)
|
||||
expect(second.listenerCount('exit')).toBe(1)
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-first', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-second', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
first.emit('exit', null, 'SIGKILL')
|
||||
second.emit('exit', null, 'SIGKILL')
|
||||
expect(first.listenerCount('exit')).toBe(0)
|
||||
expect(second.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('reports unexpected helper exit and removes its private directory', () => {
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
supervisor.start()
|
||||
|
||||
child.emit('exit', 13, null)
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: 'uuid-2',
|
||||
error: expect.objectContaining({ message: expect.stringContaining('code 13') })
|
||||
})
|
||||
])
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a registered helper error and retains ownership until exit', () => {
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
const started = supervisor.start()
|
||||
|
||||
child.emit('error', new Error('helper handle failed'))
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: started.sessionId,
|
||||
error: expect.objectContaining({ message: expect.stringContaining('helper handle failed') })
|
||||
})
|
||||
])
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(child.listenerCount('exit')).toBe(1)
|
||||
expect(() => supervisor.claim(started.sessionId)).toThrow('no longer active')
|
||||
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('does not publish a session when spawn returns no pid', () => {
|
||||
child.pid = undefined
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
|
||||
expect(() => supervisor.start()).toThrow('helper process did not report a pid')
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(() => child.emit('error', new Error('spawn failed asynchronously'))).not.toThrow()
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the private directory when token setup fails before spawn', () => {
|
||||
vi.mocked(deps.writeFileSync).mockImplementationOnce(() => {
|
||||
throw new Error('token write failed')
|
||||
})
|
||||
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
|
||||
|
||||
expect(() => supervisor.start()).toThrow('token write failed')
|
||||
|
||||
expect(deps.spawn).not.toHaveBeenCalled()
|
||||
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
type ComputerProviderSupervisorEvent,
|
||||
type StartedSupervisedMacOSProvider
|
||||
} from './computer-provider-supervisor-protocol'
|
||||
import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
// Why: the parent deadline must outlast the 10-second connect budget and claim IPC.
|
||||
export const MACOS_HELPER_CLAIM_TIMEOUT_MS = 20_000
|
||||
export const MACOS_HELPER_FORCE_KILL_GRACE_MS = 1_000
|
||||
|
||||
type SupervisedMacOSProviderSession = {
|
||||
id: string
|
||||
pid: number
|
||||
child: ChildProcess
|
||||
socketDirectory: string
|
||||
socketTokenPath: string
|
||||
claimTimer: NodeJS.Timeout
|
||||
forceKillTimer: NodeJS.Timeout | null
|
||||
terminating: boolean
|
||||
cleanupListeners: () => void
|
||||
}
|
||||
|
||||
export type MacOSNativeProviderSupervisorDeps = {
|
||||
resolveExecutablePath: () => string | null
|
||||
spawn: typeof spawn
|
||||
randomUUID: () => string
|
||||
mkdtempSync: typeof mkdtempSync
|
||||
chmodSync: typeof chmodSync
|
||||
writeFileSync: typeof writeFileSync
|
||||
rmSync: typeof rmSync
|
||||
setTimer: (callback: () => void, timeoutMs: number) => NodeJS.Timeout
|
||||
clearTimer: (timer: NodeJS.Timeout) => void
|
||||
}
|
||||
|
||||
function ignoreUnownedChildError(): void {}
|
||||
|
||||
export class MacOSNativeProviderSupervisor {
|
||||
private readonly sessions = new Map<string, SupervisedMacOSProviderSession>()
|
||||
private readonly deps: MacOSNativeProviderSupervisorDeps
|
||||
|
||||
constructor(
|
||||
private readonly emitEvent: (event: ComputerProviderSupervisorEvent) => void = () => {},
|
||||
deps?: MacOSNativeProviderSupervisorDeps
|
||||
) {
|
||||
this.deps = deps ?? createDefaultDeps()
|
||||
}
|
||||
|
||||
start(): StartedSupervisedMacOSProvider {
|
||||
const executablePath = this.deps.resolveExecutablePath()
|
||||
if (!executablePath) {
|
||||
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
|
||||
}
|
||||
|
||||
let socketDirectory: string | null = null
|
||||
let child: ChildProcess | null = null
|
||||
try {
|
||||
socketDirectory = this.deps.mkdtempSync(join(tmpdir(), 'orca-computer-use-'))
|
||||
this.deps.chmodSync(socketDirectory, 0o700)
|
||||
const socketPath = join(socketDirectory, 'provider.sock')
|
||||
const socketToken = this.deps.randomUUID()
|
||||
const socketTokenPath = join(socketDirectory, 'provider.token')
|
||||
this.deps.writeFileSync(socketTokenPath, socketToken, { encoding: 'utf8', mode: 0o600 })
|
||||
child = this.deps.spawn(
|
||||
executablePath,
|
||||
['--agent', socketPath, '--token-file', socketTokenPath],
|
||||
{ detached: true, stdio: 'ignore' }
|
||||
)
|
||||
child.on('error', ignoreUnownedChildError)
|
||||
if (typeof child.pid !== 'number' || child.pid <= 0) {
|
||||
throw new Error('helper process did not report a pid')
|
||||
}
|
||||
|
||||
const sessionId = this.deps.randomUUID()
|
||||
child.unref()
|
||||
const session = this.createSession(sessionId, child, socketDirectory, socketTokenPath)
|
||||
this.sessions.set(sessionId, session)
|
||||
child.off('error', ignoreUnownedChildError)
|
||||
return { sessionId, socketPath, socketToken }
|
||||
} catch (error) {
|
||||
try {
|
||||
child?.kill('SIGKILL')
|
||||
} catch {}
|
||||
if (socketDirectory) {
|
||||
this.removeSocketDirectory(socketDirectory)
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`native macOS helper app failed to start: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
claim(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || session.terminating) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'native macOS helper session is no longer active'
|
||||
)
|
||||
}
|
||||
this.deps.clearTimer(session.claimTimer)
|
||||
this.deps.rmSync(session.socketTokenPath, { force: true })
|
||||
}
|
||||
|
||||
release(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || session.terminating) {
|
||||
return
|
||||
}
|
||||
this.beginTermination(session)
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
session.terminating = true
|
||||
this.deps.clearTimer(session.claimTimer)
|
||||
if (session.forceKillTimer) {
|
||||
this.deps.clearTimer(session.forceKillTimer)
|
||||
session.forceKillTimer = null
|
||||
}
|
||||
this.removeSocketDirectory(session.socketDirectory)
|
||||
try {
|
||||
session.child.kill('SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private createSession(
|
||||
id: string,
|
||||
child: ChildProcess,
|
||||
socketDirectory: string,
|
||||
socketTokenPath: string
|
||||
): SupervisedMacOSProviderSession {
|
||||
const onError = (error: Error): void => {
|
||||
const session = this.sessions.get(id)
|
||||
if (!session || session.terminating) {
|
||||
return
|
||||
}
|
||||
this.emitTermination(session, `native macOS helper app failed: ${error.message}`)
|
||||
session.terminating = true
|
||||
this.deps.clearTimer(session.claimTimer)
|
||||
this.removeSocketDirectory(session.socketDirectory)
|
||||
// Why: retain ownership until exit even if the signal cannot be delivered.
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
const session = this.sessions.get(id)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
if (!session.terminating) {
|
||||
const detail = typeof code === 'number' ? `code ${code}` : `signal ${signal ?? 'unknown'}`
|
||||
this.emitTermination(session, `native macOS helper app exited with ${detail}`)
|
||||
}
|
||||
this.finishSession(session)
|
||||
}
|
||||
child.on('error', onError)
|
||||
child.once('exit', onExit)
|
||||
|
||||
const session = {
|
||||
id,
|
||||
pid: child.pid!,
|
||||
child,
|
||||
socketDirectory,
|
||||
socketTokenPath,
|
||||
claimTimer: null as unknown as NodeJS.Timeout,
|
||||
forceKillTimer: null,
|
||||
terminating: false,
|
||||
cleanupListeners: () => {
|
||||
child.off('error', onError)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', ignoreUnownedChildError)
|
||||
child.on('error', ignoreUnownedChildError)
|
||||
}
|
||||
}
|
||||
session.claimTimer = this.deps.setTimer(() => {
|
||||
const active = this.sessions.get(id)
|
||||
if (!active || active.terminating) {
|
||||
return
|
||||
}
|
||||
this.emitTermination(
|
||||
active,
|
||||
`native macOS helper app was not claimed within ${MACOS_HELPER_CLAIM_TIMEOUT_MS}ms`
|
||||
)
|
||||
this.beginTermination(active)
|
||||
}, MACOS_HELPER_CLAIM_TIMEOUT_MS)
|
||||
return session
|
||||
}
|
||||
|
||||
private beginTermination(session: SupervisedMacOSProviderSession): void {
|
||||
session.terminating = true
|
||||
this.deps.clearTimer(session.claimTimer)
|
||||
this.removeSocketDirectory(session.socketDirectory)
|
||||
try {
|
||||
session.child.kill('SIGTERM')
|
||||
} catch {}
|
||||
session.forceKillTimer = this.deps.setTimer(() => {
|
||||
session.forceKillTimer = null
|
||||
try {
|
||||
session.child.kill('SIGKILL')
|
||||
} catch {}
|
||||
}, MACOS_HELPER_FORCE_KILL_GRACE_MS)
|
||||
}
|
||||
|
||||
private finishSession(session: SupervisedMacOSProviderSession): void {
|
||||
if (this.sessions.get(session.id) !== session) {
|
||||
return
|
||||
}
|
||||
this.sessions.delete(session.id)
|
||||
this.deps.clearTimer(session.claimTimer)
|
||||
if (session.forceKillTimer) {
|
||||
this.deps.clearTimer(session.forceKillTimer)
|
||||
}
|
||||
session.cleanupListeners()
|
||||
this.removeSocketDirectory(session.socketDirectory)
|
||||
}
|
||||
|
||||
private emitTermination(session: SupervisedMacOSProviderSession, message: string): void {
|
||||
this.emitEvent({
|
||||
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
|
||||
kind: 'event',
|
||||
event: 'macos.sessionTerminated',
|
||||
sessionId: session.id,
|
||||
error: { code: 'accessibility_error', message }
|
||||
})
|
||||
}
|
||||
|
||||
private removeSocketDirectory(socketDirectory: string): void {
|
||||
try {
|
||||
this.deps.rmSync(socketDirectory, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Why: cleanup failure must not prevent signaling or reaping the owned helper PID.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDeps(): MacOSNativeProviderSupervisorDeps {
|
||||
return {
|
||||
resolveExecutablePath: resolveMacOSComputerUseExecutablePath,
|
||||
spawn,
|
||||
randomUUID,
|
||||
mkdtempSync,
|
||||
chmodSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
setTimer: setTimeout,
|
||||
clearTimer: clearTimeout
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type net from 'node:net'
|
||||
import { release, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { release } from 'node:os'
|
||||
import {
|
||||
claimSupervisedMacOSProvider,
|
||||
releaseSupervisedMacOSProvider,
|
||||
startSupervisedMacOSProvider
|
||||
} from './computer-provider-supervisor-client'
|
||||
import { connectMacOSProviderSocket } from './macos-native-provider-socket'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
@@ -11,7 +12,7 @@ const HELPER_CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
export type StartedMacOSProviderSocket = {
|
||||
socket: net.Socket
|
||||
socketDirectory: string
|
||||
sessionId: string
|
||||
socketPath: string
|
||||
socketToken: string
|
||||
}
|
||||
@@ -64,100 +65,51 @@ export function consumeNativeProviderLines(
|
||||
}
|
||||
|
||||
export async function startMacOSNativeProviderSocket({
|
||||
helperExecutablePath,
|
||||
isCurrent
|
||||
}: {
|
||||
helperExecutablePath: string
|
||||
isCurrent: (socketPath: string) => boolean
|
||||
}): Promise<StartedMacOSProviderSocket> {
|
||||
const socketDirectory = mkdtempSync(join(tmpdir(), 'orca-computer-use-'))
|
||||
chmodSync(socketDirectory, 0o700)
|
||||
const socketPath = join(socketDirectory, 'provider.sock')
|
||||
const socketToken = randomUUID()
|
||||
const socketTokenPath = join(socketDirectory, 'provider.token')
|
||||
writeFileSync(socketTokenPath, socketToken, { encoding: 'utf8', mode: 0o600 })
|
||||
// Why: launching the nested helper via LaunchServices can make TCC evaluate
|
||||
// Orca.app as responsible; the signed helper executable owns this grant.
|
||||
const provider = spawnProvider(helperExecutablePath, socketPath, socketTokenPath)
|
||||
const providerFailure = waitForProviderLaunchFailure(provider)
|
||||
const started = await startSupervisedMacOSProvider()
|
||||
const connectAbort = new AbortController()
|
||||
let socket: net.Socket | null = null
|
||||
try {
|
||||
const socket = await Promise.race([
|
||||
connectMacOSProviderSocket(socketPath, HELPER_CONNECT_TIMEOUT_MS, connectAbort.signal),
|
||||
providerFailure.promise
|
||||
socket = await Promise.race([
|
||||
connectMacOSProviderSocket(
|
||||
started.socketPath,
|
||||
HELPER_CONNECT_TIMEOUT_MS,
|
||||
connectAbort.signal
|
||||
),
|
||||
started.termination
|
||||
])
|
||||
providerFailure.cleanup()
|
||||
rmSync(socketTokenPath, { force: true })
|
||||
if (!isCurrent(socketPath)) {
|
||||
if (!isCurrent(started.socketPath)) {
|
||||
socket.destroy()
|
||||
cleanupSocketDirectory(socketDirectory)
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'native macOS provider startup was superseded'
|
||||
)
|
||||
}
|
||||
return { socket, socketDirectory, socketPath, socketToken }
|
||||
await claimSupervisedMacOSProvider(started.sessionId)
|
||||
if (!isCurrent(started.socketPath)) {
|
||||
socket.destroy()
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'native macOS provider startup was superseded'
|
||||
)
|
||||
}
|
||||
return {
|
||||
socket,
|
||||
sessionId: started.sessionId,
|
||||
socketPath: started.socketPath,
|
||||
socketToken: started.socketToken
|
||||
}
|
||||
} catch (error) {
|
||||
connectAbort.abort()
|
||||
providerFailure.cleanup()
|
||||
// Why: connect failures happen after spawn; terminate the detached helper
|
||||
// so repeated startup attempts do not leave orphan providers.
|
||||
provider.kill('SIGTERM')
|
||||
if (isCurrent(socketPath)) {
|
||||
cleanupSocketDirectory(socketDirectory)
|
||||
}
|
||||
socket?.destroy()
|
||||
await releaseSupervisedMacOSProvider(started.sessionId).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSocketDirectory(socketDirectory: string): void {
|
||||
rmSync(socketDirectory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function spawnProvider(
|
||||
helperExecutablePath: string,
|
||||
socketPath: string,
|
||||
socketTokenPath: string
|
||||
): ChildProcess {
|
||||
const provider = spawn(
|
||||
helperExecutablePath,
|
||||
['--agent', socketPath, '--token-file', socketTokenPath],
|
||||
{ detached: true, stdio: 'ignore' }
|
||||
)
|
||||
provider.unref()
|
||||
return provider
|
||||
}
|
||||
|
||||
function waitForProviderLaunchFailure(provider: ChildProcess): {
|
||||
promise: Promise<never>
|
||||
cleanup: () => void
|
||||
} {
|
||||
let cleanup = (): void => {}
|
||||
const promise = new Promise<never>((_resolve, reject) => {
|
||||
const fail = (error: Error) => {
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`native macOS helper app failed to start: ${error.message}`
|
||||
)
|
||||
)
|
||||
}
|
||||
const exit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`native macOS helper app exited before connecting: ${
|
||||
typeof code === 'number' ? `code ${code}` : `signal ${signal ?? 'unknown'}`
|
||||
}`
|
||||
)
|
||||
)
|
||||
}
|
||||
provider.once('error', fail)
|
||||
provider.once('exit', exit)
|
||||
cleanup = () => {
|
||||
provider.off('error', fail)
|
||||
provider.off('exit', exit)
|
||||
}
|
||||
})
|
||||
return { promise, cleanup }
|
||||
export function releaseMacOSNativeProviderSocketSession(sessionId: string): Promise<void> {
|
||||
return releaseSupervisedMacOSProvider(sessionId)
|
||||
}
|
||||
|
||||
@@ -10,15 +10,35 @@ import {
|
||||
callComputerSidecarCapabilities,
|
||||
resetComputerSidecarForTest
|
||||
} from './sidecar-client'
|
||||
import { COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS } from './computer-sidecar-process'
|
||||
|
||||
const { forkMock } = vi.hoisted(() => ({
|
||||
forkMock: vi.fn()
|
||||
}))
|
||||
const { forkMock, supervisorHandleMock, supervisorSenderState, supervisorShutdownMock } =
|
||||
vi.hoisted(() => ({
|
||||
forkMock: vi.fn(),
|
||||
supervisorHandleMock: vi.fn(() => false),
|
||||
supervisorSenderState: {
|
||||
current: null as null | ((message: Record<string, unknown>) => void)
|
||||
},
|
||||
supervisorShutdownMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
fork: forkMock
|
||||
}))
|
||||
|
||||
vi.mock('./computer-provider-supervisor-host', () => ({
|
||||
ComputerProviderSupervisorHost: class {
|
||||
attach(sender: (message: Record<string, unknown>) => void): void {
|
||||
supervisorSenderState.current = sender
|
||||
}
|
||||
handle = supervisorHandleMock
|
||||
shutdown(): void {
|
||||
supervisorSenderState.current = null
|
||||
supervisorShutdownMock()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
type SentRequest = {
|
||||
id: number
|
||||
method: string
|
||||
@@ -27,6 +47,7 @@ type SentRequest = {
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
killed = false
|
||||
killSignals: NodeJS.Signals[] = []
|
||||
sent: SentRequest[] = []
|
||||
deferSendCallback = false
|
||||
sendCallbacks: ((error: Error | null) => void)[] = []
|
||||
@@ -42,8 +63,9 @@ class FakeChildProcess extends EventEmitter {
|
||||
return true
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
kill(signal: NodeJS.Signals = 'SIGTERM'): boolean {
|
||||
this.killed = true
|
||||
this.killSignals.push(signal)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -60,6 +82,8 @@ describe('computer sidecar client', () => {
|
||||
vi.useFakeTimers()
|
||||
children.length = 0
|
||||
deferNextSendCallback = false
|
||||
supervisorSenderState.current = null
|
||||
supervisorHandleMock.mockReturnValue(false)
|
||||
forkMock.mockImplementation(() => {
|
||||
const child = new FakeChildProcess()
|
||||
child.deferSendCallback = deferNextSendCallback
|
||||
@@ -72,6 +96,8 @@ describe('computer sidecar client', () => {
|
||||
afterEach(() => {
|
||||
resetComputerSidecarForTest()
|
||||
forkMock.mockReset()
|
||||
supervisorHandleMock.mockReset()
|
||||
supervisorShutdownMock.mockReset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -86,7 +112,7 @@ describe('computer sidecar client', () => {
|
||||
await firstRejection
|
||||
expect(firstChild.killed).toBe(true)
|
||||
expect(firstChild.listenerCount('message')).toBe(0)
|
||||
expect(firstChild.listenerCount('exit')).toBe(0)
|
||||
expect(firstChild.listenerCount('exit')).toBe(1)
|
||||
expect(firstChild.listenerCount('error')).toBe(1)
|
||||
|
||||
const secondCall = callComputerSidecarCapabilities()
|
||||
@@ -102,6 +128,7 @@ describe('computer sidecar client', () => {
|
||||
})
|
||||
expect(() => firstChild.emit('error', new Error('old sidecar failed late'))).not.toThrow()
|
||||
firstChild.emit('exit', 1, null)
|
||||
expect(firstChild.listenerCount('exit')).toBe(0)
|
||||
|
||||
secondChild.emit('message', {
|
||||
id: secondRequest.id,
|
||||
@@ -121,7 +148,7 @@ describe('computer sidecar client', () => {
|
||||
await firstRejection
|
||||
expect(firstChild.killed).toBe(true)
|
||||
expect(firstChild.listenerCount('message')).toBe(0)
|
||||
expect(firstChild.listenerCount('exit')).toBe(0)
|
||||
expect(firstChild.listenerCount('exit')).toBe(1)
|
||||
expect(firstChild.listenerCount('error')).toBe(1)
|
||||
|
||||
const secondCall = callComputerSidecarCapabilities()
|
||||
@@ -331,7 +358,7 @@ describe('computer sidecar client', () => {
|
||||
await secondRejection
|
||||
expect(firstChild.killed).toBe(true)
|
||||
expect(firstChild.listenerCount('message')).toBe(0)
|
||||
expect(firstChild.listenerCount('exit')).toBe(0)
|
||||
expect(firstChild.listenerCount('exit')).toBe(1)
|
||||
expect(firstChild.listenerCount('error')).toBe(1)
|
||||
|
||||
const thirdCall = callComputerSidecarCapabilities()
|
||||
@@ -359,7 +386,53 @@ describe('computer sidecar client', () => {
|
||||
await expect(call).rejects.toThrow('computer sidecar IPC is unavailable')
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
expect(children[0]!.listenerCount('message')).toBe(0)
|
||||
expect(children[0]!.listenerCount('exit')).toBe(0)
|
||||
expect(children[0]!.listenerCount('exit')).toBe(1)
|
||||
expect(children[0]!.listenerCount('error')).toBe(1)
|
||||
})
|
||||
|
||||
it('force-kills a sidecar that does not exit after the grace period', async () => {
|
||||
const call = callComputerSidecarCapabilities()
|
||||
const rejection = expect(call).rejects.toThrow('computer sidecar shut down')
|
||||
const child = children[0]!
|
||||
|
||||
resetComputerSidecarForTest()
|
||||
|
||||
await rejection
|
||||
expect(child.killSignals).toEqual(['SIGTERM'])
|
||||
await vi.advanceTimersByTimeAsync(COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS)
|
||||
expect(child.killSignals).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
})
|
||||
|
||||
it('cancels sidecar force-kill escalation after confirmed exit', async () => {
|
||||
const call = callComputerSidecarCapabilities()
|
||||
const rejection = expect(call).rejects.toThrow('computer sidecar shut down')
|
||||
const child = children[0]!
|
||||
|
||||
resetComputerSidecarForTest()
|
||||
child.emit('exit', 0, null)
|
||||
|
||||
await rejection
|
||||
await vi.advanceTimersByTimeAsync(COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS)
|
||||
expect(child.killSignals).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('reaps the sidecar when a supervisor response cannot be delivered', async () => {
|
||||
const call = callComputerSidecarCapabilities()
|
||||
const rejection = expect(call).rejects.toThrow('supervisor response failed')
|
||||
const child = children[0]!
|
||||
child.deferSendCallback = true
|
||||
|
||||
supervisorSenderState.current?.({
|
||||
channel: 'orca:computer-provider-supervisor',
|
||||
kind: 'response',
|
||||
id: 99,
|
||||
ok: true,
|
||||
result: {}
|
||||
})
|
||||
child.flushSendCallback(new Error('supervisor response failed'))
|
||||
|
||||
await rejection
|
||||
expect(child.killed).toBe(true)
|
||||
expect(supervisorShutdownMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
ComputerActionResult,
|
||||
ComputerListAppsResult,
|
||||
@@ -8,46 +6,13 @@ import type {
|
||||
ComputerSnapshotResult
|
||||
} from '../../shared/runtime-types'
|
||||
import { normalizeComputerActionResult } from './computer-action-verification-normalization'
|
||||
import { ComputerSidecarProcess, type ComputerSidecarMethod } from './computer-sidecar-process'
|
||||
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
type ComputerSidecarMethod =
|
||||
| 'capabilities'
|
||||
| 'listApps'
|
||||
| 'listWindows'
|
||||
| 'getAppState'
|
||||
| 'click'
|
||||
| 'performSecondaryAction'
|
||||
| 'scroll'
|
||||
| 'drag'
|
||||
| 'typeText'
|
||||
| 'pressKey'
|
||||
| 'hotkey'
|
||||
| 'pasteText'
|
||||
| 'setValue'
|
||||
|
||||
type ComputerSidecarRequest = {
|
||||
id: number
|
||||
method: ComputerSidecarMethod
|
||||
params: unknown
|
||||
}
|
||||
|
||||
type ComputerSidecarResponse =
|
||||
| { id: number; ok: true; result: unknown }
|
||||
| { id: number; ok: false; error: { code: string; message: string } }
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
let sidecar: ComputerSidecarProcess | null = null
|
||||
|
||||
// Why: Node treats unhandled child 'error' events as process exceptions, so
|
||||
// stale children keep a no-op listener that does not retain the sidecar owner.
|
||||
function ignoreStaleChildError(): void {}
|
||||
// Why: app.exit() skips Electron quit events; sync supervisor cleanup must still run.
|
||||
process.once('exit', () => sidecar?.shutdown())
|
||||
|
||||
export async function callComputerSidecarListApps(): Promise<ComputerListAppsResult> {
|
||||
return (await getComputerSidecar().call('listApps', {})) as ComputerListAppsResult
|
||||
@@ -92,230 +57,7 @@ export function resetComputerSidecarForTest(): void {
|
||||
|
||||
function getComputerSidecar(): ComputerSidecarProcess {
|
||||
if (!sidecar) {
|
||||
sidecar = new ComputerSidecarProcess(getComputerSidecarEntryPath())
|
||||
sidecar = new ComputerSidecarProcess()
|
||||
}
|
||||
return sidecar
|
||||
}
|
||||
|
||||
function getComputerSidecarEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
const appPath = app?.getAppPath() ?? process.cwd()
|
||||
const isPackaged = app?.isPackaged ?? false
|
||||
// Why: packaged sidecars must be forked from app.asar.unpacked because
|
||||
// ELECTRON_RUN_AS_NODE bypasses Electron's asar require integration.
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
return join(basePath, 'out', 'main', 'computer-sidecar.js')
|
||||
}
|
||||
|
||||
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
|
||||
try {
|
||||
return require('electron').app
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class ComputerSidecarProcess {
|
||||
private child: ChildProcess | null = null
|
||||
private childListenerCleanup: (() => void) | null = null
|
||||
private nextId = 1
|
||||
private pending = new Map<number, PendingRequest>()
|
||||
private queueTail: Promise<void> | null = null
|
||||
private queueGeneration = 0
|
||||
|
||||
constructor(private readonly entryPath: string) {}
|
||||
|
||||
call(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
|
||||
const generation = this.queueGeneration
|
||||
const run = () => {
|
||||
if (generation !== this.queueGeneration) {
|
||||
throw new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer sidecar queue was invalidated; retry the computer-use request'
|
||||
)
|
||||
}
|
||||
return this.send(method, params)
|
||||
}
|
||||
const result = this.queueTail ? this.queueTail.then(run, run) : run()
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
this.queueTail = tail
|
||||
void tail.finally(() => {
|
||||
if (this.queueTail === tail) {
|
||||
this.queueTail = null
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
private send(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
|
||||
const child = this.ensureStarted()
|
||||
if (!child.send) {
|
||||
const error = new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'computer sidecar IPC is unavailable'
|
||||
)
|
||||
this.failActiveChild(child, error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
const id = this.nextId++
|
||||
const request: ComputerSidecarRequest = { id, method, params }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
this.shutdown()
|
||||
reject(new RuntimeClientError('action_timeout', `computer sidecar ${method} timed out`))
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
child.send(request, (error) => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
const wrapped = new RuntimeClientError('accessibility_error', error.message)
|
||||
if (this.child === child) {
|
||||
this.failActiveChild(child, wrapped)
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(id)
|
||||
reject(wrapped)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
this.queueGeneration++
|
||||
this.cleanupActiveChildListeners()
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down'))
|
||||
this.pending.delete(id)
|
||||
}
|
||||
child?.kill('SIGTERM')
|
||||
}
|
||||
|
||||
private ensureStarted(): ChildProcess {
|
||||
if (this.child && !this.child.killed) {
|
||||
return this.child
|
||||
}
|
||||
this.cleanupActiveChildListeners()
|
||||
this.child = null
|
||||
|
||||
const child = fork(this.entryPath, [], {
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_COMPUTER_SIDECAR: '1'
|
||||
},
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
|
||||
const onMessage = (message: unknown) => {
|
||||
if (this.child === child) {
|
||||
this.handleMessage(message)
|
||||
}
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
|
||||
this.handleExit(child, code, signal)
|
||||
const onError = (error: Error) => this.handleError(child, error)
|
||||
|
||||
child.on('message', onMessage)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
this.childListenerCleanup = () => {
|
||||
child.off('message', onMessage)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
child.off('error', ignoreStaleChildError)
|
||||
child.on('error', ignoreStaleChildError)
|
||||
}
|
||||
this.child = child
|
||||
return child
|
||||
}
|
||||
|
||||
private handleMessage(message: unknown): void {
|
||||
if (!isSidecarResponse(message)) {
|
||||
return
|
||||
}
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
this.pending.delete(message.id)
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result)
|
||||
return
|
||||
}
|
||||
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
|
||||
}
|
||||
|
||||
private handleExit(
|
||||
child: ChildProcess,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null
|
||||
): void {
|
||||
// Why: a timed-out child can exit after a replacement has started; stale
|
||||
// exits must not clear the live child or reject its in-flight requests.
|
||||
if (this.child !== child) {
|
||||
return
|
||||
}
|
||||
this.cleanupActiveChildListeners()
|
||||
this.child = null
|
||||
this.queueGeneration++
|
||||
const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`
|
||||
const error = new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`computer sidecar exited with ${detail}`
|
||||
)
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(error)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(child: ChildProcess, error: Error): void {
|
||||
// Why: late errors from a prior child should not poison the current sidecar.
|
||||
if (this.child !== child) {
|
||||
return
|
||||
}
|
||||
this.cleanupActiveChildListeners()
|
||||
// Why: an active process error makes the IPC sidecar unreliable; restart
|
||||
// on the next call instead of reusing a broken helper.
|
||||
this.failActiveChild(child, new RuntimeClientError('accessibility_error', error.message))
|
||||
}
|
||||
|
||||
private failActiveChild(child: ChildProcess, error: RuntimeClientError): void {
|
||||
this.cleanupActiveChildListeners()
|
||||
this.child = null
|
||||
this.queueGeneration++
|
||||
child.kill('SIGTERM')
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(error)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupActiveChildListeners(): void {
|
||||
const cleanup = this.childListenerCleanup
|
||||
this.childListenerCleanup = null
|
||||
cleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
function isSidecarResponse(message: unknown): message is ComputerSidecarResponse {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return false
|
||||
}
|
||||
const record = message as Record<string, unknown>
|
||||
return typeof record.id === 'number' && typeof record.ok === 'boolean'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { computerProviderUnavailableMessage } from './computer-provider-unavailable-message'
|
||||
import { currentComputerProvider, shutdownComputerProviders } from './computer-provider-lifecycle'
|
||||
import {
|
||||
handleComputerProviderSupervisorMessage,
|
||||
shutdownComputerProviderSupervisorClient
|
||||
} from './computer-provider-supervisor-client'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
type SidecarRequest = {
|
||||
@@ -20,6 +24,9 @@ process.once('SIGINT', () => {
|
||||
process.once('beforeExit', shutdownProviders)
|
||||
|
||||
process.on('message', (message: unknown) => {
|
||||
if (handleComputerProviderSupervisorMessage(message)) {
|
||||
return
|
||||
}
|
||||
void handleMessage(message)
|
||||
})
|
||||
|
||||
@@ -122,4 +129,5 @@ function errorToResponse(error: unknown): { code: string; message: string } {
|
||||
|
||||
function shutdownProviders(): void {
|
||||
shutdownComputerProviders()
|
||||
shutdownComputerProviderSupervisorClient()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { HistoryManager } from './history-manager'
|
||||
import { flushPendingSessionTreeRemovals } from './terminal-history-session-tombstone'
|
||||
import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import {
|
||||
@@ -58,6 +60,8 @@ describe('HistoryManager', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await mgr.dispose()
|
||||
// Detached tombstone reclaims outlive the test that queued them; settle before the fixture goes.
|
||||
await flushPendingSessionTreeRemovals()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -486,4 +490,45 @@ describe('HistoryManager', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('large session cleanup responsiveness', () => {
|
||||
it('tombstones a large session tree instead of walking it on the teardown path', async () => {
|
||||
const sessionId = 'bulky'
|
||||
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
|
||||
const sessionDir = join(dir, getHistorySessionDirName(sessionId))
|
||||
// Enough entries that a recursive walk would dominate removeSession's duration.
|
||||
for (let i = 0; i < 3_000; i++) {
|
||||
writeFileSync(join(sessionDir, `chunk-${i}.log`), `payload-${i}`)
|
||||
}
|
||||
|
||||
// Why structural and not a turn count: worktree teardown awaits removeSession once per terminal,
|
||||
// so the contract is that the awaited half only renames. The tree surviving under .pending-delete
|
||||
// right after the await can only happen if the reclaim was detached.
|
||||
await mgr.removeSession(sessionId)
|
||||
|
||||
expect(existsSync(sessionDir)).toBe(false)
|
||||
const tombstones = readdirSync(join(dir, '.pending-delete'))
|
||||
expect(tombstones).toHaveLength(1)
|
||||
expect(readdirSync(join(dir, '.pending-delete', tombstones[0])).length).toBeGreaterThan(0)
|
||||
|
||||
await flushPendingSessionTreeRemovals()
|
||||
expect(readdirSync(join(dir, '.pending-delete'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reclaims tombstones left by a quit mid-removal on the next construction', async () => {
|
||||
const sessionId = 'leftover'
|
||||
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
|
||||
await mgr.removeSession(sessionId)
|
||||
await flushPendingSessionTreeRemovals()
|
||||
|
||||
const orphan = join(dir, '.pending-delete', 'orphaned-tombstone')
|
||||
mkdirSync(orphan, { recursive: true })
|
||||
writeFileSync(join(orphan, 'output.log'), 'stranded')
|
||||
|
||||
new HistoryManager(dir)
|
||||
await flushPendingSessionTreeRemovals()
|
||||
|
||||
expect(existsSync(orphan)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import {
|
||||
fingerprintTerminalHistorySession,
|
||||
hasTerminalHistoryRecoveryProtection,
|
||||
quarantineTerminalHistorySession,
|
||||
removeTerminalHistoryQuarantines,
|
||||
type ActiveHistoryRecoveryFreeze,
|
||||
type HistoryRecoveryFreeze
|
||||
} from './terminal-history-recovery-quarantine'
|
||||
import {
|
||||
removeTerminalHistorySessionTrees,
|
||||
schedulePendingSessionTreeRemovals
|
||||
} from './terminal-history-session-tombstone'
|
||||
import { TerminalHistorySessionWriter } from './terminal-history-session-writer'
|
||||
import {
|
||||
readTerminalHistoryMetaFromDir,
|
||||
@@ -43,6 +46,8 @@ export class HistoryManager {
|
||||
) {
|
||||
this.onWriteError = opts?.onWriteError
|
||||
this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
// Why: a quit between tombstone and reclaim leaves the tree on disk; nothing else rescans the queue.
|
||||
schedulePendingSessionTreeRemovals(this.basePath)
|
||||
}
|
||||
|
||||
async openSession(sessionId: string, opts: OpenSessionOptions): Promise<void> {
|
||||
@@ -270,16 +275,11 @@ export class HistoryManager {
|
||||
async removeSession(sessionId: string): Promise<void> {
|
||||
this.writers.delete(sessionId)
|
||||
this.disabledSessions.delete(sessionId)
|
||||
const activeFreeze = this.recoveryFreezes.get(sessionId)
|
||||
if (activeFreeze) {
|
||||
this.recoveryFreezes.delete(sessionId)
|
||||
}
|
||||
this.recoveryFreezes.delete(sessionId)
|
||||
await this.mutations.wait(sessionId)
|
||||
rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
removeTerminalHistoryQuarantines(this.basePath, sessionId)
|
||||
// Why tombstoned: writer handles are closed by here, so the trees only have to become unreachable —
|
||||
// they reach hundreds of MB and every terminal a worktree delete tears down awaits this.
|
||||
await removeTerminalHistorySessionTrees(this.basePath, sessionId)
|
||||
}
|
||||
|
||||
isSessionDisabled(sessionId: string): boolean {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
hasTerminalHistoryRecoveryProtection,
|
||||
isTerminalHistoryQuarantineEntry
|
||||
} from './terminal-history-recovery-quarantine'
|
||||
import { isTerminalHistoryPendingDeleteEntry } from './terminal-history-session-tombstone'
|
||||
import {
|
||||
readTerminalHistoryMeta,
|
||||
type SessionMeta,
|
||||
@@ -195,7 +196,10 @@ export class HistoryReader {
|
||||
if (!entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
if (isTerminalHistoryQuarantineEntry(entry.name)) {
|
||||
if (
|
||||
isTerminalHistoryQuarantineEntry(entry.name) ||
|
||||
isTerminalHistoryPendingDeleteEntry(entry.name)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
let sessionId: string
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
@@ -90,10 +89,3 @@ export function quarantineTerminalHistorySession(
|
||||
renameSync(sessionDir, quarantineDir)
|
||||
return quarantineDir
|
||||
}
|
||||
|
||||
export function removeTerminalHistoryQuarantines(basePath: string, sessionId: string): void {
|
||||
rmSync(getTerminalHistoryQuarantineOwnerDir(basePath, sessionId), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { removeHostTreeMock } = vi.hoisted(() => ({
|
||||
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
vi.mock('../host-tree-removal', () => ({
|
||||
removeHostTree: removeHostTreeMock
|
||||
}))
|
||||
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import {
|
||||
cancelPendingSessionTreeRemovalRetries,
|
||||
removeTerminalHistorySessionTrees,
|
||||
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS
|
||||
} from './terminal-history-session-tombstone'
|
||||
|
||||
/** Mirrors the worktree-level tombstone retry: a session tree whose rm fails must be re-queued
|
||||
* in-process instead of waiting for the next HistoryManager construction. */
|
||||
describe('tombstoned session tree removal retries', () => {
|
||||
let basePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
basePath = mkdtempSync(join(tmpdir(), 'orca-session-tombstone-retry-'))
|
||||
removeHostTreeMock.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cancelPendingSessionTreeRemovalRetries()
|
||||
vi.useRealTimers()
|
||||
rmSync(basePath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seedSession(sessionId: string): void {
|
||||
const dir = join(basePath, getHistorySessionDirName(sessionId))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'segment-0.log'), 'x')
|
||||
}
|
||||
|
||||
it('re-queues a tombstone whose removal failed, then stops after the last attempt', async () => {
|
||||
seedSession('session-busy')
|
||||
removeHostTreeMock.mockRejectedValue(
|
||||
Object.assign(new Error('resource busy'), { code: 'EBUSY' })
|
||||
)
|
||||
|
||||
await removeTerminalHistorySessionTrees(basePath, 'session-busy')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
for (const [index, retryDelayMs] of SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.entries()) {
|
||||
await vi.advanceTimersByTimeAsync(retryDelayMs)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(index + 2)
|
||||
}
|
||||
|
||||
// Bounded: a permanently wedged tree waits for the next construction's drain.
|
||||
await vi.advanceTimersByTimeAsync(60 * 60_000)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(
|
||||
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1
|
||||
)
|
||||
expect(removeHostTreeMock).toHaveBeenLastCalledWith(expect.stringContaining('.pending-delete'))
|
||||
})
|
||||
|
||||
it('does not re-arm a retry after the removal succeeds', async () => {
|
||||
seedSession('session-clean')
|
||||
removeHostTreeMock.mockResolvedValue(undefined)
|
||||
|
||||
await removeTerminalHistorySessionTrees(basePath, 'session-clean')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[0])
|
||||
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// Why: worktree teardown stops every terminal one by one, and each stop used to await a full recursive
|
||||
// delete of that session's history tree (hundreds of MB). Rename the tree into a tombstone queue instead
|
||||
// so the stop-and-wait path is metadata-only, and drain the queue off the critical path.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { removeHostTree } from '../host-tree-removal'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { getTerminalHistoryQuarantineOwnerDir } from './terminal-history-recovery-quarantine'
|
||||
|
||||
const PENDING_DELETE_DIR_NAME = '.pending-delete'
|
||||
|
||||
const pendingSessionTreeRemovals = new Map<string, Promise<void>>()
|
||||
// Why: a tombstone whose rm fails once (Windows EBUSY under AV) would otherwise sit on disk until the
|
||||
// next HistoryManager construction. Bounded so a genuinely wedged tree stops burning timers.
|
||||
export const SESSION_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000]
|
||||
const sessionTreeRemovalAttempts = new Map<string, number>()
|
||||
const sessionTreeRemovalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
export function isTerminalHistoryPendingDeleteEntry(name: string): boolean {
|
||||
return name === PENDING_DELETE_DIR_NAME
|
||||
}
|
||||
|
||||
function getPendingDeleteRoot(basePath: string): string {
|
||||
return join(basePath, PENDING_DELETE_DIR_NAME)
|
||||
}
|
||||
|
||||
function tombstoneSessionTree(basePath: string, dir: string): string | null {
|
||||
const pendingRoot = getPendingDeleteRoot(basePath)
|
||||
try {
|
||||
mkdirSync(pendingRoot, { recursive: true })
|
||||
const tombstone = join(pendingRoot, randomUUID())
|
||||
renameSync(dir, tombstone)
|
||||
return tombstone
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSessionTreeRemovalRetry(dir: string): void {
|
||||
const attempt = sessionTreeRemovalAttempts.get(dir) ?? 0
|
||||
const retryDelayMs = SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[attempt]
|
||||
if (retryDelayMs === undefined) {
|
||||
// Out of in-process attempts: the tombstone stays queued for the next construction's drain.
|
||||
sessionTreeRemovalAttempts.delete(dir)
|
||||
return
|
||||
}
|
||||
sessionTreeRemovalAttempts.set(dir, attempt + 1)
|
||||
const timer = setTimeout(() => {
|
||||
sessionTreeRemovalRetryTimers.delete(dir)
|
||||
scheduleSessionTreeRemoval(dir)
|
||||
}, retryDelayMs)
|
||||
timer.unref?.()
|
||||
sessionTreeRemovalRetryTimers.set(dir, timer)
|
||||
}
|
||||
|
||||
function scheduleSessionTreeRemoval(dir: string): void {
|
||||
if (pendingSessionTreeRemovals.has(dir)) {
|
||||
return
|
||||
}
|
||||
// A rescan (startup drain) hitting the same tombstone supersedes its pending retry.
|
||||
const pendingRetry = sessionTreeRemovalRetryTimers.get(dir)
|
||||
if (pendingRetry) {
|
||||
clearTimeout(pendingRetry)
|
||||
sessionTreeRemovalRetryTimers.delete(dir)
|
||||
}
|
||||
const removal = removeHostTree(dir)
|
||||
.then(() => {
|
||||
sessionTreeRemovalAttempts.delete(dir)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn(
|
||||
`[history] Failed to delete tombstoned session tree: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
scheduleSessionTreeRemovalRetry(dir)
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingSessionTreeRemovals.get(dir) === removal) {
|
||||
pendingSessionTreeRemovals.delete(dir)
|
||||
}
|
||||
})
|
||||
pendingSessionTreeRemovals.set(dir, removal)
|
||||
}
|
||||
|
||||
/** Remove everything a session owns on disk — its history tree and any quarantined generations —
|
||||
* without holding the caller for the recursive walks. Both are unreachable once this resolves;
|
||||
* only the reclaim is detached. */
|
||||
export async function removeTerminalHistorySessionTrees(
|
||||
basePath: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
for (const dir of [
|
||||
join(basePath, getHistorySessionDirName(sessionId)),
|
||||
getTerminalHistoryQuarantineOwnerDir(basePath, sessionId)
|
||||
]) {
|
||||
await removeSessionOwnedTree(basePath, dir)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSessionOwnedTree(basePath: string, dir: string): Promise<void> {
|
||||
if (!existsSync(dir)) {
|
||||
return
|
||||
}
|
||||
const tombstone = tombstoneSessionTree(basePath, dir)
|
||||
if (tombstone) {
|
||||
scheduleSessionTreeRemoval(tombstone)
|
||||
return
|
||||
}
|
||||
// Rename blocked (open handles under Windows AV); remove in place so the tree is still gone on return.
|
||||
await removeHostTree(dir)
|
||||
}
|
||||
|
||||
/** Queue tombstones left by a crash or quit mid-removal. Cheap no-op when the queue never formed. */
|
||||
export function schedulePendingSessionTreeRemovals(basePath: string): void {
|
||||
const pendingRoot = getPendingDeleteRoot(basePath)
|
||||
try {
|
||||
for (const entry of readdirSync(pendingRoot)) {
|
||||
scheduleSessionTreeRemoval(join(pendingRoot, entry))
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable queue is non-fatal; the next construction rescans.
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every armed retry timer so a fixture teardown cannot resurrect a removal. Tests only. */
|
||||
export function cancelPendingSessionTreeRemovalRetries(): void {
|
||||
for (const timer of sessionTreeRemovalRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
sessionTreeRemovalRetryTimers.clear()
|
||||
sessionTreeRemovalAttempts.clear()
|
||||
}
|
||||
|
||||
/** Await the in-flight tombstone removals. Tests only — production reclaims off the critical path and
|
||||
* re-queues whatever a quit interrupted on the next HistoryManager construction. */
|
||||
export async function flushPendingSessionTreeRemovals(): Promise<void> {
|
||||
while (pendingSessionTreeRemovals.size > 0) {
|
||||
await Promise.all(pendingSessionTreeRemovals.values())
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
// hour's loss; fsync stops it from happening.
|
||||
|
||||
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { open, rename } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { open, readdir, rename, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
|
||||
@@ -57,16 +57,72 @@ export async function writeFileDurable(
|
||||
finalPath: string,
|
||||
payload: string
|
||||
): Promise<void> {
|
||||
const handle = await open(tmpPath, 'w')
|
||||
await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true)
|
||||
}
|
||||
|
||||
/**
|
||||
* `writeFileDurable` with a commit veto: `isCurrent` is consulted after the fsync and before the
|
||||
* rename so a writer that was superseded mid-write doesn't publish a stale snapshot. Returns whether
|
||||
* the rename happened; the temp file is removed on every path that doesn't commit, so a multi-MB
|
||||
* payload can't orphan itself.
|
||||
*/
|
||||
export async function writeFileDurableIfCurrent(
|
||||
tmpPath: string,
|
||||
finalPath: string,
|
||||
payload: string,
|
||||
isCurrent: () => boolean
|
||||
): Promise<boolean> {
|
||||
let renamed = false
|
||||
try {
|
||||
await handle.writeFile(payload, 'utf-8')
|
||||
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
|
||||
await handle.sync()
|
||||
const handle = await open(tmpPath, 'w')
|
||||
try {
|
||||
await handle.writeFile(payload, 'utf-8')
|
||||
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (!isCurrent()) {
|
||||
return false
|
||||
}
|
||||
await rename(tmpPath, finalPath)
|
||||
renamed = true
|
||||
await syncDirectory(dirname(finalPath))
|
||||
return true
|
||||
} finally {
|
||||
await handle.close()
|
||||
if (!renamed) {
|
||||
await rm(tmpPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
|
||||
export function durableWriteTempPath(finalPath: string): string {
|
||||
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
|
||||
* otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save,
|
||||
* the trade already accepted for rename-based atomicity. This process's own temps are skipped: a
|
||||
* `<file>.<our pid>.*.tmp` seen during a sweep is a live write, and deleting it fails its rename.
|
||||
*/
|
||||
export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise<void> {
|
||||
const directory = dirname(finalPath)
|
||||
const prefix = `${basename(finalPath)}.`
|
||||
const ownPrefix = `${prefix}${process.pid}.`
|
||||
try {
|
||||
const names = await readdir(directory)
|
||||
await Promise.all(
|
||||
names
|
||||
.filter(
|
||||
(name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix)
|
||||
)
|
||||
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
|
||||
)
|
||||
} catch {
|
||||
// Directory missing or unreadable — nothing to sweep.
|
||||
}
|
||||
await rename(tmpPath, finalPath)
|
||||
await syncDirectory(dirname(finalPath))
|
||||
}
|
||||
|
||||
/** Synchronous counterpart for quit and crash paths that cannot await. */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
refreshBranchCleanupTargetRefs
|
||||
} from '../../shared/git-branch-cleanup'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type {
|
||||
GitWorktreeInfo,
|
||||
LocalBaseRefRefreshResult,
|
||||
@@ -1157,6 +1158,19 @@ async function performRemoveWorktree(
|
||||
return {}
|
||||
}
|
||||
|
||||
// Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as
|
||||
// `git worktree remove` being slow unless it is timed separately.
|
||||
return withSpan('worktree.remove.branch_delete', () =>
|
||||
deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options)
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteBranchAfterWorktreeRemoval(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
branchHead: string,
|
||||
options: RemoveWorktreeOptions
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
// Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves
|
||||
// unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
|
||||
// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient
|
||||
// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries.
|
||||
|
||||
import type { RmOptions } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
|
||||
const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
|
||||
const WINDOWS_RM_MAX_RETRIES = 8
|
||||
const WINDOWS_RM_RETRY_DELAY_MS = 150
|
||||
|
||||
export function toHostRemovalPath(targetPath: string): string {
|
||||
// Why: Git for Windows can fail long recursive deletes even after Orca has
|
||||
// proven the worktree target; Node's host deletion should use Win32 long paths.
|
||||
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
|
||||
}
|
||||
|
||||
function getHostRemovalOptions(): RmOptions {
|
||||
const base = { recursive: true, force: true }
|
||||
if (process.platform !== 'win32') {
|
||||
return base
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
// Why: large Windows trees commonly surface transient ENOTEMPTY/EPERM while
|
||||
// Node walks and removes nested directories.
|
||||
maxRetries: WINDOWS_RM_MAX_RETRIES,
|
||||
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientWindowsRemovalError(error: unknown): boolean {
|
||||
if (process.platform !== 'win32' || typeof error !== 'object' || error === null) {
|
||||
return false
|
||||
}
|
||||
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
|
||||
if (code && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
|
||||
return true
|
||||
}
|
||||
const message = 'message' in error && typeof error.message === 'string' ? error.message : ''
|
||||
return /directory not empty|resource busy|operation not permitted/i.test(message)
|
||||
}
|
||||
|
||||
/** Recursively remove a host directory tree, retrying the transient Windows failures. */
|
||||
export async function removeHostTree(targetPath: string): Promise<void> {
|
||||
const removalPath = toHostRemovalPath(targetPath)
|
||||
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
|
||||
const rmOptions = getHostRemovalOptions()
|
||||
let attempt = 0
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await rm(removalPath, rmOptions)
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
|
||||
throw error
|
||||
}
|
||||
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
|
||||
// directory before antivirus/indexers/handles release it.
|
||||
await delay(retryDelays[attempt])
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ describe('main-i18n lazy locale loading', () => {
|
||||
expect(translateMain('menu.file', 'File')).not.toBe('File')
|
||||
})
|
||||
|
||||
it('uses caller English when a target catalog omits a key', async () => {
|
||||
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
|
||||
expect(translateMain('missing.main.feature', 'English fallback')).toBe('English fallback')
|
||||
})
|
||||
|
||||
it('returns to English from a lazily-loaded locale', async () => {
|
||||
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
|
||||
expect(translateMain('menu.file', 'File')).toBe('Archivo')
|
||||
|
||||
+13
-1
@@ -80,6 +80,7 @@ import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
|
||||
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
|
||||
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
|
||||
import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion'
|
||||
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
|
||||
import {
|
||||
getNextDefaultOnAppearanceSettingValue,
|
||||
@@ -2791,6 +2792,9 @@ void app.whenReady().then(async () => {
|
||||
}
|
||||
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
|
||||
automations.start()
|
||||
// Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is
|
||||
// armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch.
|
||||
scheduleAllPendingHistoryTreeRemovals()
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
@@ -2917,6 +2921,13 @@ app.on('will-quit', (e) => {
|
||||
killAllPty()
|
||||
const watcherShutdown = shutdownWatchersOnce()
|
||||
store?.flush()
|
||||
// Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a
|
||||
// scan completion would drop the final snapshot. Captured before any await; joins the barrier below.
|
||||
const usageCacheFlush = Promise.all([
|
||||
claudeUsage?.flush(),
|
||||
codexUsage?.flush(),
|
||||
openCodeUsage?.flush()
|
||||
]).then(() => {})
|
||||
|
||||
// Why: preventDefault to await disconnectDaemon's async checkpoint writes (else data lost); guard prevents an infinite quit loop on the re-fired will-quit.
|
||||
if (!daemonDisconnectDone) {
|
||||
@@ -2950,7 +2961,8 @@ app.on('will-quit', (e) => {
|
||||
{ name: 'runtime-rpc', promise: rpcStopAndClear },
|
||||
{ name: 'watchers', promise: watcherShutdown },
|
||||
{ name: 'emulator', promise: emulatorShutdown },
|
||||
{ name: 'plugin-hosts', promise: pluginHostShutdown }
|
||||
{ name: 'plugin-hosts', promise: pluginHostShutdown },
|
||||
{ name: 'usage-cache', promise: usageCacheFlush }
|
||||
])
|
||||
.then((pendingTeardowns) => {
|
||||
if (pendingTeardowns.length > 0) {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ParcelWatcherProcess from './parcel-watcher-process'
|
||||
|
||||
const { handleMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@parcel/watcher', () => ({
|
||||
subscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-watcher-wsl', () => ({
|
||||
createWslWatcher: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./parcel-watcher-process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ParcelWatcherProcess>()
|
||||
return {
|
||||
...actual,
|
||||
subscribeViaWatcherProcess: vi.fn(actual.subscribeViaWatcherProcess)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
onSshFilesystemProviderRegistered: () => () => {}
|
||||
}))
|
||||
|
||||
import {
|
||||
closeAllWatchers,
|
||||
closeLocalWatcherForWorktreePath,
|
||||
registerFilesystemWatcherHandlers
|
||||
} from './filesystem-watcher'
|
||||
import {
|
||||
createWatcherRemovalDeadline,
|
||||
drainBeforeWatcherRemoval,
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS,
|
||||
WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS
|
||||
} from './watcher-removal-drain'
|
||||
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
|
||||
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
|
||||
|
||||
describe('local filesystem watcher removal deadline', () => {
|
||||
const handlers: HandlerMap = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
handleMock.mockReset()
|
||||
vi.mocked(stat).mockReset()
|
||||
vi.mocked(subscribeParcelWatcher).mockReset()
|
||||
vi.mocked(subscribeViaWatcherProcess).mockClear()
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
handleMock.mockImplementation((channel, handler) => {
|
||||
handlers[channel] = handler
|
||||
})
|
||||
registerFilesystemWatcherHandlers()
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
it('bounds a wedged watcher install so worktree deletion cannot hang forever', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
// Why mock the process-backed subscribe: the in-process fallback rejects on abort, so only a
|
||||
// subscribe that ignores the abort signal exercises the deadline rather than the cancel path.
|
||||
// Once, so the wedge cannot leak into the next test.
|
||||
vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {}))
|
||||
const sender = {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
id: 1
|
||||
}
|
||||
|
||||
const watchPromise = handlers['fs:watchWorktree'](
|
||||
{ sender },
|
||||
{ worktreePath: '/tmp/repo' }
|
||||
) as Promise<unknown>
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeViaWatcherProcess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let closed = false
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo').then(() => {
|
||||
closed = true
|
||||
})
|
||||
|
||||
// The install drain leaves the final unsubscribe its reserved tail slice.
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1
|
||||
)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await closePromise
|
||||
expect(closed).toBe(true)
|
||||
|
||||
void watchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds a wedged live unsubscribe so worktree deletion cannot hang forever', async () => {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
// The in-process Parcel fallback has no unsubscribe timeout of its own, so only the shared
|
||||
// removal deadline can stop this from hanging delete forever.
|
||||
let resolveUnsubscribe: () => void = () => {}
|
||||
const unsubscribeMock = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUnsubscribe = resolve
|
||||
})
|
||||
)
|
||||
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never)
|
||||
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
|
||||
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let closed = false
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo').then(() => {
|
||||
closed = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS - 1)
|
||||
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await closePromise
|
||||
expect(closed).toBe(true)
|
||||
} finally {
|
||||
resolveUnsubscribe()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('spends one shared budget across both drains instead of one per await', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {}))
|
||||
const sender = {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
id: 1
|
||||
}
|
||||
|
||||
const watchPromise = handlers['fs:watchWorktree'](
|
||||
{ sender },
|
||||
{ worktreePath: '/tmp/repo' }
|
||||
) as Promise<unknown>
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeViaWatcherProcess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Half the budget is already gone before the close starts; the drain may only spend the rest.
|
||||
const deadline = createWatcherRemovalDeadline()
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2)
|
||||
|
||||
let closed = false
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo', deadline).then(() => {
|
||||
closed = true
|
||||
})
|
||||
// Why assert mid-drain: without this a fresh (unshared) budget would also pass the final check.
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2 - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1
|
||||
)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await closePromise
|
||||
expect(closed).toBe(true)
|
||||
|
||||
void watchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an abandoned unsubscribe poison a later close of the same root', async () => {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
let rejectUnsubscribe: (error: unknown) => void = () => {}
|
||||
const unsubscribeMock = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectUnsubscribe = reject
|
||||
})
|
||||
)
|
||||
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never)
|
||||
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo')
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS)
|
||||
await expect(closePromise).resolves.toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
// The delete this drain guarded already finished; a late native failure is stale news, and
|
||||
// retaining it would leave this root permanently undeletable until the watcher process exits.
|
||||
rejectUnsubscribe(
|
||||
new WatcherProcessFailure(
|
||||
'file watcher process did not exit after termination deadline',
|
||||
'supervisor',
|
||||
'process_unavailable',
|
||||
new Promise<void>(() => {})
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(unsubscribeMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('watcher removal drain budget', () => {
|
||||
it('reserves a tail slice so a slow final unsubscribe is not abandoned at zero', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const deadline = createWatcherRemovalDeadline()
|
||||
const earlyDrain = drainBeforeWatcherRemoval(
|
||||
new Promise(() => {}),
|
||||
deadline,
|
||||
'wedged early drain',
|
||||
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS
|
||||
)
|
||||
await expect(earlyDrain).resolves.toBe('timeout')
|
||||
expect(deadline.remainingMs()).toBe(WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS)
|
||||
|
||||
let finishFinalUnsubscribe: () => void = () => {}
|
||||
const finalDrain = drainBeforeWatcherRemoval(
|
||||
new Promise<void>((resolve) => {
|
||||
finishFinalUnsubscribe = resolve
|
||||
}),
|
||||
deadline,
|
||||
'slow final unsubscribe'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1)
|
||||
finishFinalUnsubscribe()
|
||||
|
||||
await expect(finalDrain).resolves.toBe('settled')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
WatcherChildCapacityError
|
||||
} from './parcel-watcher-child-registry'
|
||||
import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate'
|
||||
import {
|
||||
createWatcherRemovalDeadline,
|
||||
drainBeforeWatcherRemoval,
|
||||
WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS,
|
||||
type WatcherRemovalDeadline
|
||||
} from './watcher-removal-drain'
|
||||
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
|
||||
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
|
||||
|
||||
@@ -72,6 +78,10 @@ const suspendedLocalWatcherListeners = new Map<
|
||||
let localWatchersClosed = false
|
||||
let localWatcherLifecycleGeneration = 0
|
||||
const failedLocalUnsubscribes = new Map<string, unknown>()
|
||||
// Why: a drain that timed out no longer gates the delete — Git removal already proceeded past it. Its
|
||||
// late failure must not fail-close a *later* close of the same root, which would leave that path
|
||||
// undeletable until the watcher process physically exits.
|
||||
const abandonedLocalUnsubscribes = new WeakSet<Promise<void>>()
|
||||
type LocalWatcherInstallToken = {
|
||||
cancelled: boolean
|
||||
listeners: Map<number, WebContents>
|
||||
@@ -478,12 +488,21 @@ function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise<void
|
||||
rootUnsubscribes.add(unsubscribePromise)
|
||||
// Why: swallow here to avoid unhandled rejections, but keep the original promise rejected so later destructive cleanup can fail closed.
|
||||
void unsubscribePromise.catch((error: unknown) => {
|
||||
retainLocalWatcherPhysicalFailure(rootKey, error)
|
||||
if (!abandonedLocalUnsubscribes.has(unsubscribePromise)) {
|
||||
retainLocalWatcherPhysicalFailure(rootKey, error)
|
||||
}
|
||||
console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, error)
|
||||
})
|
||||
return unsubscribePromise
|
||||
}
|
||||
|
||||
/** Mark unsubscribes whose drain timed out so their late failures stay out of failedLocalUnsubscribes. */
|
||||
function abandonLocalUnsubscribes(promises: Iterable<Promise<void>>): void {
|
||||
for (const promise of promises) {
|
||||
abandonedLocalUnsubscribes.add(promise)
|
||||
}
|
||||
}
|
||||
|
||||
function retainLocalWatcherPhysicalFailure(rootKey: string, error: unknown): void {
|
||||
if (!isWatcherProcessFailure(error) || !error.physicalExit) {
|
||||
return
|
||||
@@ -780,7 +799,10 @@ function unsubscribe(worktreePath: string, senderId: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLocalWatcherForWorktreePath(worktreePath: string): Promise<void> {
|
||||
export async function closeLocalWatcherForWorktreePath(
|
||||
worktreePath: string,
|
||||
deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline()
|
||||
): Promise<void> {
|
||||
const { key: rootKey } = localWatcherRoot(worktreePath)
|
||||
const suspended = suspendedLocalWatcherListeners.get(rootKey) ?? {
|
||||
worktreePath,
|
||||
@@ -814,10 +836,45 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
|
||||
inFlight.cancelled = true
|
||||
inFlight.abortController.abort()
|
||||
}
|
||||
await pendingLocalInstallPromises.get(rootKey)
|
||||
// Why: abort alone is not enough if the native subscribe never settles; bound so delete cannot hang the app.
|
||||
const pendingInstall = pendingLocalInstallPromises.get(rootKey)
|
||||
const installDrain = await drainBeforeWatcherRemoval(
|
||||
pendingInstall,
|
||||
deadline,
|
||||
`local watcher install for ${rootKey}`,
|
||||
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
|
||||
)
|
||||
if (installDrain === 'timeout') {
|
||||
// Why: an abandoned install never runs its own cleanup, so leaving these entries would make every
|
||||
// later watch of this root queue behind the same wedged promise. Identity-checked so a late settle
|
||||
// can't evict a newer install.
|
||||
if (pendingLocalInstallPromises.get(rootKey) === pendingInstall) {
|
||||
pendingLocalInstallPromises.delete(rootKey)
|
||||
}
|
||||
if (inFlight && inFlightLocalInstalls.get(rootKey) === inFlight) {
|
||||
inFlightLocalInstalls.delete(rootKey)
|
||||
}
|
||||
}
|
||||
const pendingUnsubscribes = pendingLocalUnsubscribesByRoot.get(rootKey)
|
||||
if (pendingUnsubscribes) {
|
||||
await Promise.all(Array.from(pendingUnsubscribes))
|
||||
const draining = Array.from(pendingUnsubscribes)
|
||||
const unsubscribeDrain = await drainBeforeWatcherRemoval(
|
||||
// Why the per-promise catch: an already-abandoned unsubscribe belongs to a delete that finished
|
||||
// without it; re-raising its rejection here would fail a later close on stale news.
|
||||
Promise.all(
|
||||
draining.map((unsubscribe) =>
|
||||
abandonedLocalUnsubscribes.has(unsubscribe)
|
||||
? unsubscribe.catch(() => undefined)
|
||||
: unsubscribe
|
||||
)
|
||||
),
|
||||
deadline,
|
||||
`local watcher unsubscribe for ${rootKey}`,
|
||||
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
|
||||
)
|
||||
if (unsubscribeDrain === 'timeout') {
|
||||
abandonLocalUnsubscribes(draining)
|
||||
}
|
||||
}
|
||||
if (failedLocalUnsubscribes.has(rootKey)) {
|
||||
throw failedLocalUnsubscribes.get(rootKey)
|
||||
@@ -831,7 +888,18 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
|
||||
clearTimeout(root.batch.timer)
|
||||
}
|
||||
watchedRoots.delete(rootKey)
|
||||
await trackLocalUnsubscribe(rootKey, root)
|
||||
// Why: the in-process Parcel fallback has no unsubscribe timeout of its own, so an unbounded await
|
||||
// here would hang delete forever and hold the removal gate. The promise stays tracked in
|
||||
// pendingLocalUnsubscribesByRoot, so a later close still observes its failure.
|
||||
const finalUnsubscribe = trackLocalUnsubscribe(rootKey, root)
|
||||
const finalDrain = await drainBeforeWatcherRemoval(
|
||||
finalUnsubscribe,
|
||||
deadline,
|
||||
`local watcher unsubscribe for ${rootKey}`
|
||||
)
|
||||
if (finalDrain === 'timeout') {
|
||||
abandonLocalUnsubscribes([finalUnsubscribe])
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string): Promise<void> {
|
||||
|
||||
@@ -9,6 +9,8 @@ export type PendingPtyData = {
|
||||
droppedOutput?: true
|
||||
droppedMode2031Data?: string
|
||||
droppedMode2031ScanState?: Mode2031ReplyScanState
|
||||
projectionAdmissionIds?: readonly string[]
|
||||
projectionAdmissionsTransferred?: true
|
||||
}
|
||||
|
||||
export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked'
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendPendingProjectionAdmission,
|
||||
compactPendingProjectionAdmissions,
|
||||
PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS,
|
||||
propagatePendingProjectionRemainder
|
||||
} from './pty-pending-projection-admissions'
|
||||
|
||||
describe('pending PTY projection admissions', () => {
|
||||
it('retains the exact cap then transfers the whole ordered run on overflow', () => {
|
||||
const transfer = vi.fn()
|
||||
let state = compactPendingProjectionAdmissions()
|
||||
|
||||
for (let index = 0; index < PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS; index++) {
|
||||
state = appendPendingProjectionAdmission(state, `projection-${index}`, {
|
||||
isPending: () => true,
|
||||
transfer
|
||||
})
|
||||
}
|
||||
|
||||
expect(state.projectionAdmissionIds).toHaveLength(PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS)
|
||||
expect(transfer).not.toHaveBeenCalled()
|
||||
|
||||
state = appendPendingProjectionAdmission(state, 'projection-overflow', {
|
||||
isPending: () => true,
|
||||
transfer
|
||||
})
|
||||
|
||||
expect(state).toEqual({ projectionAdmissionsTransferred: true })
|
||||
expect(transfer).toHaveBeenCalledOnce()
|
||||
expect(transfer.mock.calls[0]?.[0]).toHaveLength(PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS + 1)
|
||||
})
|
||||
|
||||
it('compacts terminal prefixes and transfers later admissions until the remainder drains', () => {
|
||||
const transfer = vi.fn()
|
||||
const compacted = propagatePendingProjectionRemainder(
|
||||
{
|
||||
projectionAdmissionIds: ['projection-published', 'projection-partial', 'projection-tail']
|
||||
},
|
||||
{ sent: true, projectionsTransferred: false },
|
||||
{ isPending: (id) => id !== 'projection-published', transfer }
|
||||
)
|
||||
|
||||
expect(compacted).toEqual({
|
||||
projectionAdmissionIds: ['projection-partial', 'projection-tail']
|
||||
})
|
||||
|
||||
const transferred = appendPendingProjectionAdmission(
|
||||
{ projectionAdmissionsTransferred: true },
|
||||
'projection-after-transfer',
|
||||
{ isPending: () => true, transfer }
|
||||
)
|
||||
expect(transferred).toEqual({ projectionAdmissionsTransferred: true })
|
||||
expect(transfer).toHaveBeenCalledWith(['projection-after-transfer'], 'pending-projection-cap')
|
||||
|
||||
expect(
|
||||
propagatePendingProjectionRemainder(
|
||||
compacted,
|
||||
{ sent: true, projectionsTransferred: true },
|
||||
{ isPending: () => true, transfer }
|
||||
)
|
||||
).toEqual({ projectionAdmissionsTransferred: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
export const PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS = 1024
|
||||
|
||||
export type PendingProjectionAdmissions = Readonly<{
|
||||
projectionAdmissionIds?: readonly string[]
|
||||
projectionAdmissionsTransferred?: true
|
||||
}>
|
||||
|
||||
type PendingProjectionAdmissionOptions = Readonly<{
|
||||
isPending: (id: string) => boolean
|
||||
transfer: (ids: readonly string[], reason: string) => void
|
||||
}>
|
||||
|
||||
const DEFAULT_OPTIONS: PendingProjectionAdmissionOptions = {
|
||||
isPending: () => true,
|
||||
transfer: () => {}
|
||||
}
|
||||
|
||||
export function compactPendingProjectionAdmissions(
|
||||
state: PendingProjectionAdmissions = {},
|
||||
options: PendingProjectionAdmissionOptions = DEFAULT_OPTIONS
|
||||
): PendingProjectionAdmissions {
|
||||
if (state.projectionAdmissionsTransferred) {
|
||||
return { projectionAdmissionsTransferred: true }
|
||||
}
|
||||
const ids = Array.from(
|
||||
new Set((state.projectionAdmissionIds ?? []).filter((id) => options.isPending(id)))
|
||||
)
|
||||
return ids.length > 0 ? { projectionAdmissionIds: ids } : {}
|
||||
}
|
||||
|
||||
export function appendPendingProjectionAdmission(
|
||||
state: PendingProjectionAdmissions,
|
||||
id: string,
|
||||
options: PendingProjectionAdmissionOptions
|
||||
): PendingProjectionAdmissions {
|
||||
if (state.projectionAdmissionsTransferred) {
|
||||
options.transfer([id], 'pending-projection-cap')
|
||||
return { projectionAdmissionsTransferred: true }
|
||||
}
|
||||
const compacted = compactPendingProjectionAdmissions(state, options)
|
||||
const ids = [...(compacted.projectionAdmissionIds ?? []), id]
|
||||
if (ids.length <= PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS) {
|
||||
return { projectionAdmissionIds: ids }
|
||||
}
|
||||
options.transfer(ids, 'pending-projection-cap')
|
||||
return { projectionAdmissionsTransferred: true }
|
||||
}
|
||||
|
||||
export function propagatePendingProjectionRemainder(
|
||||
state: PendingProjectionAdmissions,
|
||||
delivery: Readonly<{ sent: boolean; projectionsTransferred: boolean }>,
|
||||
options: PendingProjectionAdmissionOptions
|
||||
): PendingProjectionAdmissions {
|
||||
return delivery.sent && !delivery.projectionsTransferred
|
||||
? compactPendingProjectionAdmissions(state, options)
|
||||
: { projectionAdmissionsTransferred: true }
|
||||
}
|
||||
+198
-1
@@ -247,6 +247,11 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
|
||||
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
|
||||
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
||||
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
|
||||
import {
|
||||
acceptSshPtyOutputData,
|
||||
acceptSshPtyOutputExit,
|
||||
closeSshPtyOutputGeneration
|
||||
} from './ssh-pty-output-intake-registry'
|
||||
|
||||
// Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161).
|
||||
const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
@@ -449,7 +454,8 @@ describe('registerPtyHandlers', () => {
|
||||
'ssh-reattach-1',
|
||||
'ssh-reattach-fail',
|
||||
'ssh-reattach-ok',
|
||||
'ssh-runtime-env'
|
||||
'ssh-runtime-env',
|
||||
'ssh-generation-replacement'
|
||||
]) {
|
||||
unregisterSshPtyProvider(leakedConnectionId)
|
||||
}
|
||||
@@ -11556,6 +11562,197 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps negotiated source-credit overflow off the legacy PTY-global pause path', async () => {
|
||||
vi.useFakeTimers()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const provider = installObservableDaemonTestProvider()
|
||||
let modelSequence = 0
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn(() => modelSequence),
|
||||
onPtyData: vi.fn(
|
||||
(_id: string, data: string, _at: number, rawLength = data.length) =>
|
||||
(modelSequence += rawLength)
|
||||
),
|
||||
acceptPtyDataBounded: vi.fn(
|
||||
(_id: string, _data: string, _at: number, rawLength: number) => {
|
||||
modelSequence += rawLength
|
||||
return { sequence: modelSequence, completion: Promise.resolve() }
|
||||
}
|
||||
)
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
mainWindow.webContents.send.mockClear()
|
||||
|
||||
const sourceChunk = 's'.repeat(128 * 1024)
|
||||
for (let index = 0; index < 17; index++) {
|
||||
const sourceStartSu = index * sourceChunk.length
|
||||
await acceptSshPtyOutputData({
|
||||
id: 'source-credit-pty',
|
||||
data: sourceChunk,
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'source-incarnation',
|
||||
rawLength: sourceChunk.length,
|
||||
transformed: false,
|
||||
source: {
|
||||
relayPtyId: 'relay-source-pty',
|
||||
spanId: `source-token:${sourceStartSu}:${sourceStartSu + sourceChunk.length}`,
|
||||
clientGeneration: 2,
|
||||
ownerGeneration: 3,
|
||||
deliveryToken: 'source-token',
|
||||
sourceStartSu,
|
||||
sourceEndSu: sourceStartSu + sourceChunk.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
|
||||
pendingPtyCount: 1,
|
||||
pendingChars: 0
|
||||
})
|
||||
expect(provider.pauseProducer).not.toHaveBeenCalledWith('source-credit-pty')
|
||||
expect(provider.resumeProducer).not.toHaveBeenCalledWith('source-credit-pty')
|
||||
|
||||
provider.emitData('legacy-pty', 'l'.repeat(320 * 1024))
|
||||
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
|
||||
expect(provider.pauseProducer).toHaveBeenCalledWith('legacy-pty')
|
||||
expect(provider.pauseProducer).not.toHaveBeenCalledWith('unrelated-pty')
|
||||
|
||||
vi.runAllTimers()
|
||||
expect(provider.resumeProducer).toHaveBeenCalledTimes(1)
|
||||
expect(provider.resumeProducer).toHaveBeenCalledWith('legacy-pty')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('pauses and resumes the exact SSH provider generation across reconnect replacement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const completion = makeDeferred()
|
||||
let sequence = 0
|
||||
let captures = 0
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn(() => sequence),
|
||||
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
|
||||
sequence += rawLength
|
||||
captures++
|
||||
return {
|
||||
sequence,
|
||||
completion: captures === 1 ? completion.promise : Promise.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
const original = {
|
||||
providerGeneration: 41,
|
||||
hasPtyDeliveryPauseAdapter: () => true,
|
||||
pauseProducer: vi.fn(),
|
||||
resumeProducer: vi.fn()
|
||||
}
|
||||
const replacement = {
|
||||
providerGeneration: 42,
|
||||
hasPtyDeliveryPauseAdapter: () => true,
|
||||
pauseProducer: vi.fn(),
|
||||
resumeProducer: vi.fn()
|
||||
}
|
||||
const id = 'ssh:ssh-generation-replacement@@relay-pty'
|
||||
const receipts: Promise<unknown>[] = []
|
||||
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
registerSshPtyProvider('ssh-generation-replacement', original as never)
|
||||
const running = acceptSshPtyOutputData({
|
||||
id,
|
||||
data: 'a'.repeat(256 * 1024),
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'incarnation-41',
|
||||
rawLength: 256 * 1024,
|
||||
transformed: false
|
||||
})
|
||||
receipts.push(running)
|
||||
registerSshPtyProvider('ssh-generation-replacement', replacement as never)
|
||||
const pressured = acceptSshPtyOutputData({
|
||||
id,
|
||||
data: 'b',
|
||||
providerGeneration: 41,
|
||||
ptyIncarnation: 'incarnation-41',
|
||||
rawLength: 1,
|
||||
transformed: false
|
||||
})
|
||||
receipts.push(pressured)
|
||||
|
||||
expect(original.pauseProducer).toHaveBeenCalledWith(id)
|
||||
expect(replacement.pauseProducer).not.toHaveBeenCalled()
|
||||
|
||||
completion.resolve()
|
||||
await Promise.all([running, pressured])
|
||||
expect(original.resumeProducer).toHaveBeenCalledWith(id)
|
||||
expect(replacement.resumeProducer).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
completion.resolve()
|
||||
await Promise.allSettled(receipts)
|
||||
closeSshPtyOutputGeneration(41, 'test-cleanup')
|
||||
unregisterSshPtyProvider('ssh-generation-replacement')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects local data while an SSH renderer exit waits for projection settlement', async () => {
|
||||
const provider = installObservableDaemonTestProvider()
|
||||
let sequence = 0
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn(() => sequence),
|
||||
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
|
||||
sequence += rawLength
|
||||
return { sequence, completion: Promise.resolve() }
|
||||
}),
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
const id = 'ssh:exit-data-race@@relay-pty'
|
||||
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
mainWindow.webContents.send.mockClear()
|
||||
await acceptSshPtyOutputData({
|
||||
id,
|
||||
data: 'before-exit',
|
||||
providerGeneration: 51,
|
||||
ptyIncarnation: 'incarnation-51',
|
||||
rawLength: 'before-exit'.length,
|
||||
transformed: false
|
||||
})
|
||||
const exit = acceptSshPtyOutputExit({
|
||||
id,
|
||||
code: 0,
|
||||
providerGeneration: 51,
|
||||
ptyIncarnation: 'incarnation-51'
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
provider.emitData(id, 'must-not-follow-exit')
|
||||
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', {
|
||||
id,
|
||||
data: 'must-not-follow-exit'
|
||||
})
|
||||
|
||||
getPtyAckDataListener()(null, { id, processedChars: 'before-exit'.length })
|
||||
await exit
|
||||
expect(mainWindow.webContents.send.mock.calls.at(-1)).toEqual([
|
||||
'pty:exit',
|
||||
{
|
||||
id,
|
||||
code: 0,
|
||||
providerGeneration: 51,
|
||||
ptyIncarnation: 'incarnation-51'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a paused producer when the PTY exits before draining', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
+471
-168
@@ -158,6 +158,19 @@ import {
|
||||
unmarkHiddenRendererPty
|
||||
} from './pty-hidden-delivery-gate'
|
||||
import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue'
|
||||
import {
|
||||
appendPendingProjectionAdmission,
|
||||
compactPendingProjectionAdmissions,
|
||||
propagatePendingProjectionRemainder,
|
||||
type PendingProjectionAdmissions
|
||||
} from './pty-pending-projection-admissions'
|
||||
import { SshPtyOutputIntake } from './ssh-pty-output-intake'
|
||||
import {
|
||||
cancelSshPtySourceDelivery,
|
||||
installSshPtyOutputIntake,
|
||||
publishSshPtySourceAck
|
||||
} from './ssh-pty-output-intake-registry'
|
||||
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
|
||||
import {
|
||||
clearNativeWindowsConptyPty,
|
||||
isNativeWindowsLocalPtySpawn,
|
||||
@@ -202,6 +215,7 @@ type FreshLocalFallbackProvider = IPtyProvider & {
|
||||
routesFreshSpawnsToLocalProvider?: true
|
||||
}
|
||||
const sshProviders = new Map<string, IPtyProvider>()
|
||||
const sshProvidersByGeneration = new Map<number, IPtyProvider>()
|
||||
|
||||
type RegisteredPtyProvider = {
|
||||
provider: IPtyProvider
|
||||
@@ -1296,10 +1310,19 @@ function beginPtySpawnForWorktree(
|
||||
/** Register an SSH PTY provider for a connection. */
|
||||
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
|
||||
sshProviders.set(connectionId, provider)
|
||||
const generation = (provider as { providerGeneration?: number }).providerGeneration
|
||||
if (Number.isSafeInteger(generation) && generation! > 0) {
|
||||
sshProvidersByGeneration.set(generation!, provider)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove an SSH PTY provider when a connection is closed. */
|
||||
export function unregisterSshPtyProvider(connectionId: string): void {
|
||||
const provider = sshProviders.get(connectionId)
|
||||
const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration
|
||||
if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) {
|
||||
sshProvidersByGeneration.delete(generation)
|
||||
}
|
||||
sshProviders.delete(connectionId)
|
||||
}
|
||||
|
||||
@@ -1467,6 +1490,7 @@ let rendererDidStartLoadingHandler: (() => void) | null = null
|
||||
|
||||
// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops.
|
||||
let rebindProviderListeners: (() => void) | null = null
|
||||
let sshOutputIntakeCleanup: (() => void) | null = null
|
||||
|
||||
export function rebindLocalProviderListeners(): void {
|
||||
rebindProviderListeners?.()
|
||||
@@ -1840,8 +1864,10 @@ export function registerPtyHandlers(
|
||||
},
|
||||
() => isHiddenPtyDeliveryGateEnabled(getSettings?.())
|
||||
)
|
||||
let sshOutputIntake: SshPtyOutputIntake | null = null
|
||||
// Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit.
|
||||
const rendererExitingPtyIds = new Set<string>()
|
||||
const rendererCreditBeforeExitByPty = new Map<string, boolean>()
|
||||
const rendererDeliveryRestoreNeededPtys = new Set<string>()
|
||||
|
||||
function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) {
|
||||
@@ -1927,11 +1953,18 @@ export function registerPtyHandlers(
|
||||
pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id),
|
||||
resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id)
|
||||
})
|
||||
const sourceCreditPendingPtys = new Set<string>()
|
||||
|
||||
function updateProducerFlowControl(id: string): void {
|
||||
if (!PRODUCER_FLOW_CONTROL_ENABLED) {
|
||||
return
|
||||
}
|
||||
if (sourceCreditPendingPtys.has(id)) {
|
||||
if (pendingData.get(id)) {
|
||||
return
|
||||
}
|
||||
sourceCreditPendingPtys.delete(id)
|
||||
}
|
||||
producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0)
|
||||
}
|
||||
|
||||
@@ -2000,7 +2033,16 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
function clearPendingPtyData(): void {
|
||||
for (const pending of pendingData.values()) {
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(
|
||||
pending.projectionAdmissionIds,
|
||||
'renderer-lifecycle-reset'
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingData.clear()
|
||||
sourceCreditPendingPtys.clear()
|
||||
}
|
||||
|
||||
function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
|
||||
@@ -2169,6 +2211,9 @@ export function registerPtyHandlers(
|
||||
producerFlowControl.releaseAll()
|
||||
clearDeliveryResyncProbe()
|
||||
deliveryResyncUnansweredWarnLogged = false
|
||||
for (const id of rendererDeliveryAccountingByPty.keys()) {
|
||||
sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset')
|
||||
}
|
||||
rendererDeliveryAccountingByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
clearPendingPtyData()
|
||||
@@ -2265,6 +2310,9 @@ export function registerPtyHandlers(
|
||||
accounting.lastAckAtMs = Date.now()
|
||||
}
|
||||
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged)
|
||||
if (acknowledged > 0) {
|
||||
sshOutputIntake?.settleProjectionPrefix(id, acknowledged)
|
||||
}
|
||||
return acknowledged
|
||||
}
|
||||
|
||||
@@ -2336,6 +2384,12 @@ export function registerPtyHandlers(
|
||||
// Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore.
|
||||
const pending = pendingData.get(id)
|
||||
if (pending) {
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(
|
||||
pending.projectionAdmissionIds,
|
||||
'renderer-delivery-writeoff'
|
||||
)
|
||||
}
|
||||
pendingDroppedChars += pending.data.length
|
||||
deletePendingPtyData(id)
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
@@ -2365,7 +2419,11 @@ export function registerPtyHandlers(
|
||||
return writtenOff
|
||||
}
|
||||
|
||||
function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): boolean {
|
||||
function sendPtyDataToRenderer(
|
||||
id: string,
|
||||
payload: PtyDataPayload,
|
||||
projectionAdmissionIds?: readonly string[]
|
||||
): { sent: boolean; projectionsTransferred: boolean } {
|
||||
const charCount = getPtyPayloadCharCount(payload)
|
||||
const accounting = rendererDeliveryAccountingByPty.get(id)
|
||||
const hadAccounting = accounting !== undefined
|
||||
@@ -2400,12 +2458,28 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
rendererDeliveryRestoreNeededPtys.add(id)
|
||||
if (projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed')
|
||||
}
|
||||
mainDeliveryBreadcrumbs.record('pty-data-send-failed', {
|
||||
id: redactPtyIdForDiagnostics(id),
|
||||
chars: charCount
|
||||
})
|
||||
console.error('[pty] renderer data send failed; payload will not be retried', error)
|
||||
return false
|
||||
return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined }
|
||||
}
|
||||
let projectionsTransferred = false
|
||||
if (projectionAdmissionIds) {
|
||||
try {
|
||||
sshOutputIntake?.publishProjectionPrefix(
|
||||
projectionAdmissionIds,
|
||||
payload.data.length,
|
||||
charCount
|
||||
)
|
||||
} catch {
|
||||
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed')
|
||||
projectionsTransferred = true
|
||||
}
|
||||
}
|
||||
if (rendererDeliveryRestoreNeededPtys.has(id)) {
|
||||
try {
|
||||
@@ -2418,7 +2492,7 @@ export function registerPtyHandlers(
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
return { sent: true, projectionsTransferred }
|
||||
}
|
||||
|
||||
function rendererPtyIsKnownHidden(id: string): boolean {
|
||||
@@ -2521,6 +2595,9 @@ export function registerPtyHandlers(
|
||||
pendingOverflowMarkedPtys.add(id)
|
||||
}
|
||||
pendingDroppedChars += pending.data.length
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap')
|
||||
}
|
||||
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
|
||||
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
|
||||
return {
|
||||
@@ -2531,6 +2608,39 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
function updatePendingProjectionAdmissions(
|
||||
pending: PendingPtyData,
|
||||
state: PendingProjectionAdmissions
|
||||
): void {
|
||||
delete pending.projectionAdmissionIds
|
||||
delete pending.projectionAdmissionsTransferred
|
||||
if (state.projectionAdmissionIds) {
|
||||
pending.projectionAdmissionIds = state.projectionAdmissionIds
|
||||
}
|
||||
if (state.projectionAdmissionsTransferred) {
|
||||
pending.projectionAdmissionsTransferred = true
|
||||
}
|
||||
}
|
||||
|
||||
function compactPendingProjectionState(
|
||||
pending: PendingProjectionAdmissions,
|
||||
projectionSemanticsId?: string
|
||||
): PendingProjectionAdmissions {
|
||||
const options = pendingProjectionAdmissionOptions()
|
||||
const compacted = compactPendingProjectionAdmissions(pending, options)
|
||||
return projectionSemanticsId
|
||||
? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options)
|
||||
: compacted
|
||||
}
|
||||
|
||||
function pendingProjectionAdmissionOptions() {
|
||||
return {
|
||||
isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false,
|
||||
transfer: (ids: readonly string[], reason: string) =>
|
||||
sshOutputIntake?.transferProjections(ids, reason)
|
||||
}
|
||||
}
|
||||
|
||||
function appendPendingPtyData(
|
||||
id: string,
|
||||
existing: PendingPtyData | undefined,
|
||||
@@ -2539,10 +2649,14 @@ export function registerPtyHandlers(
|
||||
preservesSeq: boolean,
|
||||
containsBackgroundOutput: boolean,
|
||||
rawLength = data.length,
|
||||
transformed = false
|
||||
transformed = false,
|
||||
projectionSemanticsId?: string
|
||||
): PendingPtyData {
|
||||
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
|
||||
if (existing?.droppedOutput === true) {
|
||||
if (projectionSemanticsId) {
|
||||
sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap')
|
||||
}
|
||||
const mode2031 = scanDroppedMode2031Data(
|
||||
data,
|
||||
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
@@ -2559,16 +2673,19 @@ export function registerPtyHandlers(
|
||||
droppedMode2031ScanState: mode2031.state
|
||||
}
|
||||
}
|
||||
const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId)
|
||||
const nextContainsBackgroundOutput =
|
||||
existing?.containsBackgroundOutput === true || containsBackgroundOutput
|
||||
if (!existing) {
|
||||
return dropOversizedPendingPtyData(id, {
|
||||
const pending: PendingPtyData = {
|
||||
data,
|
||||
...(typeof startSeq === 'number' ? { startSeq } : {}),
|
||||
...(rawLength !== data.length ? { rawLength } : {}),
|
||||
...(transformed ? { transformed: true } : {}),
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
||||
})
|
||||
}
|
||||
updatePendingProjectionAdmissions(pending, projectionState)
|
||||
return dropOversizedPendingPtyData(id, pending)
|
||||
}
|
||||
const existingRawLength = existing.rawLength ?? existing.data.length
|
||||
const next: PendingPtyData = {
|
||||
@@ -2578,6 +2695,7 @@ export function registerPtyHandlers(
|
||||
: {}),
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
||||
}
|
||||
updatePendingProjectionAdmissions(next, projectionState)
|
||||
if (typeof existing.startSeq === 'number') {
|
||||
next.startSeq = existing.startSeq
|
||||
}
|
||||
@@ -2661,6 +2779,9 @@ export function registerPtyHandlers(
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
updateProducerFlowControl(id)
|
||||
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
|
||||
}
|
||||
warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
|
||||
@@ -2676,11 +2797,15 @@ export function registerPtyHandlers(
|
||||
updateProducerFlowControl(id)
|
||||
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
|
||||
if (
|
||||
!sendPtyDataToRenderer(id, {
|
||||
!sendPtyDataToRenderer(
|
||||
id,
|
||||
data: pending.data + getDroppedMode2031RendererData(pending),
|
||||
droppedOutput: true
|
||||
})
|
||||
{
|
||||
id,
|
||||
data: pending.data + getDroppedMode2031RendererData(pending),
|
||||
droppedOutput: true
|
||||
},
|
||||
pending.projectionAdmissionIds
|
||||
).sent
|
||||
) {
|
||||
sendFailed = true
|
||||
break
|
||||
@@ -2692,33 +2817,50 @@ export function registerPtyHandlers(
|
||||
const indivisible = pending.transformed === true
|
||||
const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
|
||||
const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
|
||||
let nextPending: PendingPtyData | undefined
|
||||
if (remaining) {
|
||||
const nextPending: PendingPtyData = { data: remaining }
|
||||
nextPending = { data: remaining }
|
||||
if (typeof pending.startSeq === 'number') {
|
||||
nextPending.startSeq = pending.startSeq + chunk.length
|
||||
}
|
||||
if (pending.containsBackgroundOutput === true) {
|
||||
nextPending.containsBackgroundOutput = true
|
||||
}
|
||||
if (pending.projectionAdmissionIds) {
|
||||
nextPending.projectionAdmissionIds = pending.projectionAdmissionIds
|
||||
}
|
||||
if (pending.projectionAdmissionsTransferred) {
|
||||
nextPending.projectionAdmissionsTransferred = true
|
||||
}
|
||||
pendingData.replaceWithRemainder(selection, nextPending)
|
||||
} else {
|
||||
pendingData.remove(selection)
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
}
|
||||
updateProducerFlowControl(id)
|
||||
if (
|
||||
!sendPtyDataToRenderer(
|
||||
const delivery = sendPtyDataToRenderer(
|
||||
id,
|
||||
makePtyDataPayload(
|
||||
id,
|
||||
makePtyDataPayload(
|
||||
id,
|
||||
chunk,
|
||||
pending.startSeq,
|
||||
pending.containsBackgroundOutput,
|
||||
pending.rawLength,
|
||||
pending.transformed
|
||||
chunk,
|
||||
pending.startSeq,
|
||||
pending.containsBackgroundOutput,
|
||||
pending.rawLength,
|
||||
pending.transformed
|
||||
),
|
||||
pending.projectionAdmissionIds
|
||||
)
|
||||
if (nextPending) {
|
||||
updatePendingProjectionAdmissions(
|
||||
nextPending,
|
||||
propagatePendingProjectionRemainder(
|
||||
nextPending,
|
||||
delivery,
|
||||
pendingProjectionAdmissionOptions()
|
||||
)
|
||||
)
|
||||
) {
|
||||
}
|
||||
if (!delivery.sent) {
|
||||
sendFailed = true
|
||||
break
|
||||
}
|
||||
@@ -2781,27 +2923,45 @@ export function registerPtyHandlers(
|
||||
return true
|
||||
}
|
||||
|
||||
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
|
||||
function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed')
|
||||
return () => {}
|
||||
}
|
||||
if (rendererExitingPtyIds.has(payload.id)) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
rendererExitingPtyIds.add(payload.id)
|
||||
let released = false
|
||||
const release = (): void => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
rendererExitingPtyIds.delete(payload.id)
|
||||
}
|
||||
try {
|
||||
const hadReleasableRendererCredit = getRendererInFlightCharsForPty(payload.id) > 0
|
||||
if (!rendererCreditBeforeExitByPty.has(payload.id)) {
|
||||
rendererCreditBeforeExitByPty.set(
|
||||
payload.id,
|
||||
getRendererInFlightCharsForPty(payload.id) > 0
|
||||
)
|
||||
}
|
||||
// Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost.
|
||||
const remaining = pendingData.delete(payload.id)
|
||||
clearFlushTimerIfIdle()
|
||||
if (remaining) {
|
||||
if (remaining.droppedOutput === true) {
|
||||
// Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped.
|
||||
sendPtyDataToRenderer(payload.id, {
|
||||
id: payload.id,
|
||||
data: remaining.data,
|
||||
droppedOutput: true
|
||||
})
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
{
|
||||
id: payload.id,
|
||||
data: remaining.data,
|
||||
droppedOutput: true
|
||||
},
|
||||
remaining.projectionAdmissionIds
|
||||
)
|
||||
} else {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
@@ -2812,35 +2972,63 @@ export function registerPtyHandlers(
|
||||
remaining.containsBackgroundOutput,
|
||||
remaining.rawLength,
|
||||
remaining.transformed
|
||||
)
|
||||
),
|
||||
remaining.projectionAdmissionIds
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
|
||||
producerFlowControl.release(payload.id)
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
rendererDeliveryRestoreNeededPtys.delete(payload.id)
|
||||
lastInputAtByPty.delete(payload.id)
|
||||
interactiveOutputCharsByPty.delete(payload.id)
|
||||
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
|
||||
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
|
||||
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
|
||||
rendererDeliveryAccountingByPty.delete(payload.id)
|
||||
if (hadReleasableRendererCredit) {
|
||||
if (pendingDataFlushActive) {
|
||||
// Why: let the open round coalesce this wake into its one post-round continuation.
|
||||
const reactivatedBlocked = pendingData.reactivateBlocked()
|
||||
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
|
||||
} else {
|
||||
schedulePendingDataAfterCreditReport(true)
|
||||
}
|
||||
return release
|
||||
} catch (error) {
|
||||
release()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function finalizePtyExitForRenderer(payload: { id: string; code: number }): void {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
rendererCreditBeforeExitByPty.delete(payload.id)
|
||||
return
|
||||
}
|
||||
const hadReleasableRendererCredit =
|
||||
rendererCreditBeforeExitByPty.get(payload.id) ??
|
||||
getRendererInFlightCharsForPty(payload.id) > 0
|
||||
rendererCreditBeforeExitByPty.delete(payload.id)
|
||||
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
|
||||
producerFlowControl.release(payload.id)
|
||||
sourceCreditPendingPtys.delete(payload.id)
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
rendererDeliveryRestoreNeededPtys.delete(payload.id)
|
||||
lastInputAtByPty.delete(payload.id)
|
||||
interactiveOutputCharsByPty.delete(payload.id)
|
||||
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
|
||||
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
|
||||
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
|
||||
rendererDeliveryAccountingByPty.delete(payload.id)
|
||||
if (hadReleasableRendererCredit) {
|
||||
if (pendingDataFlushActive) {
|
||||
// Why: let the open round coalesce this wake into its one post-round continuation.
|
||||
const reactivatedBlocked = pendingData.reactivateBlocked()
|
||||
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
|
||||
} else {
|
||||
schedulePendingDataAfterCreditReport(true)
|
||||
}
|
||||
mainWindow.webContents.send('pty:exit', {
|
||||
...payload,
|
||||
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
|
||||
})
|
||||
}
|
||||
mainWindow.webContents.send('pty:exit', {
|
||||
...payload,
|
||||
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
|
||||
const release = preparePtyExitForRenderer(payload)
|
||||
if (!release) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit')
|
||||
finalizePtyExitForRenderer(payload)
|
||||
} finally {
|
||||
rendererExitingPtyIds.delete(payload.id)
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2850,6 +3038,231 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
function acceptPtyDataForRenderer(
|
||||
payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
},
|
||||
outputSeq: number | undefined,
|
||||
projection?: LegacySshProjectionSemantics
|
||||
): void {
|
||||
const rawLength = payload.sequenceChars ?? payload.data.length
|
||||
const preservesSeq = !payload.transformed && rawLength === payload.data.length
|
||||
const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
|
||||
const projectionId = projection?.identity.projectionSemanticsId
|
||||
if (mainWindow.isDestroyed()) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed')
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
producerFlowControl.releaseAll()
|
||||
clearDeliveryResyncProbe()
|
||||
clearPendingPtyData()
|
||||
pendingOverflowMarkedPtys.clear()
|
||||
rendererDeliveryAccountingByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
clearDispatcherReadyWatchdog()
|
||||
return
|
||||
}
|
||||
if (rendererExitingPtyIds.has(payload.id)) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'pty-exiting')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'hidden-drop')
|
||||
}
|
||||
const droppedChars = projection ? rawLength : payload.data.length
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
|
||||
warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (payload.data.length === 0 && !payload.transformed) {
|
||||
if (projectionId) {
|
||||
sshOutputIntake?.transferProjections([projectionId], 'empty-projection')
|
||||
}
|
||||
return
|
||||
}
|
||||
const containsBackgroundOutput =
|
||||
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
|
||||
if (containsBackgroundOutput) {
|
||||
markHiddenRendererResizeOutputDelivered(payload.id)
|
||||
}
|
||||
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
|
||||
if (projection?.desktopSpan) {
|
||||
sourceCreditPendingPtys.add(payload.id)
|
||||
}
|
||||
const pending = appendPendingPtyData(
|
||||
payload.id,
|
||||
pendingData.get(payload.id),
|
||||
payload.data,
|
||||
startSeq,
|
||||
preservesSeq,
|
||||
containsBackgroundOutput,
|
||||
rawLength,
|
||||
payload.transformed === true,
|
||||
projectionId
|
||||
)
|
||||
const shouldEmitPendingCapRestoreMarker =
|
||||
pending.droppedOutput === true &&
|
||||
!overflowMarkedBeforeAppend &&
|
||||
pendingOverflowMarkedPtys.has(payload.id)
|
||||
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
||||
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
||||
payload.id,
|
||||
nextData,
|
||||
performance.now()
|
||||
)
|
||||
if (isInteractiveOutput && rendererPtyDispatcherReady) {
|
||||
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
requestDeliveryResyncForGatedPty()
|
||||
return
|
||||
}
|
||||
deletePendingPtyData(payload.id)
|
||||
clearFlushTimerIfIdle()
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
try {
|
||||
sendPtyDataToRenderer(
|
||||
payload.id,
|
||||
{
|
||||
id: payload.id,
|
||||
data: nextData,
|
||||
...(typeof pending.startSeq === 'number'
|
||||
? {
|
||||
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
|
||||
rawLength: pending.rawLength ?? nextData.length
|
||||
}
|
||||
: {}),
|
||||
...(pending.transformed ? { transformed: true } : {}),
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
||||
},
|
||||
pending.projectionAdmissionIds
|
||||
)
|
||||
} finally {
|
||||
updateProducerFlowControl(payload.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
if (
|
||||
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
|
||||
) {
|
||||
requestDeliveryResyncForGatedPty()
|
||||
}
|
||||
if (!flushTimer) {
|
||||
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
sshOutputIntakeCleanup?.()
|
||||
sshOutputIntake = new SshPtyOutputIntake({
|
||||
getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0,
|
||||
acceptModel: (event, projection) => {
|
||||
if (!runtime) {
|
||||
throw new Error('SSH PTY output requires the main terminal model')
|
||||
}
|
||||
return runtime.acceptPtyDataBounded(
|
||||
event.id,
|
||||
event.data,
|
||||
Date.now(),
|
||||
event.rawLength,
|
||||
event.transformed,
|
||||
projection.desktopSpan ? [projection.desktopSpan] : undefined
|
||||
)
|
||||
},
|
||||
project: (event, projection) =>
|
||||
acceptPtyDataForRenderer(
|
||||
{
|
||||
id: event.id,
|
||||
data: event.data,
|
||||
sequenceChars: event.rawLength,
|
||||
transformed: event.transformed
|
||||
},
|
||||
projection.identity.sequenceEnd,
|
||||
projection
|
||||
),
|
||||
prepareExit: (event) => {
|
||||
const release = preparePtyExitForRenderer(event)
|
||||
if (!release) {
|
||||
throw new Error('pty_renderer_exit_in_progress')
|
||||
}
|
||||
return release
|
||||
},
|
||||
finalizeExit: (event) => {
|
||||
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
|
||||
finalizePtyExitForRenderer(event)
|
||||
},
|
||||
pauseProvider: (generation, id) => {
|
||||
const provider = sshProvidersByGeneration.get(generation) as
|
||||
| (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean })
|
||||
| undefined
|
||||
if (!provider?.hasPtyDeliveryPauseAdapter?.()) {
|
||||
return false
|
||||
}
|
||||
provider.pauseProducer?.(id)
|
||||
return true
|
||||
},
|
||||
resumeProvider: (generation, id) =>
|
||||
sshProvidersByGeneration.get(generation)?.resumeProducer?.(id),
|
||||
closeProvider: (generation, reason) => {
|
||||
const provider = sshProvidersByGeneration.get(generation)
|
||||
;(
|
||||
provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined
|
||||
)?.closeOutputIntake?.(reason)
|
||||
},
|
||||
resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id),
|
||||
onGenerationClosed: (providerGeneration) => {
|
||||
for (const id of pendingData.keys()) {
|
||||
const pending = pendingData.get(id)
|
||||
if (
|
||||
pending?.projectionAdmissionIds &&
|
||||
sshOutputIntake?.hasProjectionFromGeneration(
|
||||
pending.projectionAdmissionIds,
|
||||
providerGeneration
|
||||
)
|
||||
) {
|
||||
pendingData.delete(id)
|
||||
updateProducerFlowControl(id)
|
||||
pendingOverflowMarkedPtys.delete(id)
|
||||
}
|
||||
}
|
||||
sshProvidersByGeneration.delete(providerGeneration)
|
||||
},
|
||||
publishSourceAck: publishSshPtySourceAck,
|
||||
cancelSourceDelivery: cancelSshPtySourceDelivery
|
||||
})
|
||||
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(
|
||||
sshOutputIntake.getRemoteSourceRangeConsumerHooks()
|
||||
)
|
||||
const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake)
|
||||
sshOutputIntakeCleanup = () => {
|
||||
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null)
|
||||
cleanupSshOutputIntakeRegistry()
|
||||
}
|
||||
|
||||
async function shutdownProviderAndDetectExit(
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
@@ -2933,120 +3346,7 @@ export function registerPtyHandlers(
|
||||
const outputSeq = isLocalProvider
|
||||
? runtime?.getPtyOutputSequence(payload.id)
|
||||
: runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed)
|
||||
const rendererData = payload.data
|
||||
const preservesSeq = !payload.transformed && rawLength === payload.data.length
|
||||
const startSeq =
|
||||
typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
|
||||
if (mainWindow.isDestroyed()) {
|
||||
// Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration.
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
producerFlowControl.releaseAll()
|
||||
clearDeliveryResyncProbe()
|
||||
clearPendingPtyData()
|
||||
pendingOverflowMarkedPtys.clear()
|
||||
rendererDeliveryAccountingByPty.clear()
|
||||
rendererInFlightTotalChars = 0
|
||||
clearDispatcherReadyWatchdog()
|
||||
return
|
||||
}
|
||||
if (rendererExitingPtyIds.size > 0 && rendererExitingPtyIds.has(payload.id)) {
|
||||
return
|
||||
}
|
||||
const settings = getSettings?.()
|
||||
// Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot.
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
|
||||
warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length)
|
||||
if (drop.shouldEmitRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (rendererData.length === 0 && !payload.transformed) {
|
||||
return
|
||||
}
|
||||
const containsBackgroundOutput =
|
||||
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
|
||||
if (containsBackgroundOutput) {
|
||||
markHiddenRendererResizeOutputDelivered(payload.id)
|
||||
}
|
||||
const existing = pendingData.get(payload.id)
|
||||
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
|
||||
const pending = appendPendingPtyData(
|
||||
payload.id,
|
||||
existing,
|
||||
rendererData,
|
||||
startSeq,
|
||||
preservesSeq,
|
||||
containsBackgroundOutput,
|
||||
rawLength,
|
||||
payload.transformed === true
|
||||
)
|
||||
const shouldEmitPendingCapRestoreMarker =
|
||||
pending.droppedOutput === true &&
|
||||
!overflowMarkedBeforeAppend &&
|
||||
pendingOverflowMarkedPtys.has(payload.id)
|
||||
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
||||
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
||||
payload.id,
|
||||
nextData,
|
||||
performance.now()
|
||||
)
|
||||
// Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate.
|
||||
if (isInteractiveOutput && rendererPtyDispatcherReady) {
|
||||
// Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway.
|
||||
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
requestDeliveryResyncForGatedPty()
|
||||
return
|
||||
}
|
||||
deletePendingPtyData(payload.id)
|
||||
clearFlushTimerIfIdle()
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
pendingOverflowMarkedPtys.delete(payload.id)
|
||||
// Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency.
|
||||
try {
|
||||
sendPtyDataToRenderer(payload.id, {
|
||||
id: payload.id,
|
||||
data: nextData,
|
||||
...(typeof pending.startSeq === 'number'
|
||||
? {
|
||||
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
|
||||
rawLength: pending.rawLength ?? nextData.length
|
||||
}
|
||||
: {}),
|
||||
...(pending.transformed ? { transformed: true } : {}),
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
||||
})
|
||||
} finally {
|
||||
updateProducerFlowControl(payload.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
setPendingPtyData(payload.id, pending)
|
||||
if (shouldEmitPendingCapRestoreMarker) {
|
||||
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
|
||||
}
|
||||
updateProducerFlowControl(payload.id)
|
||||
// Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable.
|
||||
if (
|
||||
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
|
||||
) {
|
||||
requestDeliveryResyncForGatedPty()
|
||||
}
|
||||
if (!flushTimer) {
|
||||
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
|
||||
}
|
||||
acceptPtyDataForRenderer(payload, outputSeq)
|
||||
})
|
||||
localExitUnsub = localProvider.onExit((payload) => {
|
||||
if (!isCurrentPtyExit(payload)) {
|
||||
@@ -5646,6 +5946,9 @@ export function registerPtyHandlers(
|
||||
const pending = pendingData.get(args.id)
|
||||
if (pending && transition.droppable) {
|
||||
pendingData.delete(args.id)
|
||||
if (pending.projectionAdmissionIds) {
|
||||
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
|
||||
}
|
||||
updateProducerFlowControl(args.id)
|
||||
pendingOverflowMarkedPtys.delete(args.id)
|
||||
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
|
||||
|
||||
@@ -1115,6 +1115,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
mockStore.getRepos.mockReset().mockReturnValue([])
|
||||
mockStore.addRepo.mockReset()
|
||||
mockStore.removeProject.mockReset()
|
||||
mockStore.getSshTarget.mockReset()
|
||||
mockStore.updateRepo.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockReset()
|
||||
@@ -2066,6 +2067,76 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sets up a folder when the selected project exists only on another host', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
mockStore.getRepos.mockImplementation(() => added)
|
||||
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
|
||||
mockStore.updateRepo.mockImplementation((id, updates) => {
|
||||
const repo = added.find((entry) => entry.id === id)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
Object.assign(repo, updates)
|
||||
return { ...repo }
|
||||
})
|
||||
mockStore.getProjects.mockImplementation(() => {
|
||||
const repo = added.find((entry) => 'upstream' in entry)
|
||||
return repo
|
||||
? [
|
||||
{
|
||||
id: 'github:github.acme.test/acme/orca',
|
||||
displayName: 'Orca',
|
||||
providerIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
const result = await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: 'github:github.acme.test/acme/orca',
|
||||
projectProviderIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
},
|
||||
hostId: 'local',
|
||||
path: '/tmp/orca-local',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(added[0]?.upstream).toEqual({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
})
|
||||
expect(result).toHaveProperty('project.id', 'github:github.acme.test/acme/orca')
|
||||
})
|
||||
|
||||
it('rolls back a new repo when the supplied identity does not match the project', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
mockStore.getRepos.mockImplementation(() => added)
|
||||
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
|
||||
|
||||
await expect(
|
||||
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: 'github:acme/orca',
|
||||
projectProviderIdentity: { provider: 'github', owner: 'other', repo: 'orca' },
|
||||
hostId: 'local',
|
||||
path: '/tmp/mismatched-project',
|
||||
kind: 'git'
|
||||
})
|
||||
).rejects.toThrow('Imported folder does not match the selected project identity.')
|
||||
|
||||
expect(mockStore.removeProject).toHaveBeenCalledWith(added[0]?.id)
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
|
||||
const updated = {
|
||||
id: 'repo-update-root',
|
||||
|
||||
+37
-17
@@ -89,7 +89,10 @@ import type {
|
||||
} from '../../shared/host-repo-catalog-contract'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectIdForProviderIdentity
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
@@ -235,20 +238,23 @@ function alignRepoWithRequestedProject(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
projectId: string,
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder',
|
||||
requestedProviderIdentity?: ProjectHostSetupExistingFolderArgs['projectProviderIdentity']
|
||||
): ProjectHostSetupResult {
|
||||
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
if (setup.projectId !== projectId) {
|
||||
const project = store.getProjects().find((entry) => entry.id === projectId)
|
||||
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
|
||||
// Why: the selected project can exist only on the source host, so its structured identity travels with the request.
|
||||
const identity = project?.providerIdentity ?? requestedProviderIdentity
|
||||
if (!identity || getProjectIdForProviderIdentity(identity) !== projectId) {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
// Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it.
|
||||
const updated = store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: project.providerIdentity.owner,
|
||||
repo: project.providerIdentity.repo,
|
||||
...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {})
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
...(identity.host ? { host: identity.host } : {})
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
@@ -801,6 +807,14 @@ const ProjectGroupMoveProjectArgs = z.object({
|
||||
|
||||
const ProjectHostSetupExistingFolderIpcArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
projectProviderIdentity: z
|
||||
.object({
|
||||
provider: z.literal('github'),
|
||||
owner: z.string().min(1),
|
||||
repo: z.string().min(1),
|
||||
host: z.string().min(1).optional()
|
||||
})
|
||||
.optional(),
|
||||
hostId: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
@@ -1378,11 +1392,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if (!parsedHost) {
|
||||
throw new Error(`Unsupported host: ${args.hostId}`)
|
||||
}
|
||||
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
|
||||
if (!existingProject) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
|
||||
const result =
|
||||
parsedHost.kind === 'local'
|
||||
? await addLocalRepoFromPath(store, args.path, args.kind)
|
||||
@@ -1400,15 +1409,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
let aligned: ProjectHostSetupResult
|
||||
try {
|
||||
aligned = alignRepoWithRequestedProject(
|
||||
store,
|
||||
result.repo,
|
||||
args.projectId,
|
||||
args.setupMethod,
|
||||
args.projectProviderIdentity
|
||||
)
|
||||
} catch (err) {
|
||||
// Why: an import that cannot be linked must not leave a new repo registration or authorization root behind.
|
||||
if (!result.alreadyExisted) {
|
||||
store.removeProject(result.repo.id)
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
const aligned = alignRepoWithRequestedProject(
|
||||
store,
|
||||
result.repo,
|
||||
args.projectId,
|
||||
args.setupMethod
|
||||
)
|
||||
if (result.alreadyExisted) {
|
||||
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
type ClosedGenerationRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export class SshPtyClosedGenerationRanges {
|
||||
private readonly ranges: ClosedGenerationRange[] = []
|
||||
|
||||
add(generation: number): void {
|
||||
let index = 0
|
||||
while (index < this.ranges.length && this.ranges[index]!.end + 1 < generation) {
|
||||
index++
|
||||
}
|
||||
const current = this.ranges[index]
|
||||
if (!current || generation + 1 < current.start) {
|
||||
this.ranges.splice(index, 0, { start: generation, end: generation })
|
||||
return
|
||||
}
|
||||
current.start = Math.min(current.start, generation)
|
||||
current.end = Math.max(current.end, generation)
|
||||
const next = this.ranges[index + 1]
|
||||
if (next && current.end + 1 >= next.start) {
|
||||
current.end = Math.max(current.end, next.end)
|
||||
this.ranges.splice(index + 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
has(generation: number): boolean {
|
||||
for (const range of this.ranges) {
|
||||
if (generation < range.start) {
|
||||
return false
|
||||
}
|
||||
if (generation <= range.end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.ranges.length
|
||||
}
|
||||
|
||||
get activeGaps(): number {
|
||||
const highWater = this.ranges.at(-1)?.end ?? 0
|
||||
let closedGenerations = 0
|
||||
for (const range of this.ranges) {
|
||||
closedGenerations += range.end - range.start + 1
|
||||
}
|
||||
// Why: provider generations allocate from 1, so unclosed IDs below high-water remain active.
|
||||
return highWater - closedGenerations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
projectionError,
|
||||
reclaimProjectionRecord,
|
||||
type DesktopProjectionSpan,
|
||||
type ProjectionRecord
|
||||
} from './ssh-pty-legacy-projection-record'
|
||||
|
||||
export function publishLegacyProjectionPrefix(
|
||||
records: Map<string, ProjectionRecord>,
|
||||
ids: readonly string[],
|
||||
displayChars: number,
|
||||
accountingChars: number
|
||||
): void {
|
||||
let displayRemaining = Math.max(0, displayChars)
|
||||
let accountingRemaining = Math.max(0, accountingChars)
|
||||
for (const id of ids) {
|
||||
const record = records.get(id)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const displayLength =
|
||||
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
|
||||
const unpublishedDisplay = displayLength - record.publishedDisplay
|
||||
const unpublishedAccounting = record.semantics.identity.rawLength - record.publishedAccounting
|
||||
if (displayLength === 0 && unpublishedAccounting > 0) {
|
||||
const publishAccounting = Math.min(accountingRemaining, unpublishedAccounting)
|
||||
if (publishAccounting !== unpublishedAccounting) {
|
||||
throw projectionError('ssh_projection_indivisible_split')
|
||||
}
|
||||
record.publishedAccounting += publishAccounting
|
||||
record.state = 'published'
|
||||
accountingRemaining -= publishAccounting
|
||||
continue
|
||||
}
|
||||
if (unpublishedDisplay <= 0) {
|
||||
continue
|
||||
}
|
||||
const publishDisplay = Math.min(displayRemaining, unpublishedDisplay)
|
||||
if (publishDisplay <= 0) {
|
||||
break
|
||||
}
|
||||
if (record.semantics.identity.transformed && publishDisplay !== unpublishedDisplay) {
|
||||
throw projectionError('ssh_projection_indivisible_split')
|
||||
}
|
||||
const publishAccounting =
|
||||
publishDisplay === unpublishedDisplay
|
||||
? Math.min(accountingRemaining, unpublishedAccounting)
|
||||
: publishDisplay
|
||||
record.publishedDisplay += publishDisplay
|
||||
record.publishedAccounting += publishAccounting
|
||||
record.state = 'published'
|
||||
displayRemaining -= publishDisplay
|
||||
accountingRemaining -= publishAccounting
|
||||
}
|
||||
if (displayRemaining !== 0 || accountingRemaining !== 0) {
|
||||
throw projectionError('ssh_projection_publish_range_mismatch')
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUnpublishedLegacyProjection(
|
||||
records: ReadonlyMap<string, ProjectionRecord>,
|
||||
id: string
|
||||
): boolean {
|
||||
const record = records.get(id)
|
||||
if (!record || record.state === 'reserved') {
|
||||
return false
|
||||
}
|
||||
const displayLength =
|
||||
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
|
||||
return (
|
||||
record.publishedDisplay < displayLength ||
|
||||
record.publishedAccounting < record.semantics.identity.rawLength
|
||||
)
|
||||
}
|
||||
|
||||
export function settlePublishedLegacyProjectionPrefix(
|
||||
records: Map<string, ProjectionRecord>,
|
||||
idsByPty: Map<string, string[]>,
|
||||
ptyId: string,
|
||||
accountingChars: number,
|
||||
onSettled: ((span: DesktopProjectionSpan, reason: string) => void) | undefined
|
||||
): { settled: number; completed: number } {
|
||||
let remaining = Math.max(0, accountingChars)
|
||||
let settled = 0
|
||||
let completed = 0
|
||||
for (const id of idsByPty.get(ptyId)?.slice() ?? []) {
|
||||
const record = records.get(id)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const available = record.publishedAccounting - record.settledAccounting
|
||||
if (available <= 0) {
|
||||
continue
|
||||
}
|
||||
const take = Math.min(remaining, available)
|
||||
const finishes =
|
||||
record.settledAccounting + take === record.semantics.identity.rawLength &&
|
||||
record.publishedDisplay ===
|
||||
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
|
||||
if (finishes && record.semantics.desktopSpan) {
|
||||
onSettled?.(record.semantics.desktopSpan, 'renderer-parse')
|
||||
}
|
||||
record.settledAccounting += take
|
||||
settled += take
|
||||
remaining -= take
|
||||
if (finishes) {
|
||||
completed++
|
||||
reclaimProjectionRecord(records, idsByPty, id, ptyId)
|
||||
}
|
||||
if (remaining === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { settled, completed }
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
Mode2031ReplyDecision,
|
||||
Mode2031ReplyScanState
|
||||
} from '../../shared/terminal-color-scheme-protocol'
|
||||
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../shared/terminal-color-scheme-protocol'
|
||||
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
|
||||
|
||||
export type LegacySshProjectionIdentity = Readonly<{
|
||||
projectionSemanticsId: string
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
displayStart: number
|
||||
displayEnd: number
|
||||
sequenceEnd: number
|
||||
rawLength: number
|
||||
transformed: boolean
|
||||
}>
|
||||
|
||||
export type LegacySshProjectionSemantics = Readonly<{
|
||||
identity: LegacySshProjectionIdentity
|
||||
desktopSpan?: DesktopProjectionSpan
|
||||
beforeScanner: Readonly<Mode2031ReplyScanState>
|
||||
afterScanner: Readonly<Mode2031ReplyScanState>
|
||||
decision: Mode2031ReplyDecision
|
||||
}>
|
||||
|
||||
export type DesktopProjectionSpan = Readonly<
|
||||
TerminalOutputSourceRange & {
|
||||
projectionSemanticsId: string
|
||||
transform: Readonly<{
|
||||
transformed: boolean
|
||||
rawLengthSu: number
|
||||
scalarSafe: boolean
|
||||
}>
|
||||
}
|
||||
>
|
||||
|
||||
export type ProjectionState = 'reserved' | 'committed' | 'published'
|
||||
|
||||
export type ProjectionRecord = {
|
||||
semantics: LegacySshProjectionSemantics
|
||||
state: ProjectionState
|
||||
publishedDisplay: number
|
||||
publishedAccounting: number
|
||||
settledAccounting: number
|
||||
}
|
||||
|
||||
export type PtyProjectionCursor = {
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
displayEnd: number
|
||||
scanner: Mode2031ReplyScanState
|
||||
}
|
||||
|
||||
export type LegacySshProjectionReservation = Readonly<{
|
||||
semantics: LegacySshProjectionSemantics
|
||||
}>
|
||||
|
||||
export type LegacySshProjectionDebugSnapshot = {
|
||||
reserved: number
|
||||
committed: number
|
||||
published: number
|
||||
settled: number
|
||||
transferred: number
|
||||
rolledBack: number
|
||||
records: number
|
||||
cursors: number
|
||||
}
|
||||
|
||||
export function scannerSnapshot(state: Mode2031ReplyScanState): Readonly<Mode2031ReplyScanState> {
|
||||
return Object.freeze({ tail: state.tail, pendingSubscribe: state.pendingSubscribe })
|
||||
}
|
||||
|
||||
export function resetProjectionCursorForGap(
|
||||
cursors: ReadonlyMap<string, PtyProjectionCursor>,
|
||||
ptyId: string
|
||||
): void {
|
||||
const cursor = cursors.get(ptyId)
|
||||
if (cursor) {
|
||||
cursor.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
|
||||
}
|
||||
}
|
||||
|
||||
export function projectionError(code: string): Error {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
|
||||
export function projectionDebugSnapshot(
|
||||
records: ReadonlyMap<string, ProjectionRecord>,
|
||||
cursors: ReadonlyMap<string, PtyProjectionCursor>,
|
||||
terminalCounts: { settled: number; transferred: number; rolledBack: number }
|
||||
): LegacySshProjectionDebugSnapshot {
|
||||
const result = {
|
||||
reserved: 0,
|
||||
committed: 0,
|
||||
published: 0,
|
||||
...terminalCounts,
|
||||
records: records.size,
|
||||
cursors: cursors.size
|
||||
}
|
||||
for (const record of records.values()) {
|
||||
result[record.state]++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function reclaimProjectionRecord(
|
||||
records: Map<string, ProjectionRecord>,
|
||||
idsByPty: Map<string, string[]>,
|
||||
id: string,
|
||||
ptyId: string
|
||||
): void {
|
||||
records.delete(id)
|
||||
const ids = idsByPty.get(ptyId)
|
||||
if (!ids) {
|
||||
return
|
||||
}
|
||||
const index = ids.indexOf(id)
|
||||
if (index >= 0) {
|
||||
ids.splice(index, 1)
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
idsByPty.delete(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
export function requireProjectionRecord(
|
||||
records: ReadonlyMap<string, ProjectionRecord>,
|
||||
id: string
|
||||
): ProjectionRecord {
|
||||
const record = records.get(id)
|
||||
if (!record) {
|
||||
throw projectionError('ssh_projection_reservation_missing')
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export function rollbackCommittedProjectionRecord(
|
||||
records: Map<string, ProjectionRecord>,
|
||||
idsByPty: Map<string, string[]>,
|
||||
cursors: Map<string, PtyProjectionCursor>,
|
||||
reservation: LegacySshProjectionReservation
|
||||
): string | null {
|
||||
const id = reservation.semantics.identity.projectionSemanticsId
|
||||
const record = records.get(id)
|
||||
if (!record || record.state !== 'committed') {
|
||||
return null
|
||||
}
|
||||
const { identity, beforeScanner } = record.semantics
|
||||
const cursor = cursors.get(identity.ptyId)
|
||||
if (
|
||||
idsByPty.get(identity.ptyId)?.at(-1) !== id ||
|
||||
!cursor ||
|
||||
cursor.providerGeneration !== identity.providerGeneration ||
|
||||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
|
||||
cursor.displayEnd !== identity.displayEnd
|
||||
) {
|
||||
return null
|
||||
}
|
||||
cursor.displayEnd = identity.displayStart
|
||||
cursor.scanner = { ...beforeScanner }
|
||||
reclaimProjectionRecord(records, idsByPty, id, identity.ptyId)
|
||||
return identity.ptyId
|
||||
}
|
||||
|
||||
export function closeProjectionPty(
|
||||
cursors: Map<string, PtyProjectionCursor>,
|
||||
ptyId: string,
|
||||
providerGeneration: number,
|
||||
ptyIncarnation: string,
|
||||
beforeDelete: () => void
|
||||
): void {
|
||||
const cursor = cursors.get(ptyId)
|
||||
if (
|
||||
!cursor ||
|
||||
cursor.providerGeneration !== providerGeneration ||
|
||||
cursor.ptyIncarnation !== ptyIncarnation
|
||||
) {
|
||||
return
|
||||
}
|
||||
beforeDelete()
|
||||
cursors.delete(ptyId)
|
||||
}
|
||||
|
||||
export function getOrCreateProjectionCursor(
|
||||
cursors: Map<string, PtyProjectionCursor>,
|
||||
args: { ptyId: string; providerGeneration: number; ptyIncarnation: string },
|
||||
replaceGeneration: (providerGeneration: number) => void
|
||||
): PtyProjectionCursor {
|
||||
const existing = cursors.get(args.ptyId)
|
||||
if (!existing) {
|
||||
const cursor = {
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation: args.ptyIncarnation,
|
||||
displayEnd: 0,
|
||||
scanner: { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
|
||||
}
|
||||
cursors.set(args.ptyId, cursor)
|
||||
return cursor
|
||||
}
|
||||
if (args.providerGeneration < existing.providerGeneration) {
|
||||
throw projectionError('ssh_projection_stale_generation')
|
||||
}
|
||||
if (
|
||||
args.providerGeneration === existing.providerGeneration &&
|
||||
args.ptyIncarnation !== existing.ptyIncarnation
|
||||
) {
|
||||
throw projectionError('ssh_projection_stale_incarnation')
|
||||
}
|
||||
if (args.providerGeneration > existing.providerGeneration) {
|
||||
replaceGeneration(existing.providerGeneration)
|
||||
existing.providerGeneration = args.providerGeneration
|
||||
existing.ptyIncarnation = args.ptyIncarnation
|
||||
existing.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
|
||||
}
|
||||
return existing
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SshPtyLegacyProjectionLedger } from './ssh-pty-legacy-projection'
|
||||
import { SshPtyProjectionTerminality } from './ssh-pty-projection-terminality'
|
||||
|
||||
function reserve(
|
||||
ledger: SshPtyLegacyProjectionLedger,
|
||||
overrides: Partial<Parameters<SshPtyLegacyProjectionLedger['reserve']>[0]> = {}
|
||||
) {
|
||||
return ledger.reserve({
|
||||
ptyId: 'pty-1',
|
||||
providerGeneration: 3,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
data: 'abc',
|
||||
sequenceEnd: 3,
|
||||
rawLength: 3,
|
||||
transformed: false,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function source(sourceStartSu: number, sourceEndSu: number, deliveryToken = 'token-1') {
|
||||
return {
|
||||
spanId: `span-${sourceStartSu}`,
|
||||
clientGeneration: 2,
|
||||
ownerGeneration: 4,
|
||||
deliveryToken,
|
||||
sourceStartSu,
|
||||
sourceEndSu
|
||||
}
|
||||
}
|
||||
|
||||
describe('SshPtyLegacyProjectionLedger', () => {
|
||||
it('drains exact terminality waiters when their provider generation closes', async () => {
|
||||
const terminality = new SshPtyProjectionTerminality()
|
||||
const terminal = terminality.whenTerminal('pty-1', 3, 'incarnation-1', () => true)
|
||||
let nextResolved = false
|
||||
const next = terminality
|
||||
.whenTerminal('pty-1', 4, 'incarnation-2', () => true)
|
||||
.then(() => {
|
||||
nextResolved = true
|
||||
})
|
||||
|
||||
terminality.closeGeneration(3)
|
||||
|
||||
await expect(terminal).resolves.toBeUndefined()
|
||||
expect(nextResolved).toBe(false)
|
||||
terminality.closeGeneration(4)
|
||||
await next
|
||||
})
|
||||
|
||||
it('rolls back scanner and display reservations before commit', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const partial = reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 })
|
||||
expect(ledger.rollback(partial)).toBe(true)
|
||||
|
||||
const next = reserve(ledger, { data: '31h', sequenceEnd: 3 })
|
||||
expect(next.semantics.identity.displayStart).toBe(0)
|
||||
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
|
||||
expect(next.semantics.decision).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps immutable generation, incarnation, display, sequence, raw length, and scanner facts', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const first = reserve(ledger, {
|
||||
data: '\x1b[?2031h',
|
||||
rawLength: 11,
|
||||
sequenceEnd: 11
|
||||
})
|
||||
const semantics = ledger.commit(first)
|
||||
|
||||
expect(semantics.identity).toMatchObject({
|
||||
providerGeneration: 3,
|
||||
ptyIncarnation: 'incarnation-1',
|
||||
displayStart: 0,
|
||||
displayEnd: 8,
|
||||
sequenceEnd: 11,
|
||||
rawLength: 11
|
||||
})
|
||||
expect(semantics.decision).toBe('subscribed')
|
||||
expect(Object.isFrozen(semantics)).toBe(true)
|
||||
expect(Object.isFrozen(semantics.identity)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects stale generations and resets cross-chunk scanner state on gaps', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
ledger.commit(reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 }))
|
||||
ledger.resetForGap('pty-1')
|
||||
const next = reserve(ledger, { data: '31h', sequenceEnd: 8 })
|
||||
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
|
||||
|
||||
expect(() => reserve(ledger, { providerGeneration: 2 })).toThrow(
|
||||
'ssh_projection_stale_generation'
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes, settles, and transfers explicit ranges', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const first = ledger.commit(reserve(ledger))
|
||||
ledger.publishPrefix([first.identity.projectionSemanticsId], 3, 3)
|
||||
expect(ledger.settlePublishedPrefix('pty-1', 2)).toBe(2)
|
||||
expect(ledger.transfer([first.identity.projectionSemanticsId], 'renderer-reload')).toBe(1)
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({ transferred: 1, records: 0 })
|
||||
})
|
||||
|
||||
it('resolves exact PTY terminal waiters only after settlement or transfer', async () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const projection = ledger.commit(reserve(ledger))
|
||||
ledger.publishPrefix([projection.identity.projectionSemanticsId], 3, 3)
|
||||
|
||||
let settled = false
|
||||
const terminal = ledger.whenPtyTerminal('pty-1', 3, 'incarnation-1').then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
ledger.settlePublishedPrefix('pty-1', 2)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
ledger.transfer([projection.identity.projectionSemanticsId], 'renderer-reload')
|
||||
await terminal
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes and settles transformed source accounting with no display text', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
const projection = ledger.commit(
|
||||
reserve(ledger, {
|
||||
data: '',
|
||||
sequenceEnd: 9,
|
||||
rawLength: 9,
|
||||
transformed: true
|
||||
})
|
||||
)
|
||||
|
||||
ledger.publishPrefix([projection.identity.projectionSemanticsId], 0, 9)
|
||||
expect(ledger.settlePublishedPrefix('pty-1', 9)).toBe(9)
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({ settled: 1, records: 0 })
|
||||
})
|
||||
|
||||
it('reclaims a closed PTY cursor so its id can be reused by a new incarnation', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger()
|
||||
ledger.commit(reserve(ledger))
|
||||
ledger.closePty('pty-1', 3, 'incarnation-1', 'pty-exit')
|
||||
|
||||
const next = reserve(ledger, {
|
||||
ptyIncarnation: 'incarnation-2',
|
||||
data: 'next',
|
||||
sequenceEnd: 4,
|
||||
rawLength: 4
|
||||
})
|
||||
expect(next.semantics.identity).toMatchObject({
|
||||
ptyIncarnation: 'incarnation-2',
|
||||
displayStart: 0
|
||||
})
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({ records: 1, cursors: 1 })
|
||||
})
|
||||
|
||||
it('keeps split publication attached to one immutable desktop span', () => {
|
||||
const settled: unknown[] = []
|
||||
const ledger = new SshPtyLegacyProjectionLedger({
|
||||
onSettled: (span) => settled.push(span)
|
||||
})
|
||||
const projection = ledger.commit(
|
||||
reserve(ledger, {
|
||||
data: 'abcd',
|
||||
rawLength: 4,
|
||||
sequenceEnd: 4,
|
||||
source: source(0, 4)
|
||||
})
|
||||
)
|
||||
const id = projection.identity.projectionSemanticsId
|
||||
expect(ledger.hasUnpublished(id)).toBe(true)
|
||||
|
||||
ledger.publishPrefix([id], 2, 2)
|
||||
expect(ledger.hasUnpublished(id)).toBe(true)
|
||||
ledger.settlePublishedPrefix('pty-1', 2)
|
||||
expect(settled).toEqual([])
|
||||
ledger.publishPrefix([id], 2, 2)
|
||||
expect(ledger.hasUnpublished(id)).toBe(false)
|
||||
ledger.settlePublishedPrefix('pty-1', 2)
|
||||
|
||||
expect(settled).toEqual([
|
||||
expect.objectContaining({
|
||||
spanId: 'span-0',
|
||||
projectionSemanticsId: id,
|
||||
sourceStartSu: 0,
|
||||
sourceEndSu: 4,
|
||||
displayStart: 0,
|
||||
displayEnd: 4
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves scanner and display facts after transfer and delivery-token replacement', () => {
|
||||
const transferred: unknown[] = []
|
||||
const ledger = new SshPtyLegacyProjectionLedger({
|
||||
onTransferred: (span) => transferred.push(span)
|
||||
})
|
||||
const partial = ledger.commit(
|
||||
reserve(ledger, {
|
||||
data: '\x1b[?20',
|
||||
rawLength: 5,
|
||||
sequenceEnd: 5,
|
||||
source: source(0, 5)
|
||||
})
|
||||
)
|
||||
ledger.transfer([partial.identity.projectionSemanticsId], 'renderer-reload')
|
||||
const continuation = reserve(ledger, {
|
||||
data: '31h',
|
||||
rawLength: 3,
|
||||
sequenceEnd: 8,
|
||||
source: source(5, 8, 'token-2')
|
||||
})
|
||||
|
||||
expect(continuation.semantics.identity.displayStart).toBe(5)
|
||||
expect(continuation.semantics.beforeScanner.tail).toBe('\x1b[?20')
|
||||
expect(continuation.semantics.decision).toBe('subscribed')
|
||||
expect(transferred).toEqual([
|
||||
expect.objectContaining({
|
||||
deliveryToken: 'token-1',
|
||||
sourceStartSu: 0,
|
||||
sourceEndSu: 5
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a projection intact when its terminal transition cannot commit', () => {
|
||||
const ledger = new SshPtyLegacyProjectionLedger({
|
||||
onTransferred: () => {
|
||||
throw new Error('replacement unavailable')
|
||||
}
|
||||
})
|
||||
const projection = ledger.commit(reserve(ledger, { source: source(0, 3) }))
|
||||
|
||||
expect(() =>
|
||||
ledger.transfer([projection.identity.projectionSemanticsId], 'renderer-reload')
|
||||
).toThrow('replacement unavailable')
|
||||
expect(ledger.getDebugSnapshot()).toMatchObject({
|
||||
transferred: 0,
|
||||
records: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,318 @@
|
||||
import { scanMode2031ReplyDecision } from '../../shared/terminal-color-scheme-protocol'
|
||||
import {
|
||||
closeProjectionPty,
|
||||
getOrCreateProjectionCursor,
|
||||
projectionDebugSnapshot,
|
||||
projectionError,
|
||||
reclaimProjectionRecord,
|
||||
resetProjectionCursorForGap,
|
||||
requireProjectionRecord,
|
||||
rollbackCommittedProjectionRecord,
|
||||
scannerSnapshot,
|
||||
type DesktopProjectionSpan,
|
||||
type LegacySshProjectionDebugSnapshot,
|
||||
type LegacySshProjectionReservation,
|
||||
type LegacySshProjectionSemantics,
|
||||
type ProjectionRecord,
|
||||
type PtyProjectionCursor
|
||||
} from './ssh-pty-legacy-projection-record'
|
||||
import {
|
||||
projectionHasOpen,
|
||||
resolveProjectionTerminality,
|
||||
SshPtyProjectionTerminality,
|
||||
unpublishedProjectionIds
|
||||
} from './ssh-pty-projection-terminality'
|
||||
import {
|
||||
hasUnpublishedLegacyProjection,
|
||||
publishLegacyProjectionPrefix,
|
||||
settlePublishedLegacyProjectionPrefix
|
||||
} from './ssh-pty-legacy-projection-publication'
|
||||
|
||||
export type {
|
||||
DesktopProjectionSpan,
|
||||
LegacySshProjectionIdentity,
|
||||
LegacySshProjectionReservation,
|
||||
LegacySshProjectionSemantics
|
||||
} from './ssh-pty-legacy-projection-record'
|
||||
|
||||
export type SshPtyLegacyProjectionLedgerOptions = {
|
||||
onSettled?: (span: DesktopProjectionSpan, reason: string) => void
|
||||
onTransferred?: (span: DesktopProjectionSpan, reason: string) => void
|
||||
}
|
||||
|
||||
export class SshPtyLegacyProjectionLedger {
|
||||
private nextId = 1
|
||||
private readonly records = new Map<string, ProjectionRecord>()
|
||||
private readonly cursorByPty = new Map<string, PtyProjectionCursor>()
|
||||
private readonly idsByPty = new Map<string, string[]>()
|
||||
private readonly terminality = new SshPtyProjectionTerminality()
|
||||
private settledCount = 0
|
||||
private transferredCount = 0
|
||||
private rolledBackCount = 0
|
||||
|
||||
constructor(private readonly options: SshPtyLegacyProjectionLedgerOptions = {}) {}
|
||||
|
||||
reserve(args: {
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
ptyIncarnation: string
|
||||
data: string
|
||||
sequenceEnd: number
|
||||
rawLength: number
|
||||
transformed: boolean
|
||||
source?: Readonly<{
|
||||
relayPtyId?: string
|
||||
spanId: string
|
||||
clientGeneration: number
|
||||
ownerGeneration: number
|
||||
deliveryToken: string
|
||||
sourceStartSu: number
|
||||
sourceEndSu: number
|
||||
}>
|
||||
}): LegacySshProjectionReservation {
|
||||
const cursor = getOrCreateProjectionCursor(this.cursorByPty, args, (generation) =>
|
||||
this.transferGeneration(generation, 'provider-generation-replaced')
|
||||
)
|
||||
if (
|
||||
cursor.providerGeneration !== args.providerGeneration ||
|
||||
cursor.ptyIncarnation !== args.ptyIncarnation
|
||||
) {
|
||||
throw projectionError('ssh_projection_stale_generation')
|
||||
}
|
||||
const scan = scanMode2031ReplyDecision(cursor.scanner, args.data)
|
||||
const projectionSemanticsId = `ssh-projection:${args.providerGeneration}:${this.nextId++}`
|
||||
const identity = Object.freeze({
|
||||
projectionSemanticsId,
|
||||
ptyId: args.ptyId,
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation: args.ptyIncarnation,
|
||||
displayStart: cursor.displayEnd,
|
||||
displayEnd: cursor.displayEnd + args.data.length,
|
||||
sequenceEnd: args.sequenceEnd,
|
||||
rawLength: args.rawLength,
|
||||
transformed: args.transformed
|
||||
})
|
||||
const semantics = Object.freeze({
|
||||
identity,
|
||||
...(args.source
|
||||
? {
|
||||
desktopSpan: Object.freeze({
|
||||
...args.source,
|
||||
id: args.source.relayPtyId ?? args.ptyId,
|
||||
projectionSemanticsId,
|
||||
providerGeneration: args.providerGeneration,
|
||||
ptyIncarnation: args.ptyIncarnation,
|
||||
displayStart: identity.displayStart,
|
||||
displayEnd: identity.displayEnd,
|
||||
splittable: !args.transformed,
|
||||
transform: Object.freeze({
|
||||
transformed: args.transformed,
|
||||
rawLengthSu: args.rawLength,
|
||||
scalarSafe: !args.transformed
|
||||
})
|
||||
})
|
||||
}
|
||||
: {}),
|
||||
beforeScanner: scannerSnapshot(cursor.scanner),
|
||||
afterScanner: scannerSnapshot(scan.state),
|
||||
decision: scan.decision
|
||||
})
|
||||
this.records.set(projectionSemanticsId, {
|
||||
semantics,
|
||||
state: 'reserved',
|
||||
publishedDisplay: 0,
|
||||
publishedAccounting: 0,
|
||||
settledAccounting: 0
|
||||
})
|
||||
return Object.freeze({ semantics })
|
||||
}
|
||||
|
||||
commit(reservation: LegacySshProjectionReservation): LegacySshProjectionSemantics {
|
||||
const record = requireProjectionRecord(
|
||||
this.records,
|
||||
reservation.semantics.identity.projectionSemanticsId
|
||||
)
|
||||
if (record.state !== 'reserved') {
|
||||
throw projectionError('ssh_projection_commit_invalid')
|
||||
}
|
||||
const { identity, afterScanner } = record.semantics
|
||||
const cursor = this.cursorByPty.get(identity.ptyId)
|
||||
if (
|
||||
!cursor ||
|
||||
cursor.providerGeneration !== identity.providerGeneration ||
|
||||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
|
||||
cursor.displayEnd !== identity.displayStart
|
||||
) {
|
||||
this.records.delete(identity.projectionSemanticsId)
|
||||
throw projectionError('ssh_projection_commit_stale')
|
||||
}
|
||||
cursor.displayEnd = identity.displayEnd
|
||||
cursor.scanner = { ...afterScanner }
|
||||
record.state = 'committed'
|
||||
const ids = this.idsByPty.get(identity.ptyId) ?? []
|
||||
ids.push(identity.projectionSemanticsId)
|
||||
this.idsByPty.set(identity.ptyId, ids)
|
||||
return record.semantics
|
||||
}
|
||||
|
||||
rollback(reservation: LegacySshProjectionReservation): boolean {
|
||||
const id = reservation.semantics.identity.projectionSemanticsId
|
||||
const record = this.records.get(id)
|
||||
if (!record || record.state !== 'reserved') {
|
||||
return false
|
||||
}
|
||||
this.records.delete(id)
|
||||
this.rolledBackCount++
|
||||
return true
|
||||
}
|
||||
|
||||
rollbackCommitted(reservation: LegacySshProjectionReservation): boolean {
|
||||
const ptyId = rollbackCommittedProjectionRecord(
|
||||
this.records,
|
||||
this.idsByPty,
|
||||
this.cursorByPty,
|
||||
reservation
|
||||
)
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
this.rolledBackCount++
|
||||
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
|
||||
return true
|
||||
}
|
||||
|
||||
publishPrefix(ids: readonly string[], displayChars: number, accountingChars: number): void {
|
||||
publishLegacyProjectionPrefix(this.records, ids, displayChars, accountingChars)
|
||||
}
|
||||
|
||||
settlePublishedPrefix(ptyId: string, accountingChars: number): number {
|
||||
const result = settlePublishedLegacyProjectionPrefix(
|
||||
this.records,
|
||||
this.idsByPty,
|
||||
ptyId,
|
||||
accountingChars,
|
||||
this.options.onSettled
|
||||
)
|
||||
this.settledCount += result.completed
|
||||
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
|
||||
return result.settled
|
||||
}
|
||||
|
||||
transfer(ids: readonly string[], reason: string): number {
|
||||
let transferred = 0
|
||||
const touchedPtys = new Set<string>()
|
||||
for (const id of ids.slice()) {
|
||||
const record = this.records.get(id)
|
||||
if (!record || record.state === 'reserved') {
|
||||
continue
|
||||
}
|
||||
const ptyId = record.semantics.identity.ptyId
|
||||
if (record.semantics.desktopSpan) {
|
||||
this.options.onTransferred?.(record.semantics.desktopSpan, reason)
|
||||
}
|
||||
this.transferredCount++
|
||||
reclaimProjectionRecord(this.records, this.idsByPty, id, ptyId)
|
||||
touchedPtys.add(ptyId)
|
||||
transferred++
|
||||
}
|
||||
for (const ptyId of touchedPtys) {
|
||||
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
|
||||
}
|
||||
return transferred
|
||||
}
|
||||
|
||||
whenPtyTerminal(
|
||||
ptyId: string,
|
||||
providerGeneration: number,
|
||||
ptyIncarnation: string
|
||||
): Promise<void> {
|
||||
return this.terminality.whenTerminal(
|
||||
ptyId,
|
||||
providerGeneration,
|
||||
ptyIncarnation,
|
||||
projectionHasOpen(this.records, this.idsByPty, ptyId)
|
||||
)
|
||||
}
|
||||
|
||||
transferGeneration(providerGeneration: number, reason: string): number {
|
||||
const ids: string[] = []
|
||||
for (const [id, record] of this.records) {
|
||||
if (record.semantics.identity.providerGeneration === providerGeneration) {
|
||||
ids.push(id)
|
||||
}
|
||||
}
|
||||
return this.transfer(ids, reason)
|
||||
}
|
||||
|
||||
transferPty(ptyId: string, reason: string): number {
|
||||
return this.transfer(this.idsByPty.get(ptyId) ?? [], reason)
|
||||
}
|
||||
|
||||
transferUnpublishedPty(
|
||||
ptyId: string,
|
||||
providerGeneration: number,
|
||||
ptyIncarnation: string,
|
||||
reason: string
|
||||
): number {
|
||||
const ids = unpublishedProjectionIds(
|
||||
this.records,
|
||||
this.idsByPty.get(ptyId) ?? [],
|
||||
providerGeneration,
|
||||
ptyIncarnation
|
||||
)
|
||||
return this.transfer(ids, reason)
|
||||
}
|
||||
|
||||
closePty(
|
||||
ptyId: string,
|
||||
providerGeneration: number,
|
||||
ptyIncarnation: string,
|
||||
reason: string
|
||||
): void {
|
||||
closeProjectionPty(this.cursorByPty, ptyId, providerGeneration, ptyIncarnation, () => {
|
||||
this.transferPty(ptyId, reason)
|
||||
this.idsByPty.delete(ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
closeGeneration(providerGeneration: number, reason: string): void {
|
||||
for (const [id, record] of this.records) {
|
||||
if (record.semantics.identity.providerGeneration !== providerGeneration) {
|
||||
continue
|
||||
}
|
||||
if (record.state === 'reserved') {
|
||||
this.records.delete(id)
|
||||
this.rolledBackCount++
|
||||
} else {
|
||||
this.transfer([id], reason)
|
||||
}
|
||||
}
|
||||
for (const [ptyId, cursor] of this.cursorByPty) {
|
||||
if (cursor.providerGeneration === providerGeneration) {
|
||||
this.cursorByPty.delete(ptyId)
|
||||
this.idsByPty.delete(ptyId)
|
||||
}
|
||||
}
|
||||
this.terminality.closeGeneration(providerGeneration)
|
||||
}
|
||||
|
||||
resetForGap(ptyId: string): void {
|
||||
resetProjectionCursorForGap(this.cursorByPty, ptyId)
|
||||
}
|
||||
|
||||
get(id: string): LegacySshProjectionSemantics | undefined {
|
||||
return this.records.get(id)?.semantics
|
||||
}
|
||||
|
||||
hasUnpublished(id: string): boolean {
|
||||
return hasUnpublishedLegacyProjection(this.records, id)
|
||||
}
|
||||
|
||||
getDebugSnapshot(): LegacySshProjectionDebugSnapshot {
|
||||
return projectionDebugSnapshot(this.records, this.cursorByPty, {
|
||||
settled: this.settledCount,
|
||||
transferred: this.transferredCount,
|
||||
rolledBack: this.rolledBackCount
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type SshPtyModelAdmissionKey = Readonly<{
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
}>
|
||||
|
||||
export type SshPtyModelAdmissionReceipt = Readonly<{
|
||||
ptyId: string
|
||||
providerGeneration: number
|
||||
sequence: number
|
||||
}>
|
||||
|
||||
export type SshPtyModelAdmissionDebugSnapshot = Readonly<{
|
||||
sourceUnits: number
|
||||
bytes: number
|
||||
pressureFrames: number
|
||||
pressureBytes: number
|
||||
pausedPtys: number
|
||||
migratingPtys: number
|
||||
}>
|
||||
|
||||
export type SshPtyModelAdmissionOptions = {
|
||||
perPtyHighSourceUnits?: number
|
||||
perPtyHighBytes?: number
|
||||
perPtyLowSourceUnits?: number
|
||||
perPtyLowBytes?: number
|
||||
globalHighSourceUnits?: number
|
||||
globalHighBytes?: number
|
||||
globalLowSourceUnits?: number
|
||||
globalLowBytes?: number
|
||||
pressureMaxFrames?: number
|
||||
pressureMaxBytes?: number
|
||||
pauseProvider?: (key: SshPtyModelAdmissionKey) => boolean
|
||||
resumeProvider?: (key: SshPtyModelAdmissionKey) => void
|
||||
closeProvider?: (providerGeneration: number, reason: string) => void
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user