mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/skill-update-detection
# Conflicts: # src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts
This commit is contained in:
@@ -894,7 +894,7 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Publish release artifacts (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
|
||||
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareBenchmarkArtifacts,
|
||||
formatBenchmarkComparisonMarkdown,
|
||||
parseBenchmarkComparisonArgs
|
||||
} from './compare-benchmark-artifacts.mjs'
|
||||
|
||||
const scriptPath = 'config/scripts/compare-benchmark-artifacts.mjs'
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-benchmark-comparison-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeArtifact(dir, name, artifact) {
|
||||
const artifactPath = join(dir, name)
|
||||
writeFileSync(artifactPath, JSON.stringify(artifact))
|
||||
return artifactPath
|
||||
}
|
||||
|
||||
function comparePaths(baselinePath, candidatePath, extra = {}) {
|
||||
return compareBenchmarkArtifacts({
|
||||
baselinePath,
|
||||
candidatePath,
|
||||
now: () => new Date('2026-06-21T12:00:00.000Z'),
|
||||
title: 'Test Compare',
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('benchmark artifact comparison', () => {
|
||||
it('parses required CLI flags and rejects missing paths', () => {
|
||||
expect(() => parseBenchmarkComparisonArgs(['--baseline'])).toThrow('--baseline requires a path')
|
||||
expect(() => parseBenchmarkComparisonArgs(['--candidate'])).toThrow(
|
||||
'--candidate requires a path'
|
||||
)
|
||||
expect(() => parseBenchmarkComparisonArgs(['--candidate', 'candidate.json'])).toThrow(
|
||||
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares startup-style summary median metrics and skips null candidates', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline.json', {
|
||||
label: 'baseline',
|
||||
summaryMedianMs: {
|
||||
missingLater: 5,
|
||||
spawnToAppReady: 25,
|
||||
totalToDidFinishLoad: 100
|
||||
}
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate.json', {
|
||||
label: 'candidate',
|
||||
summaryMedianMs: {
|
||||
missingLater: null,
|
||||
spawnToAppReady: 30,
|
||||
totalToDidFinishLoad: 40
|
||||
}
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
|
||||
expect(comparison.schemaVersion).toBe(1)
|
||||
expect(comparison.createdAt).toBe('2026-06-21T12:00:00.000Z')
|
||||
expect(comparison.baseline.label).toBe('baseline')
|
||||
expect(comparison.candidate.label).toBe('candidate')
|
||||
expect(
|
||||
comparison.metrics.find((metric) => metric.key === 'totalToDidFinishLoad')
|
||||
).toMatchObject({
|
||||
absoluteDelta: -60,
|
||||
baseline: 100,
|
||||
candidate: 40,
|
||||
percentDelta: -60,
|
||||
status: 'improved',
|
||||
unit: 'ms'
|
||||
})
|
||||
expect(comparison.metrics.find((metric) => metric.key === 'spawnToAppReady')).toMatchObject({
|
||||
status: 'regressed'
|
||||
})
|
||||
expect(comparison.skippedMetrics).toContainEqual({
|
||||
key: 'missingLater',
|
||||
reason: 'missing candidate metric'
|
||||
})
|
||||
})
|
||||
|
||||
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
|
||||
suites: [
|
||||
{
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-50',
|
||||
description:
|
||||
'panes=50 frames=60 median=80.0ms worst=120.0ms rendererQueuedChars=1000 samples=1,2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate-playwright.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-50',
|
||||
description:
|
||||
'panes=50 frames=60 median=60.0ms worst=100.0ms rendererQueuedChars=800 samples=1,2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
const metricKeys = comparison.metrics.map((metric) => metric.key)
|
||||
|
||||
expect(metricKeys).toContain('opencode-scale-same-workspace-50.median')
|
||||
expect(metricKeys).toContain('opencode-scale-same-workspace-50.rendererQueuedChars')
|
||||
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.panes')
|
||||
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.frames')
|
||||
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.samples')
|
||||
expect(
|
||||
comparison.metrics.find((metric) => metric.key === 'opencode-scale-same-workspace-50.median')
|
||||
).toMatchObject({ status: 'improved', unit: 'ms' })
|
||||
})
|
||||
|
||||
it('aggregates duplicate Playwright scenario metrics before comparison', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright-duplicates.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=80.0ms rendererQueuedChars=1000'
|
||||
},
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=100.0ms rendererQueuedChars=1400'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate-playwright-duplicates.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=60.0ms rendererQueuedChars=800'
|
||||
},
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=70.0ms rendererQueuedChars=1000'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
const duplicateMedianMetrics = comparison.metrics.filter(
|
||||
(metric) => metric.key === 'opencode-duplicate.median'
|
||||
)
|
||||
|
||||
expect(duplicateMedianMetrics).toHaveLength(1)
|
||||
expect(duplicateMedianMetrics[0]).toMatchObject({
|
||||
absoluteDelta: -25,
|
||||
baseline: 90,
|
||||
candidate: 65,
|
||||
percentDelta: -27.8,
|
||||
status: 'improved',
|
||||
unit: 'ms'
|
||||
})
|
||||
expect(
|
||||
comparison.metrics.filter((metric) => metric.key === 'opencode-duplicate.rendererQueuedChars')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips unit mismatches instead of comparing incompatible metrics', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright-units.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-units',
|
||||
description: 'median=80.0ms rendererQueuedChars=1000'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate-playwright-units.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-units',
|
||||
description: 'median=60 rendererQueuedChars=800'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
|
||||
expect(comparison.metrics.map((metric) => metric.key)).toEqual([
|
||||
'opencode-units.rendererQueuedChars'
|
||||
])
|
||||
expect(comparison.skippedMetrics).toContainEqual({
|
||||
key: 'opencode-units.median',
|
||||
reason: 'unit mismatch (ms vs count)'
|
||||
})
|
||||
})
|
||||
|
||||
it('supports higher-is-better metrics for generic summary artifacts', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'generic-baseline.json', {
|
||||
summary: {
|
||||
totalBytes: 2048,
|
||||
totalCpuPercent: { mean: 80 },
|
||||
throughput: 10
|
||||
}
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'generic-candidate.json', {
|
||||
summary: {
|
||||
totalBytes: 1024,
|
||||
totalCpuPercent: { mean: 70 },
|
||||
throughput: 12
|
||||
}
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath, {
|
||||
higherIsBetter: new Set(['summary.throughput'])
|
||||
})
|
||||
|
||||
expect(comparison.metrics.find((metric) => metric.key === 'summary.throughput')).toMatchObject({
|
||||
direction: 'higher-is-better',
|
||||
status: 'improved'
|
||||
})
|
||||
expect(comparison.metrics.find((metric) => metric.key === 'summary.totalBytes')).toMatchObject({
|
||||
unit: 'bytes'
|
||||
})
|
||||
expect(
|
||||
comparison.metrics.find((metric) => metric.key === 'summary.totalCpuPercent.mean')
|
||||
).toMatchObject({
|
||||
unit: '%'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes Markdown and JSON reports from the CLI while printing Markdown', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline.json', {
|
||||
label: 'baseline',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 100 }
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate.json', {
|
||||
label: 'candidate',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 40 }
|
||||
})
|
||||
const markdownPath = join(dir, 'nested', 'comparison.md')
|
||||
const jsonPath = join(dir, 'nested', 'comparison.json')
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
scriptPath,
|
||||
'--baseline',
|
||||
baselinePath,
|
||||
'--candidate',
|
||||
candidatePath,
|
||||
'--title',
|
||||
'Test Compare',
|
||||
'--output',
|
||||
markdownPath,
|
||||
'--json-output',
|
||||
jsonPath
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('| Metric | Baseline | Candidate | Delta | Delta % | Result |')
|
||||
expect(result.stdout).toContain(
|
||||
'| totalToDidFinishLoad | 100.0ms | 40.0ms | -60.0ms | -60.0% | improved |'
|
||||
)
|
||||
expect(existsSync(markdownPath)).toBe(true)
|
||||
expect(existsSync(jsonPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(jsonPath, 'utf8')).schemaVersion).toBe(1)
|
||||
})
|
||||
|
||||
it('redacts absolute input paths from generated reports', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'absolute-baseline.json', {
|
||||
label: 'baseline',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 100 }
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'absolute-candidate.json', {
|
||||
label: 'candidate',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 40 }
|
||||
})
|
||||
const markdownPath = join(dir, 'comparison.md')
|
||||
const jsonPath = join(dir, 'comparison.json')
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
scriptPath,
|
||||
'--baseline',
|
||||
baselinePath,
|
||||
'--candidate',
|
||||
candidatePath,
|
||||
'--output',
|
||||
markdownPath,
|
||||
'--json-output',
|
||||
jsonPath
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' }
|
||||
)
|
||||
const json = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
const markdown = readFileSync(markdownPath, 'utf8')
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(json.baseline.path).toBe('absolute-baseline.json')
|
||||
expect(json.candidate.path).toBe('absolute-candidate.json')
|
||||
expect(markdown).not.toContain(dir)
|
||||
expect(result.stdout).not.toContain(dir)
|
||||
})
|
||||
|
||||
it('escapes artifact-controlled Markdown fields in reports', () => {
|
||||
const markdown = formatBenchmarkComparisonMarkdown({
|
||||
title: 'Compare\n[Injected](https://example.test)',
|
||||
createdAt: '2026-06-21T12:00:00.000Z',
|
||||
baseline: { label: 'base\n<label>', path: 'base|path', kind: 'summary' },
|
||||
candidate: { label: 'candidate', path: 'candidate.md', kind: 'summary' },
|
||||
metrics: [
|
||||
{
|
||||
key: 'summary.value|with-pipe',
|
||||
unit: '',
|
||||
baseline: 1,
|
||||
candidate: 2,
|
||||
absoluteDelta: 1,
|
||||
percentDelta: 100,
|
||||
status: 'regressed'
|
||||
}
|
||||
],
|
||||
skippedMetrics: [{ key: 'missing\nmetric', reason: 'missing | candidate' }]
|
||||
})
|
||||
|
||||
expect(markdown).toContain('# Compare \\[Injected\\]\\(https://example\\.test\\)')
|
||||
expect(markdown).toContain('Baseline: base \\<label\\> (base|path)')
|
||||
expect(markdown).toContain(
|
||||
'| summary\\.value\\|with\\-pipe | 1.0 | 2.0 | 1.0 | 100.0% | regressed |'
|
||||
)
|
||||
expect(markdown).toContain('- missing metric: missing | candidate')
|
||||
})
|
||||
|
||||
it('fails unsupported artifacts in the CLI', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline.json', { hello: 'world' })
|
||||
const candidatePath = writeArtifact(dir, 'candidate.json', { hello: 'world' })
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--baseline', baselinePath, '--candidate', candidatePath],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('unsupported benchmark artifact')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename } from 'node:path'
|
||||
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
|
||||
|
||||
const reportPaths = process.argv.slice(2)
|
||||
if (reportPaths[0] === '--') {
|
||||
@@ -26,55 +26,6 @@ const BUDGETS = {
|
||||
maxRendererDroppedBacklogs: 0
|
||||
}
|
||||
|
||||
function readJsonReport(path) {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const start = raw.indexOf('{')
|
||||
const end = raw.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) {
|
||||
throw new Error(`${path}: no JSON object found`)
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function parseAnnotationDescription(description) {
|
||||
const values = {}
|
||||
for (const part of description.split(/\s+/)) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
values[part.slice(0, index)] = part.slice(index + 1)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function collectTerminalPerfRows(report, source) {
|
||||
const rows = []
|
||||
const visitSuite = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const annotation of test.annotations ?? []) {
|
||||
if (!annotation.type.startsWith('opencode-')) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
source,
|
||||
scenario: annotation.type,
|
||||
...parseAnnotationDescription(annotation.description ?? '')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visitSuite(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visitSuite(suite)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function parseMs(value, fieldName, row, failures) {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, dirname, isAbsolute, relative } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { collectTerminalPerfRows } from './terminal-perf-report-annotations.mjs'
|
||||
|
||||
const USAGE =
|
||||
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
|
||||
|
||||
const PLAYWRIGHT_METADATA_FIELDS = new Set(['source', 'scenario', 'panes', 'frames', 'samples'])
|
||||
|
||||
export function parseBenchmarkComparisonArgs(argv = process.argv.slice(2)) {
|
||||
const args = argv[0] === '--' ? argv.slice(1) : [...argv]
|
||||
const parsed = {
|
||||
higherIsBetter: new Set(),
|
||||
title: 'Benchmark comparison'
|
||||
}
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const flag = args[index]
|
||||
if (flag === '--baseline') {
|
||||
parsed.baselinePath = readRequiredValue(args, ++index, '--baseline requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--candidate') {
|
||||
parsed.candidatePath = readRequiredValue(args, ++index, '--candidate requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--title') {
|
||||
parsed.title = readRequiredValue(args, ++index, '--title requires a value')
|
||||
continue
|
||||
}
|
||||
if (flag === '--output') {
|
||||
parsed.outputPath = readRequiredValue(args, ++index, '--output requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--json-output') {
|
||||
parsed.jsonOutputPath = readRequiredValue(args, ++index, '--json-output requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--higher-is-better') {
|
||||
let consumed = 0
|
||||
while (args[index + 1] != null && !args[index + 1].startsWith('--')) {
|
||||
parsed.higherIsBetter.add(args[index + 1])
|
||||
index += 1
|
||||
consumed += 1
|
||||
}
|
||||
if (consumed === 0) {
|
||||
throw new Error('--higher-is-better requires a metric key')
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
|
||||
if (!parsed.baselinePath) {
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
if (!parsed.candidatePath) {
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readRequiredValue(args, index, message) {
|
||||
const value = args[index]
|
||||
if (value == null || value.startsWith('--')) {
|
||||
throw new Error(message)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function readBenchmarkArtifact(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
|
||||
if (artifact?.summaryMedianMs != null) {
|
||||
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
|
||||
}
|
||||
if (artifact?.summaryMedian != null) {
|
||||
return normalizeNumericObject(path, artifact, 'daemon', artifact.summaryMedian, (key) =>
|
||||
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
|
||||
)
|
||||
}
|
||||
if (artifact?.suites != null) {
|
||||
return normalizePlaywrightArtifact(path, artifact)
|
||||
}
|
||||
if (artifact?.summary != null) {
|
||||
return normalizeSummaryArtifact(path, artifact)
|
||||
}
|
||||
throw new Error(
|
||||
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
|
||||
)
|
||||
}
|
||||
|
||||
function artifactLabel(path, artifact) {
|
||||
return typeof artifact?.label === 'string' && artifact.label.length > 0
|
||||
? artifact.label
|
||||
: basename(path)
|
||||
}
|
||||
|
||||
function normalizeNumericObject(path, artifact, kind, values, unitForKey) {
|
||||
return {
|
||||
kind,
|
||||
label: artifactLabel(path, artifact),
|
||||
metrics: Object.entries(values ?? {})
|
||||
.filter(([, value]) => Number.isFinite(value))
|
||||
.map(([key, value]) => ({
|
||||
direction: 'lower-is-better',
|
||||
key,
|
||||
unit: unitForKey(key),
|
||||
value
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaywrightArtifact(path, artifact) {
|
||||
const rows = collectTerminalPerfRows(artifact, basename(path), { typePrefix: 'opencode-' })
|
||||
const groupedMetrics = new Map()
|
||||
for (const row of rows) {
|
||||
for (const [field, rawValue] of Object.entries(row)) {
|
||||
if (PLAYWRIGHT_METADATA_FIELDS.has(field)) {
|
||||
continue
|
||||
}
|
||||
const parsed = parseMetricValue(rawValue)
|
||||
if (parsed == null) {
|
||||
continue
|
||||
}
|
||||
const key = `${row.scenario}.${field}`
|
||||
const metricGroup = groupedMetrics.get(key) ?? {
|
||||
unit: parsed.unit,
|
||||
values: []
|
||||
}
|
||||
metricGroup.values.push(parsed.value)
|
||||
groupedMetrics.set(key, metricGroup)
|
||||
}
|
||||
}
|
||||
const metrics = [...groupedMetrics.entries()].map(([key, metricGroup]) => ({
|
||||
direction: 'lower-is-better',
|
||||
key,
|
||||
unit: metricGroup.unit,
|
||||
value: mean(metricGroup.values)
|
||||
}))
|
||||
return {
|
||||
kind: 'playwright',
|
||||
label: artifactLabel(path, artifact),
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function parseMetricValue(rawValue) {
|
||||
if (typeof rawValue === 'string') {
|
||||
const msMatch = rawValue.match(/^(-?\d+(?:\.\d+)?)ms$/)
|
||||
if (msMatch) {
|
||||
return { unit: 'ms', value: Number(msMatch[1]) }
|
||||
}
|
||||
}
|
||||
const numericValue = Number(rawValue)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null
|
||||
}
|
||||
return { unit: 'count', value: numericValue }
|
||||
}
|
||||
|
||||
function normalizeSummaryArtifact(path, artifact) {
|
||||
const metrics = []
|
||||
flattenSummary(metrics, ['summary'], artifact.summary)
|
||||
return {
|
||||
kind: 'summary',
|
||||
label: artifactLabel(path, artifact),
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
function flattenSummary(metrics, pathParts, value) {
|
||||
if (Number.isFinite(value)) {
|
||||
const key = pathParts.join('.')
|
||||
metrics.push({
|
||||
direction: 'lower-is-better',
|
||||
key,
|
||||
unit: unitForSummaryKey(pathParts),
|
||||
value
|
||||
})
|
||||
return
|
||||
}
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
for (const [childKey, childValue] of Object.entries(value)) {
|
||||
flattenSummary(metrics, [...pathParts, childKey], childValue)
|
||||
}
|
||||
}
|
||||
|
||||
function unitForSummaryKey(pathParts) {
|
||||
if (pathParts.some((part) => part.endsWith('CpuPercent'))) {
|
||||
return '%'
|
||||
}
|
||||
if (pathParts.some((part) => part.endsWith('Bytes'))) {
|
||||
return 'bytes'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function compareBenchmarkArtifacts({
|
||||
baselinePath,
|
||||
candidatePath,
|
||||
title = 'Benchmark comparison',
|
||||
higherIsBetter = new Set(),
|
||||
now = () => new Date()
|
||||
}) {
|
||||
const baseline = normalizeBenchmarkArtifact(baselinePath)
|
||||
const candidate = normalizeBenchmarkArtifact(candidatePath)
|
||||
const candidateMetrics = new Map(candidate.metrics.map((metric) => [metric.key, metric]))
|
||||
const baselineMetrics = new Map(baseline.metrics.map((metric) => [metric.key, metric]))
|
||||
const metrics = []
|
||||
const skippedMetrics = []
|
||||
|
||||
for (const baselineMetric of baseline.metrics) {
|
||||
const candidateMetric = candidateMetrics.get(baselineMetric.key)
|
||||
if (!isComparableMetric(baselineMetric)) {
|
||||
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing baseline metric' })
|
||||
continue
|
||||
}
|
||||
if (!isComparableMetric(candidateMetric)) {
|
||||
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing candidate metric' })
|
||||
continue
|
||||
}
|
||||
if (baselineMetric.unit !== candidateMetric.unit) {
|
||||
skippedMetrics.push({
|
||||
key: baselineMetric.key,
|
||||
reason: `unit mismatch (${formatUnitLabel(baselineMetric.unit)} vs ${formatUnitLabel(candidateMetric.unit)})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const direction = higherIsBetter.has(baselineMetric.key)
|
||||
? 'higher-is-better'
|
||||
: baselineMetric.direction
|
||||
metrics.push(compareMetric(baselineMetric, candidateMetric, direction))
|
||||
}
|
||||
|
||||
for (const candidateMetric of candidate.metrics) {
|
||||
if (!baselineMetrics.has(candidateMetric.key)) {
|
||||
skippedMetrics.push({ key: candidateMetric.key, reason: 'missing baseline metric' })
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics.length === 0) {
|
||||
throw new Error('No comparable benchmark metrics found.')
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
createdAt: now().toISOString(),
|
||||
title,
|
||||
baseline: {
|
||||
path: benchmarkDisplayPath(baselinePath),
|
||||
label: baseline.label,
|
||||
kind: baseline.kind
|
||||
},
|
||||
candidate: {
|
||||
path: benchmarkDisplayPath(candidatePath),
|
||||
label: candidate.label,
|
||||
kind: candidate.kind
|
||||
},
|
||||
metrics,
|
||||
skippedMetrics
|
||||
}
|
||||
}
|
||||
|
||||
function benchmarkDisplayPath(path, cwd = process.cwd()) {
|
||||
if (!isAbsolute(path)) {
|
||||
return path
|
||||
}
|
||||
const relativePath = relative(cwd, path)
|
||||
if (relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)) {
|
||||
return relativePath
|
||||
}
|
||||
return basename(path)
|
||||
}
|
||||
|
||||
function isComparableMetric(metric) {
|
||||
return metric != null && Number.isFinite(metric.value)
|
||||
}
|
||||
|
||||
function compareMetric(baselineMetric, candidateMetric, direction) {
|
||||
const rawDelta = candidateMetric.value - baselineMetric.value
|
||||
const absoluteDelta = roundOneDecimal(rawDelta)
|
||||
const percentDelta =
|
||||
baselineMetric.value === 0
|
||||
? null
|
||||
: roundOneDecimal((rawDelta / Math.abs(baselineMetric.value)) * 100)
|
||||
return {
|
||||
key: baselineMetric.key,
|
||||
unit: baselineMetric.unit,
|
||||
direction,
|
||||
baseline: baselineMetric.value,
|
||||
candidate: candidateMetric.value,
|
||||
absoluteDelta,
|
||||
percentDelta,
|
||||
status: metricStatus(absoluteDelta, direction)
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnitLabel(unit) {
|
||||
return unit === '' ? 'none' : unit
|
||||
}
|
||||
|
||||
function roundOneDecimal(value) {
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
function metricStatus(absoluteDelta, direction) {
|
||||
if (absoluteDelta === 0) {
|
||||
return 'unchanged'
|
||||
}
|
||||
if (direction === 'higher-is-better') {
|
||||
return absoluteDelta > 0 ? 'improved' : 'regressed'
|
||||
}
|
||||
return absoluteDelta < 0 ? 'improved' : 'regressed'
|
||||
}
|
||||
|
||||
export function formatBenchmarkComparisonMarkdown(comparison) {
|
||||
const lines = [
|
||||
`# ${markdownText(comparison.title)}`,
|
||||
'',
|
||||
`Baseline: ${markdownText(comparison.baseline.label)} (${markdownText(comparison.baseline.path)})`,
|
||||
`Candidate: ${markdownText(comparison.candidate.label)} (${markdownText(comparison.candidate.path)})`,
|
||||
`Generated: ${comparison.createdAt}`,
|
||||
'',
|
||||
'| Metric | Baseline | Candidate | Delta | Delta % | Result |',
|
||||
'|---|---:|---:|---:|---:|---|'
|
||||
]
|
||||
for (const metric of comparison.metrics) {
|
||||
lines.push(
|
||||
`| ${markdownTableCell(metric.key)} | ${formatMetricValue(metric.baseline, metric.unit)} | ${formatMetricValue(metric.candidate, metric.unit)} | ${formatMetricValue(metric.absoluteDelta, metric.unit)} | ${formatPercent(metric.percentDelta)} | ${metric.status} |`
|
||||
)
|
||||
}
|
||||
if (comparison.skippedMetrics.length > 0) {
|
||||
lines.push('', '## Skipped metrics')
|
||||
for (const skippedMetric of comparison.skippedMetrics) {
|
||||
lines.push(`- ${markdownText(skippedMetric.key)}: ${markdownText(skippedMetric.reason)}`)
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function markdownText(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\s*\r?\n\s*/g, ' ')
|
||||
.replace(/[\\`*_{}<>()#+.!-]|\[|\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function markdownTableCell(value) {
|
||||
return markdownText(value).replaceAll('|', '\\|')
|
||||
}
|
||||
|
||||
function formatMetricValue(value, unit) {
|
||||
return `${Number(value).toFixed(1)}${unit}`
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return value == null ? '' : `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function runBenchmarkComparisonCli(args = {}) {
|
||||
const argv = args.argv ?? process.argv.slice(2)
|
||||
const parsed = parseBenchmarkComparisonArgs(argv)
|
||||
const comparison = compareBenchmarkArtifacts(parsed)
|
||||
const markdown = formatBenchmarkComparisonMarkdown(comparison)
|
||||
if (parsed.outputPath) {
|
||||
mkdirSync(dirname(parsed.outputPath), { recursive: true })
|
||||
writeFileSync(parsed.outputPath, markdown)
|
||||
}
|
||||
if (parsed.jsonOutputPath) {
|
||||
mkdirSync(dirname(parsed.jsonOutputPath), { recursive: true })
|
||||
writeFileSync(parsed.jsonOutputPath, `${JSON.stringify(comparison, null, 2)}\n`)
|
||||
}
|
||||
const stdout = args.stdout ?? process.stdout
|
||||
stdout.write(markdown)
|
||||
return comparison
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
runBenchmarkComparisonCli()
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,32 @@ describe('Electron runtime package contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes both Linux release matrix entries', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const parsedWorkflow = parse(releaseWorkflow)
|
||||
const publishLinuxStep = parsedWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Publish release artifacts (Linux)'
|
||||
)
|
||||
|
||||
expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-x64'")
|
||||
expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-arm64'")
|
||||
expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}')
|
||||
})
|
||||
|
||||
it('keeps Linux postinstall repairing Chromium sandbox permissions', () => {
|
||||
const afterInstallScript = readFileSync(
|
||||
join(projectDir, 'resources/linux/packaging/after-install.sh'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(afterInstallScript).toContain('chrome-sandbox')
|
||||
expect(afterInstallScript).toContain('chmod 4755 "$sandbox"')
|
||||
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
|
||||
})
|
||||
|
||||
it('lets release-cut tag a version that is already present on main', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], {
|
||||
stdio: 'inherit',
|
||||
env
|
||||
})
|
||||
|
||||
if (runtime.error) {
|
||||
throw runtime.error
|
||||
}
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename } from 'node:path'
|
||||
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
|
||||
|
||||
const reportPaths = process.argv.slice(2)
|
||||
if (reportPaths[0] === '--') {
|
||||
@@ -13,55 +13,6 @@ if (reportPaths.length === 0) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function readJsonReport(path) {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const start = raw.indexOf('{')
|
||||
const end = raw.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) {
|
||||
throw new Error(`${path}: no JSON object found`)
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function parseAnnotationDescription(description) {
|
||||
const values = {}
|
||||
for (const part of description.split(/\s+/)) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
values[part.slice(0, index)] = part.slice(index + 1)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function collectTerminalPerfRows(report, source) {
|
||||
const rows = []
|
||||
const visitSuite = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const annotation of test.annotations ?? []) {
|
||||
if (!annotation.type.startsWith('opencode-')) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
source,
|
||||
scenario: annotation.type,
|
||||
...parseAnnotationDescription(annotation.description ?? '')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visitSuite(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visitSuite(suite)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function markdownCell(value) {
|
||||
return String(value ?? '').replaceAll('|', '\\|')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = 'config/scripts/summarize-terminal-perf-report.mjs'
|
||||
const tempDirs = []
|
||||
|
||||
function writeReport() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-summary-'))
|
||||
tempDirs.push(dir)
|
||||
const reportPath = join(dir, 'report.json')
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale',
|
||||
description: 'panes=50 frames=60 median=12.3ms rendererQueuedChars=1000'
|
||||
},
|
||||
{
|
||||
type: 'browser-unrelated',
|
||||
description: 'panes=1 median=999.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('summarize-terminal-perf-report', () => {
|
||||
it('prints only OpenCode terminal perf annotation rows', () => {
|
||||
const output = execFileSync(process.execPath, [scriptPath, writeReport()], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('| Source | Scenario | Panes | Frames | Median |')
|
||||
expect(output).toContain('| report.json | opencode-scale | 50 | 60 | 12.3ms |')
|
||||
expect(output).toContain('1000')
|
||||
expect(output).not.toContain('browser-unrelated')
|
||||
expect(output).not.toContain('999.0ms')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
export function readJsonReport(path) {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const start = raw.indexOf('{')
|
||||
const end = raw.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) {
|
||||
throw new Error(`${path}: no JSON object found`)
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1))
|
||||
}
|
||||
|
||||
export function parseAnnotationDescription(description) {
|
||||
const values = {}
|
||||
for (const part of description.split(/\s+/)) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
values[part.slice(0, index)] = part.slice(index + 1)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function collectTerminalPerfRows(report, source, options = {}) {
|
||||
const { typePrefix = 'opencode-' } = options
|
||||
const rows = []
|
||||
const visitSuite = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const annotation of test.annotations ?? []) {
|
||||
if (!annotation.type.startsWith(typePrefix)) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
...parseAnnotationDescription(annotation.description ?? ''),
|
||||
// Why: annotation descriptions are artifact-controlled; keep the
|
||||
// trusted report source and annotation type from being relabeled.
|
||||
source,
|
||||
scenario: annotation.type
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visitSuite(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visitSuite(suite)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTerminalPerfRows,
|
||||
parseAnnotationDescription,
|
||||
readJsonReport
|
||||
} from './terminal-perf-report-annotations.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeReportPath(content) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-annotations-'))
|
||||
tempDirs.push(dir)
|
||||
const reportPath = join(dir, 'report.json')
|
||||
writeFileSync(reportPath, content)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
function makeNestedReport() {
|
||||
return {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'browser-unrelated',
|
||||
description: 'median=999.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale',
|
||||
description: 'panes=50 median=12.3ms ignored-token rendererQueuedChars=1000'
|
||||
},
|
||||
{
|
||||
type: 'terminal-scale',
|
||||
description: 'panes=25 median=7.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('terminal perf report annotations', () => {
|
||||
it('parses key-value annotation description segments only', () => {
|
||||
expect(parseAnnotationDescription('panes=50 no-equals median=12.3ms name=a=b')).toEqual({
|
||||
median: '12.3ms',
|
||||
name: 'a=b',
|
||||
panes: '50'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads JSON reports surrounded by noisy process output', () => {
|
||||
const reportPath = makeReportPath(
|
||||
`noise before\n${JSON.stringify({ suites: [] })}\nnoise after`
|
||||
)
|
||||
|
||||
expect(readJsonReport(reportPath)).toEqual({ suites: [] })
|
||||
})
|
||||
|
||||
it('collects nested OpenCode annotations by default', () => {
|
||||
expect(collectTerminalPerfRows(makeNestedReport(), 'report.json')).toEqual([
|
||||
{
|
||||
median: '12.3ms',
|
||||
panes: '50',
|
||||
rendererQueuedChars: '1000',
|
||||
scenario: 'opencode-scale',
|
||||
source: 'report.json'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps trusted source and scenario fields when descriptions contain matching keys', () => {
|
||||
const report = {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale',
|
||||
description: 'source=spoofed.json scenario=spoofed median=12.3ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(collectTerminalPerfRows(report, 'report.json')).toEqual([
|
||||
{
|
||||
median: '12.3ms',
|
||||
scenario: 'opencode-scale',
|
||||
source: 'report.json'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('supports a custom annotation type prefix', () => {
|
||||
expect(
|
||||
collectTerminalPerfRows(makeNestedReport(), 'report.json', { typePrefix: 'terminal-' })
|
||||
).toEqual([
|
||||
{
|
||||
median: '7.0ms',
|
||||
panes: '25',
|
||||
scenario: 'terminal-scale',
|
||||
source: 'report.json'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.4m">
|
||||
<title>downloads: 1.4m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.5m">
|
||||
<title>downloads: 1.5m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.4m</text>
|
||||
<text x="93.5" y="14">1.4m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.5m</text>
|
||||
<text x="93.5" y="14">1.5m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -35,7 +35,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -226,7 +226,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4915,12 +4915,14 @@ export default function SessionScreen() {
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
smartInsertDelete={false}
|
||||
// Why: iOS textContentType wins over autoComplete and can
|
||||
// narrow the keyboard surface; keep IME switching available.
|
||||
autoComplete="off"
|
||||
keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)}
|
||||
returnKeyType="default"
|
||||
blurOnSubmit={false}
|
||||
editable={canSend}
|
||||
importantForAutofill="no"
|
||||
textContentType="none"
|
||||
/>
|
||||
</Pressable>
|
||||
) : (
|
||||
@@ -4946,6 +4948,9 @@ export default function SessionScreen() {
|
||||
autoCorrect={autocompleteEnabled}
|
||||
spellCheck={autocompleteEnabled}
|
||||
smartInsertDelete={false}
|
||||
// Why: terminal commands are not autofill content, but the
|
||||
// keyboard must stay default so non-Latin IMEs remain selectable.
|
||||
autoComplete="off"
|
||||
keyboardType={getTerminalCommandKeyboardType(
|
||||
Platform.OS,
|
||||
autocompleteEnabled
|
||||
|
||||
@@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => {
|
||||
expect(sessionRouteSource).not.toContain("'ascii-capable'")
|
||||
expect(sessionRouteSource).not.toContain('"ascii-capable"')
|
||||
})
|
||||
|
||||
it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
|
||||
expect(sessionRouteSource).not.toContain('textContentType="none"')
|
||||
expect(sessionRouteSource).toContain('autoComplete="off"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1508,6 +1508,7 @@ private final class TreeRenderer {
|
||||
var focusedElementId: Int?
|
||||
var truncated = false
|
||||
var maxDepthReached = false
|
||||
private let reader = AXSnapshotReader()
|
||||
private var nextIndex = 0
|
||||
static let maxNodes = 1200
|
||||
static let maxDepth = 64
|
||||
@@ -1530,22 +1531,26 @@ private final class TreeRenderer {
|
||||
}
|
||||
guard !ancestors.contains(where: { CFEqual($0, element) }) else { return }
|
||||
|
||||
let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
let children = primaryChildren(element, role: role, windowBounds: windowBounds)
|
||||
let value = valueString(element)
|
||||
let placeholder = placeholderString(element)
|
||||
let rawActions = actions(element)
|
||||
let rowSummary = rowTextSummary(element, role: role)
|
||||
let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
|
||||
let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds)
|
||||
let value = reader.valueString(element, role: role)
|
||||
let placeholder = reader.placeholderString(element)
|
||||
let rawActions = reader.actions(element)
|
||||
let rowSummary = reader.rowTextSummary(element, role: role)
|
||||
let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String)
|
||||
let title = reader.stringAttribute(element, kAXTitleAttribute as String)
|
||||
let label = reader.stringAttribute(element, kAXDescriptionAttribute as String)
|
||||
let url = reader.stringAttribute(element, kAXURLAttribute as String)
|
||||
let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
|
||||
let baseNode = SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
|
||||
title: stringAttribute(element, kAXTitleAttribute as String),
|
||||
label: stringAttribute(element, kAXDescriptionAttribute as String),
|
||||
roleDescription: roleDescription,
|
||||
title: title,
|
||||
label: label,
|
||||
linkText: linkText,
|
||||
value: value,
|
||||
placeholder: placeholder,
|
||||
url: stringAttribute(element, kAXURLAttribute as String),
|
||||
url: url,
|
||||
traits: [],
|
||||
rawActions: rawActions,
|
||||
childCount: children.count,
|
||||
@@ -1553,19 +1558,19 @@ private final class TreeRenderer {
|
||||
)
|
||||
let name = SnapshotRenderHeuristics.displayName(baseNode)
|
||||
let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role)
|
||||
let localFrame = frame(element, windowBounds: windowBounds)
|
||||
let traits = traitsFor(element, role: role)
|
||||
let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors)
|
||||
let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
|
||||
let localFrame = reader.frame(element, windowBounds: windowBounds)
|
||||
let traits = reader.traitsFor(element, role: role)
|
||||
let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors)
|
||||
let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
|
||||
let node = SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
|
||||
title: stringAttribute(element, kAXTitleAttribute as String),
|
||||
label: stringAttribute(element, kAXDescriptionAttribute as String),
|
||||
roleDescription: roleDescription,
|
||||
title: title,
|
||||
label: label,
|
||||
linkText: linkText,
|
||||
value: value,
|
||||
placeholder: placeholder,
|
||||
url: stringAttribute(element, kAXURLAttribute as String),
|
||||
url: url,
|
||||
traits: traits,
|
||||
rawActions: rawActions,
|
||||
childCount: children.count,
|
||||
@@ -1617,6 +1622,23 @@ private final class TreeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
|
||||
let childNodes = children.map { child in
|
||||
let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
return SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String),
|
||||
title: reader.stringAttribute(child, kAXTitleAttribute as String),
|
||||
label: reader.stringAttribute(child, kAXDescriptionAttribute as String),
|
||||
value: reader.valueString(child, role: role),
|
||||
traits: reader.traitsFor(child, role: role)
|
||||
)
|
||||
}
|
||||
// Why: browsers expose every open tab through AX; retaining only the active
|
||||
// tab keeps snapshots focused on the current page instead of stale tab titles.
|
||||
return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
|
||||
}
|
||||
|
||||
private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) {
|
||||
guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return }
|
||||
let indent = String(repeating: "\t", count: depth)
|
||||
@@ -1647,44 +1669,301 @@ private final class TreeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private func valueString(_ element: AXUIElement) -> String? {
|
||||
if isSecureTextElement(element) {
|
||||
return "[redacted]"
|
||||
private final class AXSnapshotReader {
|
||||
private enum CachedAttribute {
|
||||
case missing
|
||||
case found(CFTypeRef)
|
||||
}
|
||||
if let string = stringAttribute(element, kAXValueAttribute as String) {
|
||||
return string
|
||||
|
||||
private final class ElementCache {
|
||||
let element: AXUIElement
|
||||
var loadedAttributeNames = false
|
||||
var advertisedAttributes: Set<String>?
|
||||
var attributes: [String: CachedAttribute] = [:]
|
||||
var actions: [String]?
|
||||
var settable: [String: Bool] = [:]
|
||||
|
||||
init(element: AXUIElement) {
|
||||
self.element = element
|
||||
}
|
||||
}
|
||||
if let number = numberAttribute(element, kAXValueAttribute as String) {
|
||||
return number.stringValue
|
||||
|
||||
private var elementsByHash: [CFHashCode: [ElementCache]] = [:]
|
||||
|
||||
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
||||
guard let value = copyAttribute(element, attribute) else { return nil }
|
||||
if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL {
|
||||
return url.absoluteString
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isSecureTextElement(_ element: AXUIElement) -> Bool {
|
||||
let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? ""
|
||||
let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? ""
|
||||
let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? ""
|
||||
let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? ""
|
||||
let placeholder = placeholderString(element)?.lowercased() ?? ""
|
||||
let haystack = [role, subrole, title, description, placeholder].joined(separator: " ")
|
||||
return haystack.contains("secure") ||
|
||||
haystack.contains("password") ||
|
||||
haystack.contains("passcode") ||
|
||||
haystack.contains("verification code") ||
|
||||
haystack.contains("one-time code")
|
||||
}
|
||||
func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? {
|
||||
copyAttribute(element, attribute) as? Bool
|
||||
}
|
||||
|
||||
private func placeholderString(_ element: AXUIElement) -> String? {
|
||||
stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
|
||||
}
|
||||
func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? {
|
||||
copyAttribute(element, attribute) as? NSNumber
|
||||
}
|
||||
|
||||
private func traitsFor(_ element: AXUIElement, role: String) -> [String] {
|
||||
var traits: [String] = []
|
||||
if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
|
||||
if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
|
||||
if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
|
||||
if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
|
||||
return traits
|
||||
func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? {
|
||||
copyAttribute(element, attribute) as? [AXUIElement]
|
||||
}
|
||||
|
||||
func actions(_ element: AXUIElement) -> [String] {
|
||||
let cache = cache(for: element)
|
||||
if let actions = cache.actions {
|
||||
return actions
|
||||
}
|
||||
var value: CFArray?
|
||||
let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : []
|
||||
cache.actions = actions
|
||||
return actions
|
||||
}
|
||||
|
||||
func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool {
|
||||
let cache = cache(for: element)
|
||||
if let cached = cache.settable[attribute] {
|
||||
return cached
|
||||
}
|
||||
var settable = DarwinBoolean(false)
|
||||
let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue
|
||||
cache.settable[attribute] = value
|
||||
return value
|
||||
}
|
||||
|
||||
func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? {
|
||||
guard let absolute = absoluteFrame(element) else { return nil }
|
||||
return CGRect(
|
||||
x: absolute.minX - windowBounds.minX,
|
||||
y: absolute.minY - windowBounds.minY,
|
||||
width: absolute.width,
|
||||
height: absolute.height
|
||||
)
|
||||
}
|
||||
|
||||
func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
|
||||
if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
|
||||
return visibleRows(rows, parent: element, windowBounds: windowBounds)
|
||||
}
|
||||
return copyArray(element, kAXChildrenAttribute as String) ?? []
|
||||
}
|
||||
|
||||
func valueString(_ element: AXUIElement, role: String) -> String? {
|
||||
if isSecureTextElement(element, role: role) {
|
||||
return "[redacted]"
|
||||
}
|
||||
if let string = stringAttribute(element, kAXValueAttribute as String) {
|
||||
return string
|
||||
}
|
||||
if let number = numberAttribute(element, kAXValueAttribute as String) {
|
||||
return number.stringValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func placeholderString(_ element: AXUIElement) -> String? {
|
||||
stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
|
||||
}
|
||||
|
||||
func traitsFor(_ element: AXUIElement, role: String) -> [String] {
|
||||
var traits: [String] = []
|
||||
if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
|
||||
if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
|
||||
if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
|
||||
if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
|
||||
return traits
|
||||
}
|
||||
|
||||
func genericTextSummary(
|
||||
_ element: AXUIElement,
|
||||
role: String,
|
||||
name: String?,
|
||||
actions: [String],
|
||||
traits: [String]
|
||||
) -> String? {
|
||||
guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
|
||||
name == nil,
|
||||
actions.isEmpty,
|
||||
traits.isEmpty,
|
||||
isPlainTextSubtree(element, maxDepth: 4)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
|
||||
guard texts.count >= 2 else { return nil }
|
||||
let summary = texts.joined(separator: " ")
|
||||
guard summary.count <= 220 else { return nil }
|
||||
return summary
|
||||
}
|
||||
|
||||
func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
|
||||
guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
|
||||
let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
|
||||
guard !texts.isEmpty else { return nil }
|
||||
return texts.joined(separator: " ")
|
||||
}
|
||||
|
||||
func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
|
||||
var values: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func collect(_ node: AXUIElement, depth: Int) {
|
||||
guard values.count < limit, depth <= maxDepth else { return }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
for candidate in [
|
||||
stringAttribute(node, kAXValueAttribute as String),
|
||||
stringAttribute(node, kAXTitleAttribute as String),
|
||||
stringAttribute(node, kAXDescriptionAttribute as String),
|
||||
] {
|
||||
guard let candidate else { continue }
|
||||
let text = preview(candidate, maxLength: 80)
|
||||
guard !text.isEmpty, seen.insert(text).inserted else { continue }
|
||||
values.append(text)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
collect(child, depth: depth + 1)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
|
||||
collect(element, depth: 0)
|
||||
return values
|
||||
}
|
||||
|
||||
func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
|
||||
if role == "AXWebArea" { return 0 }
|
||||
guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
|
||||
return nil
|
||||
}
|
||||
return ancestors.count - index
|
||||
}
|
||||
|
||||
private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
|
||||
let cache = cache(for: element)
|
||||
if let cached = cache.attributes[attribute] {
|
||||
switch cached {
|
||||
case .missing:
|
||||
return nil
|
||||
case let .found(value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
if let advertisedAttributes = advertisedAttributes(cache),
|
||||
!SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) {
|
||||
cache.attributes[attribute] = .missing
|
||||
return nil
|
||||
}
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else {
|
||||
cache.attributes[attribute] = .missing
|
||||
return nil
|
||||
}
|
||||
cache.attributes[attribute] = .found(value)
|
||||
return value
|
||||
}
|
||||
|
||||
private func advertisedAttributes(_ cache: ElementCache) -> Set<String>? {
|
||||
if cache.loadedAttributeNames {
|
||||
return cache.advertisedAttributes
|
||||
}
|
||||
cache.loadedAttributeNames = true
|
||||
var value: CFArray?
|
||||
guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else {
|
||||
return nil
|
||||
}
|
||||
cache.advertisedAttributes = Set(attributes)
|
||||
return cache.advertisedAttributes
|
||||
}
|
||||
|
||||
private func absoluteFrame(_ element: AXUIElement) -> CGRect? {
|
||||
guard let positionValue = copyAttribute(element, kAXPositionAttribute as String),
|
||||
let sizeValue = copyAttribute(element, kAXSizeAttribute as String)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
var point = CGPoint.zero
|
||||
var size = CGSize.zero
|
||||
guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point),
|
||||
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return CGRect(origin: point, size: size)
|
||||
}
|
||||
|
||||
private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
|
||||
guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
|
||||
return Array(rows.prefix(20))
|
||||
}
|
||||
let visible = rows.filter { row in
|
||||
guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
|
||||
return rowFrame.intersects(parentFrame)
|
||||
}
|
||||
return Array((visible.isEmpty ? rows : visible).prefix(20))
|
||||
}
|
||||
|
||||
private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool {
|
||||
guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else {
|
||||
return false
|
||||
}
|
||||
let haystack = [
|
||||
role,
|
||||
stringAttribute(element, kAXSubroleAttribute as String) ?? "",
|
||||
stringAttribute(element, kAXTitleAttribute as String) ?? "",
|
||||
stringAttribute(element, kAXDescriptionAttribute as String) ?? "",
|
||||
placeholderString(element) ?? "",
|
||||
].joined(separator: " ").lowercased()
|
||||
return haystack.contains("secure") ||
|
||||
haystack.contains("password") ||
|
||||
haystack.contains("passcode") ||
|
||||
haystack.contains("verification code") ||
|
||||
haystack.contains("one-time code")
|
||||
}
|
||||
|
||||
private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
|
||||
var sawText = false
|
||||
let allowedContainerRoles: Set<String> = [
|
||||
kAXGroupRole as String,
|
||||
kAXUnknownRole as String,
|
||||
kAXStaticTextRole as String,
|
||||
"AXLink",
|
||||
"AXImage",
|
||||
]
|
||||
|
||||
func visit(_ node: AXUIElement, depth: Int) -> Bool {
|
||||
guard depth <= maxDepth else { return false }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
guard allowedContainerRoles.contains(role) else { return false }
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
sawText = true
|
||||
}
|
||||
guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
guard visit(child, depth: depth + 1) else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return visit(element, depth: 0) && sawText
|
||||
}
|
||||
|
||||
private func cache(for element: AXUIElement) -> ElementCache {
|
||||
let hash = CFHash(element)
|
||||
if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) {
|
||||
return cache
|
||||
}
|
||||
let cache = ElementCache(element: element)
|
||||
elementsByHash[hash, default: []].append(cache)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
|
||||
private let valueSettableRoles: Set<String> = [
|
||||
@@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set<String> = [
|
||||
kAXTextFieldRole as String,
|
||||
]
|
||||
|
||||
private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
|
||||
if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
|
||||
return visibleRows(rows, parent: element, windowBounds: windowBounds)
|
||||
}
|
||||
return copyArray(element, kAXChildrenAttribute as String) ?? []
|
||||
}
|
||||
|
||||
private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
|
||||
guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
|
||||
return Array(rows.prefix(20))
|
||||
}
|
||||
let visible = rows.filter { row in
|
||||
guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
|
||||
return rowFrame.intersects(parentFrame)
|
||||
}
|
||||
return Array((visible.isEmpty ? rows : visible).prefix(20))
|
||||
}
|
||||
|
||||
private func usesRowsAsPrimaryChildren(role: String) -> Bool {
|
||||
[
|
||||
kAXBrowserRole as String,
|
||||
@@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool {
|
||||
].contains(role)
|
||||
}
|
||||
|
||||
private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
|
||||
let childNodes = children.map { child in
|
||||
let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
return SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String),
|
||||
title: stringAttribute(child, kAXTitleAttribute as String),
|
||||
label: stringAttribute(child, kAXDescriptionAttribute as String),
|
||||
value: valueString(child),
|
||||
traits: traitsFor(child, role: role)
|
||||
)
|
||||
}
|
||||
// Why: browsers expose every open tab through AX; retaining only the active
|
||||
// tab keeps snapshots focused on the current page instead of stale tab titles.
|
||||
return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
|
||||
}
|
||||
|
||||
private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool {
|
||||
guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else {
|
||||
return false
|
||||
@@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? {
|
||||
return Int(digits)
|
||||
}
|
||||
|
||||
private func genericTextSummary(
|
||||
_ element: AXUIElement,
|
||||
role: String,
|
||||
name: String?,
|
||||
actions: [String],
|
||||
traits: [String]
|
||||
) -> String? {
|
||||
guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
|
||||
name == nil,
|
||||
actions.isEmpty,
|
||||
traits.isEmpty,
|
||||
isPlainTextSubtree(element, maxDepth: 4)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
|
||||
guard texts.count >= 2 else { return nil }
|
||||
let summary = texts.joined(separator: " ")
|
||||
guard summary.count <= 220 else { return nil }
|
||||
return summary
|
||||
}
|
||||
|
||||
private func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
|
||||
guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
|
||||
let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
|
||||
guard !texts.isEmpty else { return nil }
|
||||
return texts.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
|
||||
var sawText = false
|
||||
let allowedContainerRoles: Set<String> = [
|
||||
kAXGroupRole as String,
|
||||
kAXUnknownRole as String,
|
||||
kAXStaticTextRole as String,
|
||||
"AXLink",
|
||||
"AXImage",
|
||||
]
|
||||
|
||||
func visit(_ node: AXUIElement, depth: Int) -> Bool {
|
||||
guard depth <= maxDepth else { return false }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
guard allowedContainerRoles.contains(role) else { return false }
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
sawText = true
|
||||
}
|
||||
guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
guard visit(child, depth: depth + 1) else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return visit(element, depth: 0) && sawText
|
||||
}
|
||||
|
||||
private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
|
||||
var values: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func collect(_ node: AXUIElement, depth: Int) {
|
||||
guard values.count < limit, depth <= maxDepth else { return }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
for candidate in [
|
||||
stringAttribute(node, kAXValueAttribute as String),
|
||||
stringAttribute(node, kAXTitleAttribute as String),
|
||||
stringAttribute(node, kAXDescriptionAttribute as String),
|
||||
] {
|
||||
guard let candidate else { continue }
|
||||
let text = preview(candidate, maxLength: 80)
|
||||
guard !text.isEmpty, seen.insert(text).inserted else { continue }
|
||||
values.append(text)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
collect(child, depth: depth + 1)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
|
||||
collect(element, depth: 0)
|
||||
return values
|
||||
}
|
||||
|
||||
private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
|
||||
if role == "AXWebArea" { return 0 }
|
||||
guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
|
||||
return nil
|
||||
}
|
||||
return ancestors.count - index
|
||||
}
|
||||
|
||||
private func sanitize(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable {
|
||||
}
|
||||
|
||||
public enum SnapshotRenderHeuristics {
|
||||
public static func shouldProbeSecureTextMetadata(role: String) -> Bool {
|
||||
let normalized = role.lowercased()
|
||||
return normalized.contains("text") ||
|
||||
normalized.contains("field") ||
|
||||
normalized.contains("password") ||
|
||||
normalized.contains("search") ||
|
||||
normalized.contains("combo")
|
||||
}
|
||||
|
||||
public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set<String>?) -> Bool {
|
||||
guard let advertisedAttributes else { return true }
|
||||
return advertisedAttributes.contains(attribute)
|
||||
}
|
||||
|
||||
public static func displayName(_ node: SnapshotRenderNode) -> String? {
|
||||
if let title = clean(node.title) {
|
||||
return title
|
||||
|
||||
+18
@@ -2,6 +2,24 @@ import XCTest
|
||||
@testable import OrcaComputerUseMacOSCore
|
||||
|
||||
final class SnapshotRenderingTests: XCTestCase {
|
||||
func testSkipsUnsupportedAdvertisedAttributes() {
|
||||
let advertised: Set<String> = ["AXRole", "AXChildren"]
|
||||
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXRole", advertisedAttributes: advertised))
|
||||
XCTAssertFalse(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: advertised))
|
||||
}
|
||||
|
||||
func testUnknownAttributeAdvertisementsStayPermissive() {
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: nil))
|
||||
}
|
||||
|
||||
func testSecureTextMetadataOnlyProbesTextLikeRoles() {
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXTextField"))
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXSearchField"))
|
||||
XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXGroup"))
|
||||
XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXButton"))
|
||||
}
|
||||
|
||||
func testElidesAnonymousWrappers() {
|
||||
let node = SnapshotRenderNode(role: "AXGroup", childCount: 1)
|
||||
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.92-rc.0",
|
||||
"version": "1.4.94",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -70,6 +70,7 @@
|
||||
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
|
||||
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless",
|
||||
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
@@ -78,9 +79,13 @@
|
||||
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
|
||||
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs"
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
|
||||
"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:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
|
||||
@@ -12,6 +12,13 @@ set -e
|
||||
link="/usr/bin/orca-ide"
|
||||
|
||||
for dir in /opt/Orca /opt/orca-ide /opt/orca; do
|
||||
sandbox="$dir/chrome-sandbox"
|
||||
if [ -f "$sandbox" ]; then
|
||||
# Why: packaged Linux installs must leave Chromium's sandbox helper usable
|
||||
# on hosts where unprivileged user namespaces are unavailable.
|
||||
chmod 4755 "$sandbox" || true
|
||||
fi
|
||||
|
||||
shim="$dir/resources/bin/orca-ide"
|
||||
if [ -x "$shim" ]; then
|
||||
# Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide.
|
||||
|
||||
@@ -4,7 +4,7 @@ description: >-
|
||||
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
|
||||
terminals, repos, automations, worktree comments, and the browser embedded
|
||||
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
|
||||
"Orca worktree", "child worktree", "spawn codex/claude in a worktree",
|
||||
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "terminal send", "Orca browser", or "control
|
||||
the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs,
|
||||
Playwright, or Computer Use when the task touches Orca-managed state. Use
|
||||
@@ -61,6 +61,7 @@ orca worktree create --name child-task --agent codex --prompt "hi" --json
|
||||
orca worktree create --name independent-task --no-parent --json
|
||||
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
|
||||
orca worktree set --worktree active --comment "reproduced bug; testing fix" --json
|
||||
orca worktree set --worktree active --workspace-status in-review --json
|
||||
orca worktree rm --worktree id:<worktreeId> --force --json
|
||||
```
|
||||
|
||||
@@ -108,6 +109,8 @@ orca worktree set --worktree active --comment "fix implemented; running integrat
|
||||
|
||||
Update after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested.
|
||||
|
||||
Card status uses `--workspace-status <id>`; defaults are `todo`, `in-progress`, `in-review`, `completed`.
|
||||
|
||||
## Terminals
|
||||
|
||||
Common commands:
|
||||
|
||||
@@ -14,7 +14,18 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
applyAgentStatusHooksEnabledMock: vi.fn(),
|
||||
callMock: vi.fn(),
|
||||
getCliStatusMock: vi.fn(() => Promise.resolve({ result: { runtime: { reachable: false } } })),
|
||||
getCliStatusMock: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'test-status',
|
||||
ok: true,
|
||||
result: {
|
||||
app: { running: false, pid: null },
|
||||
runtime: { state: 'not_running', reachable: false, runtimeId: null },
|
||||
graph: { state: 'not_running' }
|
||||
},
|
||||
_meta: { runtimeId: 'test' }
|
||||
})
|
||||
),
|
||||
getDefaultUserDataPathMock: vi.fn(),
|
||||
getManagedAgentHookStatusesMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
hookDefinitionHasManagedCommand,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeManagedScript,
|
||||
writeHooksJson,
|
||||
type HooksConfig
|
||||
@@ -167,6 +168,11 @@ describe('createManagedCommandMatcher', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches encoded Windows launcher commands by decoding their script path', () => {
|
||||
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
expect(match(command)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the legacy per-userData script path AND the new shared ~/.orca path', () => {
|
||||
// Why: install() must sweep old per-userData commands when migrating to
|
||||
// the shared ~/.orca script path, or stale launchers keep failing.
|
||||
@@ -332,6 +338,57 @@ describe('wrapPosixHookCommand', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('wrapWindowsHookCommand', () => {
|
||||
function decodeWindowsHookCommand(command: string): string {
|
||||
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
|
||||
expect(encodedCommand).toBeTruthy()
|
||||
return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
it('invokes the .cmd through an encoded PowerShell command', () => {
|
||||
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
expect(command).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(
|
||||
"& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
)
|
||||
})
|
||||
|
||||
// Why: a user profile path like `C:\Users\Jane Doe` is the regression from
|
||||
// #6078 — the raw path used to be split at the space. The wrapper must keep
|
||||
// the whole path inside the encoded command so shells do not split it.
|
||||
it('preserves spaces in the script path (user profile with space case)', () => {
|
||||
const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
expect(decodeWindowsHookCommand(cmd)).toBe(
|
||||
"& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cmd.exe percent expansion and caret escapes out of the command line', () => {
|
||||
const cmd = wrapWindowsHookCommand('C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd')
|
||||
expect(cmd).not.toContain('%ORCA_TEST%')
|
||||
expect(cmd).not.toContain('^')
|
||||
expect(decodeWindowsHookCommand(cmd)).toBe(
|
||||
"& 'C:\\Users\\%ORCA_TEST%\\a^b\\codex-hook.cmd'; exit $LASTEXITCODE"
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'executes a script path containing a cmd.exe caret literally',
|
||||
() => {
|
||||
const scriptDir = join(tmpDir, 'home with ^ caret', '.orca', 'agent-hooks')
|
||||
mkdirSync(scriptDir, { recursive: true })
|
||||
const scriptPath = join(scriptDir, 'codex-hook.cmd')
|
||||
writeFileSync(scriptPath, '@echo off\r\nexit /b 7\r\n', 'utf-8')
|
||||
|
||||
const result = spawnSync('cmd.exe', ['/d', '/c', wrapWindowsHookCommand(scriptPath)], {
|
||||
env: { ...process.env, ORCA_WRAP_TEST: 'expanded' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe(7)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('buildWindowsAgentHookPostCommand', () => {
|
||||
it('forces UTF-8 for redirected hook stdin and POST bodies', () => {
|
||||
const command = buildWindowsAgentHookPostCommand('codex')
|
||||
|
||||
@@ -75,11 +75,25 @@ export function createManagedCommandMatcher(
|
||||
if (!command) {
|
||||
return false
|
||||
}
|
||||
const normalizedCommand = command.replaceAll('\\', '/')
|
||||
const decodedCommand = decodePowerShellEncodedCommand(command)
|
||||
const searchText = decodedCommand ? `${command}\n${decodedCommand}` : command
|
||||
const normalizedCommand = searchText.replaceAll('\\', '/')
|
||||
return needles.some((needle) => normalizedCommand.includes(needle))
|
||||
}
|
||||
}
|
||||
|
||||
function decodePowerShellEncodedCommand(command: string): string | null {
|
||||
const match = command.match(/\s-EncodedCommand\s+(\S+)/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return Buffer.from(match[1], 'base64').toString('utf16le')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: prod, dev, and parallel Orca instances must write the same managed
|
||||
// settings entry instead of racing between per-userData script paths.
|
||||
export function getSharedManagedScriptPath(scriptFileName: string): string {
|
||||
@@ -106,6 +120,21 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record<string, str
|
||||
return `if [ -x ${quoted} ]; then ${invocation}; fi`
|
||||
}
|
||||
|
||||
function quotePowerShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
// Why: Windows splits a raw hook command on whitespace, so a user profile path
|
||||
// like `C:\Users\Jane Doe` makes the agent try to execute `C:\Users\Jane` and
|
||||
// fail with exit code 1. Keep the script path inside an encoded PowerShell
|
||||
// command so cmd.exe never gets a chance to expand legal path characters like
|
||||
// `%` or `^` before the .cmd is invoked. #6078.
|
||||
export function wrapWindowsHookCommand(scriptPath: string): string {
|
||||
const command = `& ${quotePowerShellString(scriptPath)}; exit $LASTEXITCODE`
|
||||
const encodedCommand = Buffer.from(command, 'utf16le').toString('base64')
|
||||
return `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
}
|
||||
|
||||
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
|
||||
// Why: Windows PowerShell 5.1 defaults redirected stdin/request bodies to the
|
||||
// active code page. Hook payloads are UTF-8 JSON, so force UTF-8 on both read
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
parseRovoSessionFile
|
||||
} from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import {
|
||||
parseClaudeSessionFile,
|
||||
parseCodexSessionFile,
|
||||
parseGeminiSessionFile
|
||||
} from './session-scanner-primary-parsers'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
import {
|
||||
parseCopilotSessionFile,
|
||||
parseCursorSessionFile,
|
||||
@@ -20,6 +19,15 @@ import {
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
/**
|
||||
* Parse a single agent session file into an `AiVaultSession`. Routes to the
|
||||
* appropriate agent-specific parser based on `candidate.agent`. For OpenCode
|
||||
* SQLite candidates (synthetic `db#id` paths), routes to
|
||||
* `parseOpenCodeSqliteSession` instead of the legacy JSON parser.
|
||||
* @param candidate - The session file candidate to parse.
|
||||
* @param platform - The platform to use for resume command generation.
|
||||
* @returns The parsed `AiVaultSession`, or `null` if parsing fails.
|
||||
*/
|
||||
export async function parseAgentSessionFile(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
@@ -35,8 +43,20 @@ export async function parseAgentSessionFile(
|
||||
return parseCopilotSessionFile(candidate.file, platform)
|
||||
case 'cursor':
|
||||
return parseCursorSessionFile(candidate.file, platform)
|
||||
case 'opencode':
|
||||
case 'opencode': {
|
||||
// Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic
|
||||
// <dbPath>#<sessionId> candidate path. Legacy file-based sessions use
|
||||
// real filesystem paths and fall through to the JSON parser.
|
||||
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
|
||||
if (sqliteCandidate) {
|
||||
return parseOpenCodeSqliteSession({
|
||||
dbPath: sqliteCandidate.dbPath,
|
||||
sessionId: sqliteCandidate.sessionId,
|
||||
platform
|
||||
})
|
||||
}
|
||||
return parseOpenCodeSessionFile(candidate.file, platform)
|
||||
}
|
||||
case 'grok':
|
||||
return parseGrokSessionFile(candidate.file, platform)
|
||||
case 'hermes':
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
describe('parseCodexSessionFile', () => {
|
||||
it('does not double-count usage when token count formats switch', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-'))
|
||||
tempRoots.push(root)
|
||||
const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl')
|
||||
await mkdir(dirname(sessionPath), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
sessionPath,
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'token-format-switch', cwd: '/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Measure Codex token totals' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
last_token_usage: {
|
||||
input_tokens: 70,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 30,
|
||||
total_tokens: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:03.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 90,
|
||||
cached_input_tokens: 25,
|
||||
output_tokens: 60,
|
||||
total_tokens: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const sessionStat = await stat(sessionPath)
|
||||
const session = await parseCodexSessionFile(
|
||||
{
|
||||
path: sessionPath,
|
||||
mtimeMs: sessionStat.mtimeMs,
|
||||
modifiedAt: sessionStat.mtime.toISOString()
|
||||
},
|
||||
'darwin',
|
||||
root
|
||||
)
|
||||
|
||||
expect(session?.totalTokens).toBe(150)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { stat } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractGitBranch,
|
||||
extractModel,
|
||||
extractString,
|
||||
normalizeCodexUsage,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
subtractCodexUsage
|
||||
} from './session-scanner-values'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
type CodexSessionIndexTitleCacheEntry = {
|
||||
signature: string
|
||||
titles: Map<string, string>
|
||||
}
|
||||
|
||||
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
if (isCodexWorkerSession(payload)) {
|
||||
// Why: Codex writes internal worker/sub-agent transcripts into the same
|
||||
// history tree; AI Vault should show user-started sessions only.
|
||||
return null
|
||||
}
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const indexedTitle =
|
||||
extractCodexSessionMetadataTitle(payload) ??
|
||||
(await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
|
||||
if (indexedTitle) {
|
||||
accumulator.title = indexedTitle
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
continue
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
continue
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
let delta: CodexUsageSnapshot | null = null
|
||||
if (totalUsage) {
|
||||
delta = subtractCodexUsage(totalUsage, previousTotals)
|
||||
previousTotals = totalUsage
|
||||
} else if (lastUsage) {
|
||||
delta = lastUsage
|
||||
previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
}
|
||||
|
||||
function addCodexUsage(
|
||||
base: CodexUsageSnapshot,
|
||||
increment: CodexUsageSnapshot
|
||||
): CodexUsageSnapshot {
|
||||
return {
|
||||
inputTokens: base.inputTokens + increment.inputTokens,
|
||||
cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens,
|
||||
outputTokens: base.outputTokens + increment.outputTokens,
|
||||
reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens,
|
||||
totalTokens: base.totalTokens + increment.totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
|
||||
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
|
||||
}
|
||||
|
||||
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
|
||||
const threadSource = extractCodexThreadSource(payload)
|
||||
if (threadSource) {
|
||||
return threadSource.toLowerCase() !== 'user'
|
||||
}
|
||||
|
||||
const source = asRecord(payload.source)
|
||||
return Boolean(asRecord(source?.subagent))
|
||||
}
|
||||
|
||||
function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
|
||||
return (
|
||||
normalizeTitleText(extractString(payload.title) ?? '') ??
|
||||
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
|
||||
normalizeTitleText(extractString(payload.threadName) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitle(
|
||||
sessionFilePath: string,
|
||||
codexHome: string | null,
|
||||
sessionId: string
|
||||
): Promise<string | null> {
|
||||
const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
|
||||
if (!resolvedCodexHome) {
|
||||
return null
|
||||
}
|
||||
const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
|
||||
return titleBySessionId.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
|
||||
let currentDir = dirname(sessionFilePath)
|
||||
while (currentDir && dirname(currentDir) !== currentDir) {
|
||||
if (basename(currentDir) === 'sessions') {
|
||||
return dirname(currentDir)
|
||||
}
|
||||
currentDir = dirname(currentDir)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<string, string>> {
|
||||
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
let signature: string
|
||||
try {
|
||||
const indexStat = await stat(indexPath)
|
||||
signature = `${indexStat.size}:${indexStat.mtimeMs}`
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const cached = codexSessionIndexTitleCache.get(codexHome)
|
||||
if (cached) {
|
||||
const entry = await cached
|
||||
if (entry.signature === signature) {
|
||||
return entry.titles
|
||||
}
|
||||
}
|
||||
|
||||
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
|
||||
signature,
|
||||
titles
|
||||
}))
|
||||
codexSessionIndexTitleCache.set(codexHome, pending)
|
||||
return (await pending).titles
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitlesFromDisk(
|
||||
indexPath: string
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(indexPath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex creates the index opportunistically; older homes may only have raw transcripts.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
opencodeDbPaths: [],
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
devinTranscriptsDir: join(root, 'devin-transcripts'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readdir } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
|
||||
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
|
||||
const OPENCODE_STORAGE_DIR = join(
|
||||
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
|
||||
'storage'
|
||||
)
|
||||
|
||||
export function opencodeDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
const storageDirs = opencodeStorageDirs(options, wslHomeDirs)
|
||||
return storageDirs.map(async (storageDir, index) =>
|
||||
discoverOpenCodeSessions({
|
||||
storageDir,
|
||||
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
|
||||
limitPerAgent: limit,
|
||||
issues
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function opencodeStorageDirs(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[]
|
||||
): string[] {
|
||||
return [
|
||||
options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR,
|
||||
...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage'))
|
||||
]
|
||||
}
|
||||
|
||||
async function opencodeDbPathsForSource(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
storageDir: string,
|
||||
sourceIndex: number
|
||||
): Promise<readonly string[]> {
|
||||
if (options.opencodeDbPaths) {
|
||||
return sourceIndex === 0 ? options.opencodeDbPaths : []
|
||||
}
|
||||
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
|
||||
if (sourceIndex === 0 && options.opencodeStorageDir) {
|
||||
return listOpenCodeDatabasesInDirectory(dirname(storageDir))
|
||||
}
|
||||
if (sourceIndex === 0) {
|
||||
return listOpenCodeDatabases()
|
||||
}
|
||||
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
|
||||
return wslHomeDir
|
||||
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
|
||||
: []
|
||||
}
|
||||
|
||||
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(dataDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDir, entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import Database from '../sqlite/sync-database'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
let tempDbDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
for (const dir of tempDbDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempRoots = []
|
||||
tempDbDirs = []
|
||||
})
|
||||
|
||||
function isolatedScanRoots(root: string) {
|
||||
return {
|
||||
claudeProjectsDir: join(root, 'claude-projects'),
|
||||
codexSessionsDir: join(root, 'codex-sessions'),
|
||||
geminiSessionsDir: join(root, 'gemini-sessions'),
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
opencodeDbPaths: [] as readonly string[],
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
devinTranscriptsDir: join(root, 'devin-transcripts'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
rovoSessionsDir: join(root, 'rovo-sessions'),
|
||||
openclawStateDir: join(root, 'openclaw-state'),
|
||||
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
function createTempOpenCodeDb(): { db: Database.Database; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-'))
|
||||
tempDbDirs.push(dir)
|
||||
const path = join(dir, 'opencode.db')
|
||||
return { db: new Database(path), path }
|
||||
}
|
||||
|
||||
function applyOpenCodeSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_archived INTEGER,
|
||||
model TEXT,
|
||||
agent TEXT,
|
||||
cost REAL DEFAULT 0 NOT NULL,
|
||||
tokens_input INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_output INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => {
|
||||
it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const opencodeDataDir = join(root, 'custom-opencode')
|
||||
const opencodeStorageDir = join(opencodeDataDir, 'storage')
|
||||
await mkdir(opencodeStorageDir, { recursive: true })
|
||||
|
||||
const dbPath = join(opencodeDataDir, 'opencode.db')
|
||||
const db = new Database(dbPath)
|
||||
applyOpenCodeSchema(db)
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, slug, directory, title, version,
|
||||
time_created, time_updated, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode',
|
||||
'Custom SQLite session', '1.0.0',
|
||||
1777634010000, 1777634011000, NULL, 'build', 0,
|
||||
8, 13, 21, 34, 0)`
|
||||
).run()
|
||||
db.close()
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
opencodeStorageDir,
|
||||
opencodeDbPaths: undefined,
|
||||
platform: 'darwin',
|
||||
limit: 50
|
||||
})
|
||||
|
||||
const session = result.sessions.find((s) => s.sessionId === 'custom-db-session')
|
||||
expect(session).toBeDefined()
|
||||
expect(session!.agent).toBe('opencode')
|
||||
expect(session!.title).toBe('Custom SQLite session')
|
||||
expect(session!.filePath).toBe(dbPath)
|
||||
expect(session!.totalTokens).toBe(42)
|
||||
})
|
||||
|
||||
it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
|
||||
// Legacy file session under storage/session/<projectId>/<sessionId>.json
|
||||
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
|
||||
await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'),
|
||||
JSON.stringify({
|
||||
id: 'legacy-session',
|
||||
directory: '/tmp/legacy',
|
||||
title: 'Legacy file session',
|
||||
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
summary: { title: 'Legacy file session' },
|
||||
time: { created: 1_777_634_000_000 },
|
||||
tokens: { input: 5, output: 2 }
|
||||
})
|
||||
)
|
||||
|
||||
// SQLite session — same sessionId as the legacy file (dedup should keep SQLite)
|
||||
const { db, path: dbPath } = createTempOpenCodeDb()
|
||||
applyOpenCodeSchema(db)
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, slug, directory, title, version,
|
||||
time_created, time_updated, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0',
|
||||
1777634002000, 1777634003000, ?, 'build', 0,
|
||||
100, 40, 10, 5, 0)`
|
||||
).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }))
|
||||
db.prepare(
|
||||
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
||||
VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
|
||||
).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } }))
|
||||
db.prepare(
|
||||
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
|
||||
).run(JSON.stringify({ type: 'text', text: 'sqlite hello' }))
|
||||
db.close()
|
||||
|
||||
// A second SQLite-only session
|
||||
const { db: db2, path: dbPath2 } = createTempOpenCodeDb()
|
||||
applyOpenCodeSchema(db2)
|
||||
db2
|
||||
.prepare(
|
||||
`INSERT INTO session (id, project_id, slug, directory, title, version,
|
||||
time_created, time_updated, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0',
|
||||
1777634004000, 1777634005000, NULL, 'build', 0,
|
||||
50, 20, 0, 0, 0)`
|
||||
)
|
||||
.run()
|
||||
db2.close()
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
opencodeDbPaths: [dbPath, dbPath2],
|
||||
platform: 'darwin',
|
||||
limit: 50
|
||||
})
|
||||
|
||||
const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode')
|
||||
const sessionIds = opencodeSessions.map((s) => s.sessionId).sort()
|
||||
expect(sessionIds).toEqual(['legacy-session', 'sqlite-only'])
|
||||
|
||||
// Why: dedup keeps the SQLite entry (newer time_updated, source of truth)
|
||||
const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session')
|
||||
expect(legacyEntry).toBeDefined()
|
||||
expect(legacyEntry!.title).toBe('SQLite session')
|
||||
expect(legacyEntry!.cwd).toBe('/tmp/sqlite')
|
||||
expect(legacyEntry!.filePath).toBe(dbPath)
|
||||
expect(legacyEntry!.totalTokens).toBe(150)
|
||||
expect(legacyEntry!.resumeCommand).toBe(
|
||||
"cd '/tmp/sqlite' && opencode --session 'legacy-session'"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import { basename, extname, join } from 'path'
|
||||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { discoverFiles } from './session-scanner-discovery'
|
||||
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
SessionFileCandidate,
|
||||
SessionFileDiscovery
|
||||
} from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
// Why: keep the SQLite discovery + dedup layer separate from the parser so
|
||||
// each file stays under the max-lines lint rule and the discovery layer can
|
||||
// be tested in isolation.
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
title: string | null
|
||||
directory: string | null
|
||||
time_created: number
|
||||
time_updated: number
|
||||
model_json: string | null
|
||||
agent: string | null
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
cost: number
|
||||
message_count: number
|
||||
}
|
||||
|
||||
function openReadonlyDatabase(dbPath: string): SyncDatabase {
|
||||
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
|
||||
db.pragma('query_only = ON')
|
||||
return db
|
||||
}
|
||||
|
||||
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'session') &&
|
||||
columnExists(db, 'session', 'time_created') &&
|
||||
columnExists(db, 'session', 'time_updated')
|
||||
)
|
||||
}
|
||||
|
||||
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
|
||||
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
|
||||
}
|
||||
|
||||
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'message') &&
|
||||
columnExists(db, 'message', 'session_id') &&
|
||||
columnExists(db, 'message', 'data')
|
||||
)
|
||||
}
|
||||
|
||||
function buildSessionListQuery(db: SyncDatabase): string {
|
||||
const modelSelect = sessionColumnSelect(db, 'model')
|
||||
const agentSelect = sessionColumnSelect(db, 'agent')
|
||||
const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read']
|
||||
const tokenSelects = tokenColumns
|
||||
.map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`)
|
||||
.join(', ')
|
||||
const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0'
|
||||
const parentIdPredicate = columnExists(db, 'session', 'parent_id')
|
||||
? 'AND s.parent_id IS NULL'
|
||||
: ''
|
||||
const archivedPredicate = columnExists(db, 'session', 'time_archived')
|
||||
? 'AND s.time_archived IS NULL'
|
||||
: ''
|
||||
const messageCountSubquery = canCountOpenCodeMessages(db)
|
||||
? `(SELECT COUNT(*) FROM message m
|
||||
WHERE m.session_id = s.id
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
|
||||
: '0'
|
||||
|
||||
return `SELECT s.id,
|
||||
${sessionColumnSelect(db, 'title')} AS title,
|
||||
${sessionColumnSelect(db, 'directory')} AS directory,
|
||||
s.time_created,
|
||||
s.time_updated,
|
||||
${modelSelect} AS model_json, ${agentSelect} AS agent,
|
||||
${tokenSelects}, ${costSelect} AS cost,
|
||||
${messageCountSubquery} AS message_count
|
||||
FROM session s
|
||||
WHERE 1=1 ${parentIdPredicate} ${archivedPredicate}
|
||||
ORDER BY s.time_updated DESC
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
|
||||
const mtimeMs =
|
||||
typeof row.time_updated === 'number' && row.time_updated > 0
|
||||
? row.time_updated
|
||||
: row.time_created
|
||||
return {
|
||||
agent: 'opencode' as AiVaultAgent,
|
||||
file: {
|
||||
path: buildOpenCodeSqliteCandidatePath(dbPath, row.id),
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString()
|
||||
},
|
||||
codexHome: null
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] {
|
||||
const candidatesBySessionId = new Map<string, SessionFileCandidate>()
|
||||
for (const candidate of candidates) {
|
||||
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const previous = candidatesBySessionId.get(parsed.sessionId)
|
||||
if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) {
|
||||
candidatesBySessionId.set(parsed.sessionId, candidate)
|
||||
}
|
||||
}
|
||||
return [...candidatesBySessionId.values()].sort((left, right) => {
|
||||
return right.file.mtimeMs - left.file.mtimeMs
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List OpenCode sessions from one or more SQLite databases as synthetic
|
||||
* `SessionFileCandidate` entries. Each candidate's file path is a synthetic
|
||||
* `<dbPath>#<sessionId>` string that the parser dispatcher routes to
|
||||
* `parseOpenCodeSqliteSession`. Databases that lack the `session` table are
|
||||
* silently skipped; errors are recorded as scan issues.
|
||||
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
|
||||
* @param args.limit - Maximum number of sessions to return per database.
|
||||
* @param args.issues - Collected scan issues to append errors to.
|
||||
* @returns Array of synthetic candidates sorted by `time_updated` DESC.
|
||||
*/
|
||||
export async function listOpenCodeSqliteSessions(args: {
|
||||
dbPaths: readonly string[]
|
||||
limit: number
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<SessionFileCandidate[]> {
|
||||
const candidates: SessionFileCandidate[] = []
|
||||
for (const dbPath of args.dbPaths) {
|
||||
let db: SyncDatabase | null = null
|
||||
try {
|
||||
db = openReadonlyDatabase(dbPath)
|
||||
if (!canReadOpenCodeSessions(db)) {
|
||||
continue
|
||||
}
|
||||
const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[]
|
||||
for (const row of rows) {
|
||||
candidates.push(rowToCandidate(row, dbPath))
|
||||
}
|
||||
} catch (err) {
|
||||
args.issues.push({
|
||||
agent: 'opencode',
|
||||
path: dbPath,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
return dedupeAndSortSqliteCandidates(candidates)
|
||||
}
|
||||
|
||||
// Why: extract the sessionId from a legacy file path like
|
||||
// storage/session/<projectId>/<sessionId>.json. Falls back to the filename
|
||||
// without extension when the opencode id format doesn't match a UUID.
|
||||
function sessionIdFromLegacyFilePath(filePath: string): string {
|
||||
return basename(filePath, extname(filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover OpenCode sessions from both the legacy file layout and the SQLite
|
||||
* DB, deduplicating at the file level before parsing. On mixed installs the
|
||||
* same session may appear once via a stale legacy JSON file and once via the
|
||||
* SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries
|
||||
* whose sessionId matches a SQLite entry are dropped. Legacy installs without
|
||||
* the `session` table fall through to the file scanner unchanged.
|
||||
* @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`).
|
||||
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
|
||||
* @param args.limitPerAgent - Maximum number of candidates per source.
|
||||
* @param args.issues - Collected scan issues to append errors to.
|
||||
* @returns A `SessionFileDiscovery` with deduplicated file entries.
|
||||
*/
|
||||
export async function discoverOpenCodeSessions(args: {
|
||||
storageDir: string
|
||||
dbPaths: readonly string[]
|
||||
limitPerAgent: number
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
const [fileDiscovery, sqliteCandidates] = await Promise.all([
|
||||
discoverFiles({
|
||||
rootDir: join(args.storageDir, 'session'),
|
||||
limit: args.limitPerAgent,
|
||||
agent: 'opencode',
|
||||
issues: args.issues,
|
||||
extensions: ['.json']
|
||||
}),
|
||||
listOpenCodeSqliteSessions({
|
||||
dbPaths: args.dbPaths,
|
||||
limit: args.limitPerAgent,
|
||||
issues: args.issues
|
||||
})
|
||||
])
|
||||
|
||||
const sqliteFiles = sqliteCandidates.map((c) => c.file)
|
||||
// Why: on mixed installs the same OpenCode session may appear once via the
|
||||
// SQLite DB and once via a stale legacy JSON file. SQLite is the source of
|
||||
// truth on 1.17.x, so drop file-based duplicates when a SQLite entry with
|
||||
// the same sessionId already exists. Deduping at the file level also avoids
|
||||
// parsing the same session twice.
|
||||
if (sqliteFiles.length === 0) {
|
||||
return {
|
||||
agent: 'opencode' as const,
|
||||
rootDir: fileDiscovery.rootDir,
|
||||
files: fileDiscovery.files
|
||||
}
|
||||
}
|
||||
const sqliteSessionIds = new Set<string>()
|
||||
for (const file of sqliteFiles) {
|
||||
const parsed = splitOpenCodeSqliteCandidate(file.path)
|
||||
if (parsed) {
|
||||
sqliteSessionIds.add(parsed.sessionId)
|
||||
}
|
||||
}
|
||||
const dedupedFileDiscovery: FileWithMtime[] = []
|
||||
for (const file of fileDiscovery.files) {
|
||||
if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) {
|
||||
dedupedFileDiscovery.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: 'opencode' as const,
|
||||
rootDir: fileDiscovery.rootDir,
|
||||
files: [...dedupedFileDiscovery, ...sqliteFiles]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildOpenCodeSqliteCandidatePath,
|
||||
looksLikeOpenCodeSqliteCandidate,
|
||||
splitOpenCodeSqliteCandidate
|
||||
} from './session-scanner-opencode-sqlite-paths'
|
||||
|
||||
describe('splitOpenCodeSqliteCandidate', () => {
|
||||
it('splits a synthetic db#sessionId path', () => {
|
||||
const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc')
|
||||
expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' })
|
||||
})
|
||||
|
||||
it('splits a stable-db path', () => {
|
||||
const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz')
|
||||
expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' })
|
||||
})
|
||||
|
||||
it('rejects a path whose db basename is not opencode*.db', () => {
|
||||
expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull()
|
||||
expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a path without a separator', () => {
|
||||
expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an empty sessionId', () => {
|
||||
expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('looksLikeOpenCodeSqliteCandidate', () => {
|
||||
it('returns true for a synthetic path', () => {
|
||||
expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a real filesystem path', () => {
|
||||
expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpenCodeSqliteCandidatePath', () => {
|
||||
it('joins dbPath and sessionId with #', () => {
|
||||
expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { basename } from 'path'
|
||||
|
||||
// Why: keep the synthetic candidate-path helpers separate from the SQLite
|
||||
// discovery/parser so both the scanner and the agent-parser dispatcher can
|
||||
// import them without pulling in the SyncDatabase dependency.
|
||||
|
||||
const OPENCODE_SQLITE_PATH_SEPARATOR = '#'
|
||||
|
||||
/**
|
||||
* Build a synthetic candidate path that encodes the SQLite DB path and session ID
|
||||
* as `<dbPath>#<sessionId>`. Used by the discovery layer so SQLite-backed
|
||||
* sessions flow through the same FileWithMtime pipeline as file-backed ones.
|
||||
* @param dbPath - Absolute path to the opencode.db file.
|
||||
* @param sessionId - The OpenCode session ID (primary key in the session table).
|
||||
* @returns The synthetic candidate path string.
|
||||
*/
|
||||
export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string {
|
||||
return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a synthetic candidate path back into its DB path and session ID parts.
|
||||
* Validates that the DB basename matches `opencode*.db` so real filesystem paths
|
||||
* that happen to contain `#` are never misrouted to the SQLite parser.
|
||||
* @param candidatePath - The synthetic path to parse.
|
||||
* @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise.
|
||||
*/
|
||||
export function splitOpenCodeSqliteCandidate(
|
||||
candidatePath: string
|
||||
): { dbPath: string; sessionId: string } | null {
|
||||
const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR)
|
||||
if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) {
|
||||
return null
|
||||
}
|
||||
const dbPath = candidatePath.slice(0, separatorIndex)
|
||||
const sessionId = candidatePath.slice(separatorIndex + 1)
|
||||
if (!dbPath || !sessionId) {
|
||||
return null
|
||||
}
|
||||
// Why: OpenCode DB files are named opencode*.db; reject anything else so we
|
||||
// never misroute a real filesystem path that happens to contain '#'.
|
||||
if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) {
|
||||
return null
|
||||
}
|
||||
return { dbPath, sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite
|
||||
* candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null).
|
||||
* @param candidatePath - The path to test.
|
||||
* @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise.
|
||||
*/
|
||||
export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean {
|
||||
return splitOpenCodeSqliteCandidate(candidatePath) !== null
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
||||
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
function createTempDb(): { db: Database.Database; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-'))
|
||||
tempDirs.push(dir)
|
||||
const path = join(dir, 'opencode.db')
|
||||
return { db: new Database(path), path }
|
||||
}
|
||||
|
||||
function applyOpenCodeSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
share_url TEXT,
|
||||
summary_additions INTEGER,
|
||||
summary_deletions INTEGER,
|
||||
summary_files INTEGER,
|
||||
summary_diffs TEXT,
|
||||
revert TEXT,
|
||||
permission TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_compacting INTEGER,
|
||||
time_archived INTEGER,
|
||||
workspace_id TEXT,
|
||||
path TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
cost REAL DEFAULT 0 NOT NULL,
|
||||
tokens_input INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_output INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE project (
|
||||
id TEXT PRIMARY KEY,
|
||||
worktree TEXT NOT NULL,
|
||||
vcs TEXT,
|
||||
name TEXT,
|
||||
icon_url TEXT,
|
||||
icon_color TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_initialized INTEGER,
|
||||
sandboxes TEXT NOT NULL,
|
||||
commands TEXT,
|
||||
icon_url_override TEXT
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function applyMinimalOpenCodeSchema(db: Database.Database): void {
|
||||
db.exec(`CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL
|
||||
);`)
|
||||
}
|
||||
|
||||
function insertSession(
|
||||
db: Database.Database,
|
||||
args: {
|
||||
id: string
|
||||
title?: string
|
||||
directory?: string
|
||||
timeCreated: number
|
||||
timeUpdated: number
|
||||
parentId?: string | null
|
||||
timeArchived?: number | null
|
||||
model?: string | null
|
||||
agent?: string | null
|
||||
tokensInput?: number
|
||||
tokensOutput?: number
|
||||
tokensReasoning?: number
|
||||
tokensCacheRead?: number
|
||||
cost?: number
|
||||
}
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
|
||||
time_created, time_updated, time_archived, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0',
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, 0)`
|
||||
).run(
|
||||
args.id,
|
||||
args.parentId ?? null,
|
||||
`slug-${args.id}`,
|
||||
args.directory ?? '/tmp/opencode',
|
||||
args.title ?? 'OpenCode title',
|
||||
args.timeCreated,
|
||||
args.timeUpdated,
|
||||
args.timeArchived ?? null,
|
||||
args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }),
|
||||
args.agent ?? 'build',
|
||||
args.cost ?? 0,
|
||||
args.tokensInput ?? 100,
|
||||
args.tokensOutput ?? 40,
|
||||
args.tokensReasoning ?? 10,
|
||||
args.tokensCacheRead ?? 5
|
||||
)
|
||||
}
|
||||
|
||||
function insertMessage(
|
||||
db: Database.Database,
|
||||
args: {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: 'user' | 'assistant'
|
||||
timeCreated: number
|
||||
summaryTitle?: string | null
|
||||
summaryBody?: string | null
|
||||
}
|
||||
): void {
|
||||
const data = JSON.stringify({
|
||||
role: args.role,
|
||||
time: { created: args.timeCreated },
|
||||
agent: 'build',
|
||||
summary:
|
||||
args.summaryTitle || args.summaryBody
|
||||
? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null }
|
||||
: undefined
|
||||
})
|
||||
db.prepare(
|
||||
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data)
|
||||
}
|
||||
|
||||
function insertPart(
|
||||
db: Database.Database,
|
||||
args: {
|
||||
id: string
|
||||
messageId: string
|
||||
sessionId: string
|
||||
timeCreated: number
|
||||
type?: 'text' | 'tool' | 'reasoning'
|
||||
text?: string
|
||||
}
|
||||
): void {
|
||||
const data = JSON.stringify({
|
||||
type: args.type ?? 'text',
|
||||
text: args.text ?? 'hello world'
|
||||
})
|
||||
db.prepare(
|
||||
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data)
|
||||
}
|
||||
describe('listOpenCodeSqliteSessions', () => {
|
||||
it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_old',
|
||||
title: 'Old',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'ses_new',
|
||||
title: 'New',
|
||||
timeCreated: 1_777_634_002_000,
|
||||
timeUpdated: 1_777_634_003_000
|
||||
})
|
||||
db.close()
|
||||
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues
|
||||
})
|
||||
expect(issues).toEqual([])
|
||||
expect(candidates).toHaveLength(2)
|
||||
expect(candidates[0].agent).toBe('opencode')
|
||||
expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000)
|
||||
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new'))
|
||||
expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old'))
|
||||
})
|
||||
|
||||
it('dedups matching session ids across databases and keeps the newest row', async () => {
|
||||
const { db: oldDb, path: oldPath } = createTempDb()
|
||||
applyOpenCodeSchema(oldDb)
|
||||
insertSession(oldDb, {
|
||||
id: 'ses_duplicate',
|
||||
title: 'Old duplicate',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
oldDb.close()
|
||||
|
||||
const { db: newDb, path: newPath } = createTempDb()
|
||||
applyOpenCodeSchema(newDb)
|
||||
insertSession(newDb, {
|
||||
id: 'ses_duplicate',
|
||||
title: 'New duplicate',
|
||||
timeCreated: 1_777_634_002_000,
|
||||
timeUpdated: 1_777_634_003_000
|
||||
})
|
||||
newDb.close()
|
||||
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [oldPath, newPath],
|
||||
limit: 10,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate'))
|
||||
})
|
||||
it('excludes archived and child sessions', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_normal',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'ses_archived',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_002_000,
|
||||
timeArchived: 1_777_634_002_500
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'ses_child',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_003_000,
|
||||
parentId: 'ses_normal'
|
||||
})
|
||||
db.close()
|
||||
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates.map((c) => c.file.path)).toEqual([
|
||||
buildOpenCodeSqliteCandidatePath(path, 'ses_normal')
|
||||
])
|
||||
})
|
||||
|
||||
it('returns [] when the session table is missing (legacy install)', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec('CREATE TABLE other (id TEXT)')
|
||||
db.close()
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates).toEqual([])
|
||||
})
|
||||
|
||||
it('records an issue when the DB file does not exist', async () => {
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: ['/nonexistent/opencode.db'],
|
||||
limit: 10,
|
||||
issues
|
||||
})
|
||||
expect(candidates).toEqual([])
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0].agent).toBe('opencode')
|
||||
expect(issues[0].path).toBe('/nonexistent/opencode.db')
|
||||
})
|
||||
|
||||
it('lists sessions from a minimal readable session table', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyMinimalOpenCodeSchema(db)
|
||||
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
||||
db.close()
|
||||
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues
|
||||
})
|
||||
expect(issues).toEqual([])
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeSqliteSession', () => {
|
||||
it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_1',
|
||||
title: 'OpenCode title',
|
||||
directory: '/tmp/opencode',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000,
|
||||
tokensInput: 100,
|
||||
tokensOutput: 40,
|
||||
tokensReasoning: 10,
|
||||
tokensCacheRead: 5,
|
||||
cost: 0.01
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 'msg_1',
|
||||
sessionId: 'ses_1',
|
||||
role: 'user',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
summaryTitle: 'OpenCode title'
|
||||
})
|
||||
insertPart(db, {
|
||||
id: 'prt_1',
|
||||
messageId: 'msg_1',
|
||||
sessionId: 'ses_1',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
text: 'Plan the work'
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 'msg_2',
|
||||
sessionId: 'ses_1',
|
||||
role: 'assistant',
|
||||
timeCreated: 1_777_634_000_900
|
||||
})
|
||||
insertPart(db, {
|
||||
id: 'prt_2',
|
||||
messageId: 'msg_2',
|
||||
sessionId: 'ses_1',
|
||||
timeCreated: 1_777_634_001_000,
|
||||
text: 'Done'
|
||||
})
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_1',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.agent).toBe('opencode')
|
||||
expect(session!.sessionId).toBe('ses_1')
|
||||
expect(session!.filePath).toBe(path)
|
||||
expect(session!.title).toBe('OpenCode title')
|
||||
expect(session!.cwd).toBe('/tmp/opencode')
|
||||
expect(session!.model).toBe('glm-5.2')
|
||||
expect(session!.totalTokens).toBe(150)
|
||||
expect(session!.messageCount).toBe(2)
|
||||
expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString())
|
||||
expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString())
|
||||
expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'")
|
||||
expect(session!.previewMessages).toHaveLength(2)
|
||||
expect(session!.previewMessages[0].text).toBe('Plan the work')
|
||||
expect(session!.previewMessages[0].role).toBe('user')
|
||||
expect(session!.previewMessages[1].text).toBe('Done')
|
||||
expect(session!.previewMessages[1].role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('falls back to summary.body for title when session.title is empty', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_2',
|
||||
title: '',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 'msg_1',
|
||||
sessionId: 'ses_2',
|
||||
role: 'user',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
summaryBody: 'fallback title from summary'
|
||||
})
|
||||
insertPart(db, {
|
||||
id: 'prt_1',
|
||||
messageId: 'msg_1',
|
||||
sessionId: 'ses_2',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
text: 'hello'
|
||||
})
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_2',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.title).toBe('fallback title from summary')
|
||||
})
|
||||
|
||||
it('returns null when the session id is not found', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_real',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
db.close()
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_missing',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the DB has no session table', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec('CREATE TABLE other (id TEXT)')
|
||||
db.close()
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_1',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('parses a minimal readable session table without optional columns or messages', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyMinimalOpenCodeSchema(db)
|
||||
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_minimal',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.sessionId).toBe('ses_minimal')
|
||||
expect(session!.filePath).toBe(path)
|
||||
expect(session!.title).toBe('OpenCode ses_mini')
|
||||
expect(session!.cwd).toBeNull()
|
||||
expect(session!.model).toBeNull()
|
||||
expect(session!.messageCount).toBe(0)
|
||||
expect(session!.totalTokens).toBe(0)
|
||||
expect(session!.previewMessages).toEqual([])
|
||||
})
|
||||
|
||||
it('extracts model from older modelID schema', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_3',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000,
|
||||
model: JSON.stringify({ modelID: 'claude-sonnet-4-5' })
|
||||
})
|
||||
db.close()
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_3',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.model).toBe('claude-sonnet-4-5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import { normalizeTitleText } from './session-scanner-values'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
// Why: OpenCode 1.17.x migrated session storage from per-session JSON files
|
||||
// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module
|
||||
// parses individual sessions from the DB into AiVaultSession objects. The
|
||||
// discovery layer (listing candidates) lives in
|
||||
// session-scanner-opencode-sqlite-discovery.ts.
|
||||
|
||||
const OPENCODE_SQLITE_PREVIEW_LIMIT = 5
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
title: string | null
|
||||
directory: string | null
|
||||
time_created: number
|
||||
time_updated: number
|
||||
model_json: string | null
|
||||
agent: string | null
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
cost: number
|
||||
message_count: number
|
||||
}
|
||||
|
||||
type PreviewRow = {
|
||||
role: string | null
|
||||
part_data: string
|
||||
time_created: number
|
||||
summary_title: string | null
|
||||
summary_body: string | null
|
||||
}
|
||||
|
||||
function openReadonlyDatabase(dbPath: string): SyncDatabase {
|
||||
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
|
||||
// Why: belt-and-suspenders guard so a bug in the SELECT list can never
|
||||
// mutate the user's opencode.db.
|
||||
db.pragma('query_only = ON')
|
||||
return db
|
||||
}
|
||||
|
||||
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'session') &&
|
||||
columnExists(db, 'session', 'time_created') &&
|
||||
columnExists(db, 'session', 'time_updated')
|
||||
)
|
||||
}
|
||||
|
||||
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
|
||||
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
|
||||
}
|
||||
|
||||
function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string {
|
||||
return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0'
|
||||
}
|
||||
|
||||
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'message') &&
|
||||
columnExists(db, 'message', 'session_id') &&
|
||||
columnExists(db, 'message', 'data')
|
||||
)
|
||||
}
|
||||
|
||||
function buildSessionQuery(db: SyncDatabase): string {
|
||||
const messageCountSubquery = canCountOpenCodeMessages(db)
|
||||
? `(SELECT COUNT(*) FROM message m
|
||||
WHERE m.session_id = s.id
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
|
||||
: '0'
|
||||
return `SELECT s.id,
|
||||
${sessionColumnSelect(db, 'title')} AS title,
|
||||
${sessionColumnSelect(db, 'directory')} AS directory,
|
||||
s.time_created,
|
||||
s.time_updated,
|
||||
${sessionColumnSelect(db, 'model')} AS model_json,
|
||||
${sessionColumnSelect(db, 'agent')} AS agent,
|
||||
${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input,
|
||||
${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output,
|
||||
${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning,
|
||||
${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read,
|
||||
${sessionNumberColumnSelect(db, 'cost')} AS cost,
|
||||
${messageCountSubquery} AS message_count
|
||||
FROM session s
|
||||
WHERE s.id = ?
|
||||
LIMIT 1`
|
||||
}
|
||||
|
||||
function extractModelId(modelJson: string | null): string | null {
|
||||
if (!modelJson) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(modelJson) as unknown
|
||||
const record =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
// Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}.
|
||||
// Older schemas used {"modelID":"..."}; accept both.
|
||||
return (
|
||||
(typeof record.id === 'string' && record.id.trim()) ||
|
||||
(typeof record.modelID === 'string' && record.modelID.trim()) ||
|
||||
null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] {
|
||||
if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') {
|
||||
return role
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function extractPartText(partData: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(partData) as unknown
|
||||
const record =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
if (typeof record.text === 'string') {
|
||||
return record.text
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewQuery(db: SyncDatabase): string | null {
|
||||
if (
|
||||
!canCountOpenCodeMessages(db) ||
|
||||
!tableExists(db, 'part') ||
|
||||
!columnExists(db, 'message', 'id') ||
|
||||
!columnExists(db, 'part', 'message_id') ||
|
||||
!columnExists(db, 'part', 'time_created') ||
|
||||
!columnExists(db, 'part', 'data')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return `SELECT json_extract(m.data, '$.role') AS role,
|
||||
p.data AS part_data,
|
||||
p.time_created,
|
||||
json_extract(m.data, '$.summary.title') AS summary_title,
|
||||
json_extract(m.data, '$.summary.body') AS summary_body
|
||||
FROM message m
|
||||
JOIN part p ON p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY p.time_created DESC
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single OpenCode session from the SQLite database into an
|
||||
* `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost)
|
||||
* and up to 5 preview messages by joining the `message` and `part` tables.
|
||||
* The database is opened read-only with `PRAGMA query_only = ON` as a
|
||||
* belt-and-suspenders guard against mutations.
|
||||
* @param args.dbPath - Absolute path to the opencode.db file.
|
||||
* @param args.sessionId - The session ID (primary key in the `session` table).
|
||||
* @param args.platform - The platform to use for resume command generation.
|
||||
* @returns The parsed `AiVaultSession`, or `null` if the session does not exist
|
||||
* or the database lacks the required schema.
|
||||
*/
|
||||
export async function parseOpenCodeSqliteSession(args: {
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const { dbPath, sessionId, platform } = args
|
||||
let db: SyncDatabase | null = null
|
||||
try {
|
||||
db = openReadonlyDatabase(dbPath)
|
||||
if (!canReadOpenCodeSessions(db)) {
|
||||
return null
|
||||
}
|
||||
const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined
|
||||
if (!row || row.id !== sessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mtimeMs =
|
||||
typeof row.time_updated === 'number' && row.time_updated > 0
|
||||
? row.time_updated
|
||||
: row.time_created
|
||||
// Why: discovery uses a synthetic db#session path only for parser routing.
|
||||
// The UI's log open/reveal actions need a real filesystem path.
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'opencode',
|
||||
file: {
|
||||
path: dbPath,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString()
|
||||
},
|
||||
sessionId
|
||||
})
|
||||
accumulator.title = normalizeTitleText(row.title ?? '')
|
||||
accumulator.cwd = row.directory
|
||||
accumulator.model = extractModelId(row.model_json)
|
||||
accumulator.totalTokens =
|
||||
(row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0)
|
||||
accumulator.messageCount = row.message_count ?? 0
|
||||
updateTimeline(accumulator, row.time_created)
|
||||
updateTimeline(accumulator, row.time_updated)
|
||||
|
||||
const previewSql = buildPreviewQuery(db)
|
||||
if (previewSql) {
|
||||
const previewRows = db
|
||||
.prepare(previewSql)
|
||||
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[]
|
||||
// Why: query returns newest-first; push in chronological order so the
|
||||
// accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT
|
||||
// messages.
|
||||
for (let i = previewRows.length - 1; i >= 0; i--) {
|
||||
const previewRow = previewRows[i]
|
||||
if (!previewRow) {
|
||||
continue
|
||||
}
|
||||
const text = extractPartText(previewRow.part_data)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role: mapPreviewRole(previewRow.role),
|
||||
text,
|
||||
timestamp: previewRow.time_created
|
||||
})
|
||||
if (previewRow.role === 'user' && !accumulator.title) {
|
||||
accumulator.title =
|
||||
normalizeTitleText(previewRow.summary_title ?? '') ||
|
||||
normalizeTitleText(previewRow.summary_body ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
@@ -16,14 +16,10 @@ import {
|
||||
asRecord,
|
||||
claudeUsageTotal,
|
||||
extractContentText,
|
||||
extractGitBranch,
|
||||
extractMessageText,
|
||||
extractModel,
|
||||
extractString,
|
||||
normalizeCodexUsage,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
subtractCodexUsage,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
@@ -99,139 +95,6 @@ export async function parseClaudeSessionFile(
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
if (isCodexWorkerSession(payload)) {
|
||||
// Why: Codex writes internal worker/sub-agent transcripts into the same
|
||||
// history tree; AI Vault should show user-started sessions only.
|
||||
return null
|
||||
}
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
continue
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
continue
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage
|
||||
if (totalUsage) {
|
||||
previousTotals = totalUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
}
|
||||
|
||||
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
|
||||
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
|
||||
}
|
||||
|
||||
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
|
||||
const threadSource = extractCodexThreadSource(payload)
|
||||
if (threadSource) {
|
||||
return threadSource.toLowerCase() !== 'user'
|
||||
}
|
||||
|
||||
const source = asRecord(payload.source)
|
||||
return Boolean(asRecord(source?.subagent))
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
|
||||
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
|
||||
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
|
||||
@@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join(
|
||||
'session-state'
|
||||
)
|
||||
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
|
||||
const OPENCODE_STORAGE_DIR = join(
|
||||
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
|
||||
'storage'
|
||||
)
|
||||
const GROK_SESSIONS_DIR = join(
|
||||
process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
|
||||
'sessions'
|
||||
@@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: {
|
||||
...(options.additionalCodexSessionsDirs ?? [])
|
||||
])
|
||||
|
||||
return Promise.all<SessionFileDiscovery>([
|
||||
return Promise.all([
|
||||
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
|
||||
// SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy)
|
||||
// and the SQLite scanner (1.17.x); dedup by sessionId happens inside.
|
||||
...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
|
||||
...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
|
||||
...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues),
|
||||
...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
|
||||
@@ -109,7 +110,6 @@ function standardDiscoveries(
|
||||
discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] })
|
||||
),
|
||||
...cursorDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...opencodeDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...grokDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...devinDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
@@ -139,21 +139,6 @@ function cursorDiscoveries(
|
||||
)
|
||||
}
|
||||
|
||||
function opencodeDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
return sessionRootDirs(
|
||||
join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'),
|
||||
wslHomeDirs,
|
||||
['.local', 'share', 'opencode', 'storage', 'session']
|
||||
).map((rootDir) =>
|
||||
discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] })
|
||||
)
|
||||
}
|
||||
|
||||
function grokDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
|
||||
@@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024
|
||||
const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512
|
||||
const FIELD_BLANK_SCAN_LIMIT = 1024
|
||||
|
||||
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for'
|
||||
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions'
|
||||
const XML_INSTRUCTIONS_PREFIX = '<INSTRUCTIONS>'
|
||||
|
||||
const HIDDEN_TEXT_BLOCKS = [
|
||||
|
||||
@@ -14,6 +14,9 @@ export type AiVaultScanOptions = {
|
||||
copilotSessionsDir?: string
|
||||
cursorProjectsDir?: string
|
||||
opencodeStorageDir?: string
|
||||
// Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB
|
||||
// here so they don't depend on the real ~/.local/share/opencode.
|
||||
opencodeDbPaths?: readonly string[]
|
||||
grokSessionsDir?: string
|
||||
devinTranscriptsDir?: string
|
||||
hermesSessionsDir?: string
|
||||
|
||||
@@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) {
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
// Why: prevent the SQLite scanner from picking up the real
|
||||
// ~/.local/share/opencode/opencode.db during tests.
|
||||
opencodeDbPaths: [] as readonly string[],
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
devinTranscriptsDir: join(root, 'devin-transcripts'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
@@ -110,7 +113,7 @@ describe('scanAiVaultSessions', () => {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '# AGENTS.md instructions for /repo/app <INSTRUCTIONS>' }
|
||||
{ type: 'text', text: '# AGENTS.md instructions\n\n<INSTRUCTIONS>repo policy' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
@@ -162,6 +165,15 @@ describe('scanAiVaultSessions', () => {
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(root, 'session_index.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
id: '019f0000-1111-7222-8333-444444444444',
|
||||
thread_name: 'Indexed Codex resume picker title'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
@@ -171,7 +183,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(2)
|
||||
expect(result.sessions.map((session) => session.title).sort()).toEqual([
|
||||
'Fix the resume picker filters',
|
||||
'Indexed Codex resume picker title',
|
||||
'Vault polish pass'
|
||||
])
|
||||
|
||||
|
||||
@@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000
|
||||
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
|
||||
/**
|
||||
* Scan all supported AI agent session stores and return a unified, sorted,
|
||||
* deduplicated list of sessions for the AI Vault panel. Discovers sessions
|
||||
* from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based
|
||||
* stores (OpenCode 1.17.x). Results are sorted by session sort time DESC
|
||||
* and truncated to `limit`.
|
||||
* @param options - Optional scan configuration (limits, custom dirs, platform).
|
||||
* @returns The list of sessions, scan issues, and a timestamp.
|
||||
*/
|
||||
export async function scanAiVaultSessions(
|
||||
options: AiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
|
||||
+374
-2
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
browserWindowGetAllWindowsMock,
|
||||
@@ -36,11 +37,37 @@ vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({
|
||||
default: 'watercolor-icon'
|
||||
}))
|
||||
|
||||
vi.mock('../../resources/app-icons/orca-watercolor.png?asset&asarUnpack', () => ({
|
||||
default: 'watercolor-icon-unpacked'
|
||||
}))
|
||||
|
||||
vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({
|
||||
default: 'blue-icon'
|
||||
}))
|
||||
|
||||
import { applyAppIcon, getAppIconPath } from './app-icon'
|
||||
vi.mock('../../resources/app-icons/orca-blue.png?asset&asarUnpack', () => ({
|
||||
default: 'blue-icon-unpacked'
|
||||
}))
|
||||
|
||||
import { applyAppIcon, getAppIconPath, persistMacDockIcon } from './app-icon'
|
||||
|
||||
function waitForQueuedPersistence(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function waitForQueuedPersistenceMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function createMockChildProcess(): EventEmitter & { kill: ReturnType<typeof vi.fn> } {
|
||||
const childProcess = new EventEmitter() as EventEmitter & { kill: ReturnType<typeof vi.fn> }
|
||||
childProcess.kill = vi.fn(() => {
|
||||
childProcess.emit('exit')
|
||||
return true
|
||||
})
|
||||
return childProcess
|
||||
}
|
||||
|
||||
describe('app icon selection', () => {
|
||||
beforeEach(() => {
|
||||
@@ -51,6 +78,10 @@ describe('app icon selection', () => {
|
||||
isMock.dev = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves classic, watercolor, blue, and invalid icon ids', () => {
|
||||
expect(getAppIconPath('classic')).toBe('classic-icon')
|
||||
expect(getAppIconPath('watercolor')).toBe('watercolor-icon')
|
||||
@@ -76,4 +107,345 @@ describe('app icon selection', () => {
|
||||
}
|
||||
expect(windowSetIconMock).toHaveBeenCalledWith(image)
|
||||
})
|
||||
|
||||
it('persists a custom macOS dock icon to the app bundle for inactive Dock pins', async () => {
|
||||
const execFile = vi.fn(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null) => void
|
||||
) => {
|
||||
const onComplete =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? (optionsOrCallback as (error: Error | null) => void)
|
||||
: callback
|
||||
onComplete?.(null)
|
||||
}
|
||||
)
|
||||
|
||||
persistMacDockIcon('watercolor', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'/usr/bin/osascript',
|
||||
expect.arrayContaining(['-e', expect.stringContaining('setIcon:image forFile:appPath')]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app',
|
||||
ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('clears the AppKit icon and Finder metadata when switching macOS back to classic', async () => {
|
||||
const execFile = vi.fn(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null) => void
|
||||
) => {
|
||||
const onComplete =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? (optionsOrCallback as (error: Error | null) => void)
|
||||
: callback
|
||||
onComplete?.(null)
|
||||
}
|
||||
)
|
||||
|
||||
persistMacDockIcon('classic', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
expect(execFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/usr/bin/osascript',
|
||||
expect.arrayContaining([
|
||||
'-e',
|
||||
expect.stringContaining('setIcon:(missing value) forFile:appPath')
|
||||
]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app'
|
||||
}),
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'/usr/bin/xattr',
|
||||
['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'],
|
||||
expect.objectContaining({
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'/usr/bin/xattr',
|
||||
['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'],
|
||||
expect.objectContaining({
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('warns for non-benign failures when clearing Finder custom icon metadata', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const execFile = vi.fn(
|
||||
(
|
||||
file: string,
|
||||
args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null) => void
|
||||
) => {
|
||||
const onComplete =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? (optionsOrCallback as (error: Error | null) => void)
|
||||
: callback
|
||||
if (file !== '/usr/bin/xattr') {
|
||||
onComplete?.(null)
|
||||
return
|
||||
}
|
||||
onComplete?.(new Error(args[1] === 'com.apple.FinderInfo' ? 'No such xattr' : 'EACCES'))
|
||||
}
|
||||
)
|
||||
|
||||
persistMacDockIcon('classic', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[app-icon] failed to clear macOS dock icon metadata com.apple.ResourceFork:',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns when the AppKit classic icon reset fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const execFile = vi.fn(
|
||||
(
|
||||
file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null) => void
|
||||
) => {
|
||||
const onComplete =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? (optionsOrCallback as (error: Error | null) => void)
|
||||
: callback
|
||||
onComplete?.(file === '/usr/bin/osascript' ? new Error('reset denied') : null)
|
||||
}
|
||||
)
|
||||
|
||||
persistMacDockIcon('classic', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[app-icon] failed to clear macOS dock icon:',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('serializes rapid macOS dock icon persistence so the last icon request wins', async () => {
|
||||
const pendingCallbacks: (() => void)[] = []
|
||||
const execFile = vi.fn(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null) => void
|
||||
) => {
|
||||
const onComplete =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? (optionsOrCallback as (error: Error | null) => void)
|
||||
: callback
|
||||
pendingCallbacks.push(() => onComplete?.(null))
|
||||
}
|
||||
)
|
||||
|
||||
persistMacDockIcon('watercolor', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
persistMacDockIcon('blue', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
persistMacDockIcon('classic', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(execFile).toHaveBeenCalledTimes(1)
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'/usr/bin/osascript',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
pendingCallbacks.shift()?.()
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
expect(execFile).toHaveBeenCalledTimes(2)
|
||||
expect(execFile).not.toHaveBeenCalledWith(
|
||||
'/usr/bin/osascript',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_APP_ICON_PATH: 'blue-icon-unpacked'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/usr/bin/osascript',
|
||||
expect.arrayContaining([
|
||||
'-e',
|
||||
expect.stringContaining('setIcon:(missing value) forFile:appPath')
|
||||
]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app'
|
||||
}),
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
pendingCallbacks.shift()?.()
|
||||
await waitForQueuedPersistence()
|
||||
|
||||
expect(execFile).toHaveBeenCalledTimes(4)
|
||||
expect(execFile).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/usr/bin/xattr',
|
||||
['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'],
|
||||
expect.objectContaining({
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFile).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/usr/bin/xattr',
|
||||
['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'],
|
||||
expect.objectContaining({
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
for (const completeCommand of pendingCallbacks) {
|
||||
completeCommand()
|
||||
}
|
||||
await waitForQueuedPersistence()
|
||||
})
|
||||
|
||||
it('continues macOS dock icon persistence when a command never completes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hungChildProcess = createMockChildProcess()
|
||||
const execFile = vi.fn(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null) => void
|
||||
) => {
|
||||
if (execFile.mock.calls.length === 1) {
|
||||
return hungChildProcess
|
||||
}
|
||||
const onComplete =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? (optionsOrCallback as (error: Error | null) => void)
|
||||
: callback
|
||||
onComplete?.(null)
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
persistMacDockIcon('watercolor', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await waitForQueuedPersistenceMicrotasks()
|
||||
|
||||
persistMacDockIcon('blue', {
|
||||
appBundlePath: '/Applications/Orca.app',
|
||||
execFile,
|
||||
isDevApp: false,
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(execFile).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await waitForQueuedPersistenceMicrotasks()
|
||||
|
||||
expect(hungChildProcess.kill).not.toHaveBeenCalled()
|
||||
expect(execFile).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await waitForQueuedPersistenceMicrotasks()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[app-icon] timed out persisting macOS dock icon')
|
||||
expect(hungChildProcess.kill).toHaveBeenCalledTimes(1)
|
||||
expect(execFile).toHaveBeenCalledTimes(2)
|
||||
expect(execFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/usr/bin/osascript',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_APP_ICON_PATH: 'blue-icon-unpacked'
|
||||
}),
|
||||
timeout: 10_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import {
|
||||
execFile as execFileChildProcess,
|
||||
type ChildProcess,
|
||||
type ExecFileOptions
|
||||
} from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { app, BrowserWindow, nativeImage } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import classicIcon from '../../resources/icon.png?asset'
|
||||
import classicDevIcon from '../../resources/icon-dev.png?asset'
|
||||
import watercolorIcon from '../../resources/app-icons/orca-watercolor.png?asset'
|
||||
import watercolorMacDockIcon from '../../resources/app-icons/orca-watercolor.png?asset&asarUnpack'
|
||||
import blueIcon from '../../resources/app-icons/orca-blue.png?asset'
|
||||
import blueMacDockIcon from '../../resources/app-icons/orca-blue.png?asset&asarUnpack'
|
||||
import { normalizeAppIconId, type AppIconId } from '../shared/app-icon'
|
||||
|
||||
const APP_ICON_PATHS = {
|
||||
@@ -12,6 +20,59 @@ const APP_ICON_PATHS = {
|
||||
blue: blueIcon
|
||||
} satisfies Record<AppIconId, string>
|
||||
|
||||
const MAC_DOCK_ICON_PATHS = {
|
||||
watercolor: watercolorMacDockIcon,
|
||||
blue: blueMacDockIcon
|
||||
} satisfies Record<Exclude<AppIconId, 'classic'>, string>
|
||||
|
||||
type ExecFile = (
|
||||
file: string,
|
||||
args: string[],
|
||||
optionsOrCallback: ExecFileOptions | ((error: Error | null) => void),
|
||||
callback?: (error: Error | null) => void
|
||||
) => unknown
|
||||
|
||||
type MacDockIconChildProcess = Pick<ChildProcess, 'kill' | 'once'>
|
||||
|
||||
type PersistMacDockIconOptions = {
|
||||
appBundlePath?: string
|
||||
execFile?: ExecFile
|
||||
isDevApp?: boolean
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
const MAC_DOCK_ICON_SCRIPT = [
|
||||
'use framework "AppKit"',
|
||||
'use scripting additions',
|
||||
'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"',
|
||||
'set iconPath to system attribute "ORCA_APP_ICON_PATH"',
|
||||
"set image to current application's NSImage's alloc()'s initWithContentsOfFile:iconPath",
|
||||
'if image is missing value then error "Orca app icon image could not be loaded"',
|
||||
"set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:image forFile:appPath options:0",
|
||||
'if ok is false then error "Orca app icon could not be persisted"'
|
||||
]
|
||||
|
||||
const MAC_DOCK_ICON_CLEAR_SCRIPT = [
|
||||
'use framework "AppKit"',
|
||||
'use scripting additions',
|
||||
'set appPath to system attribute "ORCA_APP_BUNDLE_PATH"',
|
||||
"set ok to current application's NSWorkspace's sharedWorkspace()'s setIcon:(missing value) forFile:appPath options:0",
|
||||
'if ok is false then error "Orca app icon could not be cleared"'
|
||||
]
|
||||
|
||||
const MAC_DOCK_ICON_COMMAND_TIMEOUT_MS = 10_000
|
||||
const MAC_DOCK_ICON_COMMAND_FALLBACK_MS = 1_000
|
||||
|
||||
const defaultExecFile: ExecFile = (file, args, optionsOrCallback, callback) => {
|
||||
if (typeof optionsOrCallback === 'function') {
|
||||
return execFileChildProcess(file, args, optionsOrCallback)
|
||||
}
|
||||
return execFileChildProcess(file, args, optionsOrCallback, callback ?? (() => {}))
|
||||
}
|
||||
|
||||
let macDockIconPersistenceGeneration = 0
|
||||
let macDockIconPersistenceQueue = Promise.resolve()
|
||||
|
||||
export function getAppIconPath(value: unknown): string {
|
||||
return APP_ICON_PATHS[normalizeAppIconId(value)]
|
||||
}
|
||||
@@ -20,6 +81,212 @@ export function createAppIconImage(value: unknown): Electron.NativeImage {
|
||||
return nativeImage.createFromPath(getAppIconPath(value))
|
||||
}
|
||||
|
||||
function getMacAppBundlePath(): string | undefined {
|
||||
const appBundlePath = resolve(dirname(app.getPath('exe')), '..', '..')
|
||||
return appBundlePath.endsWith('.app') ? appBundlePath : undefined
|
||||
}
|
||||
|
||||
function runMacCustomIconCommand(
|
||||
execFile: ExecFile,
|
||||
appBundlePath: string,
|
||||
iconPath: string
|
||||
): Promise<void> {
|
||||
return runBoundedMacDockIconCommand({
|
||||
args: MAC_DOCK_ICON_SCRIPT.flatMap((line) => ['-e', line]),
|
||||
execFile,
|
||||
file: '/usr/bin/osascript',
|
||||
onError: (error) => {
|
||||
console.warn('[app-icon] failed to persist macOS dock icon:', error)
|
||||
},
|
||||
options: {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_APP_BUNDLE_PATH: appBundlePath,
|
||||
ORCA_APP_ICON_PATH: iconPath
|
||||
}
|
||||
},
|
||||
timeoutWarning: '[app-icon] timed out persisting macOS dock icon'
|
||||
})
|
||||
}
|
||||
|
||||
type BoundedMacDockIconCommandOptions = {
|
||||
args: string[]
|
||||
execFile: ExecFile
|
||||
file: string
|
||||
onError: (error: Error) => void
|
||||
options?: ExecFileOptions
|
||||
timeoutWarning: string
|
||||
}
|
||||
|
||||
function toError(value: unknown): Error {
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
function isMacDockIconChildProcess(value: unknown): value is MacDockIconChildProcess {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'kill' in value &&
|
||||
typeof value.kill === 'function' &&
|
||||
'once' in value &&
|
||||
typeof value.once === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function runBoundedMacDockIconCommand({
|
||||
args,
|
||||
execFile,
|
||||
file,
|
||||
onError,
|
||||
options,
|
||||
timeoutWarning
|
||||
}: BoundedMacDockIconCommandOptions): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let childProcess: MacDockIconChildProcess | undefined
|
||||
let settled = false
|
||||
let forceFallback: NodeJS.Timeout | undefined
|
||||
|
||||
const finish = (error: Error | null): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(fallback)
|
||||
if (forceFallback) {
|
||||
clearTimeout(forceFallback)
|
||||
}
|
||||
if (error) {
|
||||
onError(error)
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
const finishAfterFallback = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (forceFallback) {
|
||||
clearTimeout(forceFallback)
|
||||
}
|
||||
console.warn(timeoutWarning)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const fallback = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (!childProcess) {
|
||||
finishAfterFallback()
|
||||
return
|
||||
}
|
||||
// Why: the queue must not release while an older icon process can still win.
|
||||
childProcess.once('exit', finishAfterFallback)
|
||||
childProcess.once('close', finishAfterFallback)
|
||||
forceFallback = setTimeout(finishAfterFallback, MAC_DOCK_ICON_COMMAND_FALLBACK_MS)
|
||||
childProcess.kill()
|
||||
}, MAC_DOCK_ICON_COMMAND_TIMEOUT_MS + MAC_DOCK_ICON_COMMAND_FALLBACK_MS)
|
||||
|
||||
try {
|
||||
const maybeChildProcess = execFile(
|
||||
file,
|
||||
args,
|
||||
{
|
||||
...options,
|
||||
timeout: MAC_DOCK_ICON_COMMAND_TIMEOUT_MS
|
||||
},
|
||||
finish
|
||||
)
|
||||
if (isMacDockIconChildProcess(maybeChildProcess)) {
|
||||
childProcess = maybeChildProcess
|
||||
}
|
||||
} catch (error) {
|
||||
finish(toError(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleMacDockIconQueueError(error: unknown): void {
|
||||
console.warn('[app-icon] failed to persist macOS dock icon:', error)
|
||||
}
|
||||
|
||||
function enqueueMacDockIconPersistence(work: () => Promise<void>): void {
|
||||
macDockIconPersistenceQueue = macDockIconPersistenceQueue
|
||||
.catch(handleMacDockIconQueueError)
|
||||
.then(work)
|
||||
.catch(handleMacDockIconQueueError)
|
||||
}
|
||||
|
||||
function clearMacCustomIconMetadata(execFile: ExecFile, appBundlePath: string): Promise<void> {
|
||||
const clearAppKitIcon = (): Promise<void> => {
|
||||
return runBoundedMacDockIconCommand({
|
||||
args: MAC_DOCK_ICON_CLEAR_SCRIPT.flatMap((line) => ['-e', line]),
|
||||
execFile,
|
||||
file: '/usr/bin/osascript',
|
||||
onError: (error) => {
|
||||
console.warn('[app-icon] failed to clear macOS dock icon:', error)
|
||||
},
|
||||
options: {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_APP_BUNDLE_PATH: appBundlePath
|
||||
}
|
||||
},
|
||||
timeoutWarning: '[app-icon] timed out clearing macOS dock icon'
|
||||
})
|
||||
}
|
||||
|
||||
const clearAttribute = (attribute: string): Promise<void> => {
|
||||
return runBoundedMacDockIconCommand({
|
||||
args: ['-d', attribute, appBundlePath],
|
||||
execFile,
|
||||
file: '/usr/bin/xattr',
|
||||
onError: (error) => {
|
||||
if (!error.message.includes('No such xattr')) {
|
||||
console.warn(`[app-icon] failed to clear macOS dock icon metadata ${attribute}:`, error)
|
||||
}
|
||||
},
|
||||
timeoutWarning: `[app-icon] timed out clearing macOS dock icon metadata ${attribute}`
|
||||
})
|
||||
}
|
||||
|
||||
return clearAppKitIcon().then(() =>
|
||||
Promise.all([
|
||||
clearAttribute('com.apple.FinderInfo'),
|
||||
clearAttribute('com.apple.ResourceFork')
|
||||
]).then(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
export function persistMacDockIcon(value: unknown, options: PersistMacDockIconOptions = {}): void {
|
||||
const platform = options.platform ?? process.platform
|
||||
const isDevApp = options.isDevApp ?? (is.dev || !app.isPackaged)
|
||||
if (platform !== 'darwin' || isDevApp) {
|
||||
return
|
||||
}
|
||||
const appBundlePath = options.appBundlePath ?? getMacAppBundlePath()
|
||||
if (!appBundlePath) {
|
||||
return
|
||||
}
|
||||
const execFile = options.execFile ?? defaultExecFile
|
||||
const iconId = normalizeAppIconId(value)
|
||||
const generation = ++macDockIconPersistenceGeneration
|
||||
enqueueMacDockIconPersistence(async () => {
|
||||
// Why: stale queued writes must not reapply an older Dock pin icon.
|
||||
if (generation !== macDockIconPersistenceGeneration) {
|
||||
return
|
||||
}
|
||||
if (iconId === 'classic') {
|
||||
await clearMacCustomIconMetadata(execFile, appBundlePath)
|
||||
return
|
||||
}
|
||||
// Why: a stopped app's Dock tile is resolved from Finder metadata, not
|
||||
// Electron's live app.dock.setIcon state.
|
||||
await runMacCustomIconCommand(execFile, appBundlePath, MAC_DOCK_ICON_PATHS[iconId])
|
||||
})
|
||||
}
|
||||
|
||||
export function applyAppIcon(value: unknown): void {
|
||||
const image = createAppIconImage(value)
|
||||
if (image.isEmpty()) {
|
||||
@@ -33,4 +300,5 @@ export function applyAppIcon(value: unknown): void {
|
||||
window.setIcon(image)
|
||||
}
|
||||
}
|
||||
persistMacDockIcon(value)
|
||||
}
|
||||
|
||||
@@ -161,14 +161,22 @@ describe('ClaudeHookService.install', () => {
|
||||
)
|
||||
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
|
||||
expect(
|
||||
legacyCommands.some((command: string) => command.includes(CLAUDE_SCRIPT_FILE_NAME))
|
||||
legacyCommands.some((command: string) =>
|
||||
process.platform === 'win32'
|
||||
? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
|
||||
: command.includes(CLAUDE_SCRIPT_FILE_NAME)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
legacyCommands.some((command: string) =>
|
||||
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain(CLAUDE_SCRIPT_FILE_NAME)
|
||||
expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: new RegExp(CLAUDE_SCRIPT_FILE_NAME)
|
||||
)
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
).toContain('DEVIN_PROJECT_DIR')
|
||||
@@ -177,6 +185,36 @@ describe('ClaudeHookService.install', () => {
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path
|
||||
// with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed
|
||||
// command must use an encoded launcher so Git Bash/cmd.exe never splits or
|
||||
// expands the raw path before invoking the managed .cmd.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces '))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
|
||||
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('ClaudeHookService.installRemote', () => {
|
||||
@@ -287,7 +325,14 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8'))
|
||||
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
|
||||
if (process.platform === 'win32') {
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
} else {
|
||||
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
}
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getSharedManagedScriptPath,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
@@ -74,9 +75,11 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
|
||||
|
||||
export function getManagedCommand(scriptPath: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Why: Claude Code runs hooks through Git Bash on Windows; forward slashes
|
||||
// survive that shell layer while native Windows APIs still accept them.
|
||||
return scriptPath.replaceAll('\\', '/')
|
||||
// Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes
|
||||
// alone don't survive a path with spaces — bash splits at the space and
|
||||
// tries to execute `C:/Users/Jorge` as a command. Wrapping in
|
||||
// `cmd.exe /d /c call "..."` keeps the path as one argument. #6078.
|
||||
return wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalFontFamily: 'JetBrains Mono',
|
||||
terminalFontWeight: 500,
|
||||
terminalLineHeight: 1,
|
||||
terminalScrollSensitivity: 1.15,
|
||||
terminalFastScrollSensitivity: 5,
|
||||
terminalTuiScrollSensitivity: 3,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
terminalLigatures: 'auto',
|
||||
terminalCursorStyle: 'block',
|
||||
|
||||
@@ -64,6 +64,9 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalFontFamily: 'JetBrains Mono',
|
||||
terminalFontWeight: 500,
|
||||
terminalLineHeight: 1,
|
||||
terminalScrollSensitivity: 1.15,
|
||||
terminalFastScrollSensitivity: 5,
|
||||
terminalTuiScrollSensitivity: 3,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
terminalLigatures: 'auto',
|
||||
terminalCursorStyle: 'block',
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { tmpdir } from 'os'
|
||||
import type * as Os from 'os'
|
||||
import { join } from 'path'
|
||||
import { wrapPosixHookCommand } from '../agent-hooks/installer-utils'
|
||||
import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils'
|
||||
import { upsertHookTrustEntriesInContent } from './config-toml-trust'
|
||||
|
||||
const { getPathMock, homedirMock } = vi.hoisted(() => ({
|
||||
@@ -39,6 +39,11 @@ import { CodexHookService } from './hook-service'
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
|
||||
function isCodexManagedCommand(command: string | undefined): boolean {
|
||||
const scriptFileName = process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh'
|
||||
return createManagedCommandMatcher(scriptFileName)(command)
|
||||
}
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -118,8 +123,9 @@ describe('CodexHookService', () => {
|
||||
'UserPromptSubmit'
|
||||
].sort()
|
||||
)
|
||||
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks')
|
||||
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
|
||||
expect(
|
||||
isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
|
||||
).toBe(true)
|
||||
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(trustConfig).toContain('model = "gpt-5.2-codex"')
|
||||
@@ -127,6 +133,40 @@ describe('CodexHookService', () => {
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
|
||||
// be written verbatim as the hook command, so Codex split it at the space and
|
||||
// the hook exited with code 1. The managed command uses an encoded launcher
|
||||
// so the path never appears raw on the cmd.exe command line.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command in an encoded launcher when the profile path contains a space (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
const systemCodexHome = join(spaceHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
const hooksConfig = JSON.parse(
|
||||
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
|
||||
|
||||
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
|
||||
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => {
|
||||
const systemCodexHome = join(tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
@@ -177,12 +217,12 @@ describe('CodexHookService', () => {
|
||||
).toBe(true)
|
||||
expect(
|
||||
devHooks.hooks.Stop?.some((definition) =>
|
||||
definition.hooks?.[0]?.command?.includes('codex-hook')
|
||||
isCodexManagedCommand(definition.hooks?.[0]?.command)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
prodHooks.hooks.Stop?.some((definition) =>
|
||||
definition.hooks?.[0]?.command?.includes('codex-hook')
|
||||
isCodexManagedCommand(definition.hooks?.[0]?.command)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks)
|
||||
@@ -296,7 +336,9 @@ describe('CodexHookService', () => {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
|
||||
expect(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
|
||||
expect(isCodexManagedCommand(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command)).toBe(
|
||||
true
|
||||
)
|
||||
expect(runtimeHooks.hooks.PostToolUse?.[1]?.hooks?.[0]?.command).toBe(
|
||||
'slow-user-post-tool-hook'
|
||||
)
|
||||
@@ -430,7 +472,7 @@ describe('CodexHookService', () => {
|
||||
) ?? []
|
||||
|
||||
expect(stopCommands).toContain(userCommand)
|
||||
expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true)
|
||||
expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true)
|
||||
expect(runtimeHooks.hooks.PreCompact).toBeUndefined()
|
||||
for (const command of pluginCommands) {
|
||||
expect(runtimeHooksText).not.toContain(command)
|
||||
@@ -953,8 +995,10 @@ describe('CodexHookService', () => {
|
||||
(definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []
|
||||
) ?? []
|
||||
expect(stopCommands).toContain(userCommand)
|
||||
expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true)
|
||||
expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
|
||||
expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true)
|
||||
expect(
|
||||
isCodexManagedCommand(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
|
||||
).toBe(true)
|
||||
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain('[features]\nhooks = true')
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookCommandConfig,
|
||||
@@ -111,7 +112,9 @@ function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getSystemConfigPath(): string {
|
||||
|
||||
@@ -48,15 +48,46 @@ describe('CommandCodeHookService', () => {
|
||||
expect(config.hooks.PreToolUse[0].matcher).toBe('.*')
|
||||
expect(config.hooks.PostToolUse[0].matcher).toBe('.*')
|
||||
expect(config.hooks.Stop[0].matcher).toBeUndefined()
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook')
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
if (process.platform === 'win32') {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook.cmd')
|
||||
} else {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: /command-code-hook/
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path with a space used to be written
|
||||
// verbatim as the hook command, so the agent split it at the space. The
|
||||
// managed command must use an encoded launcher so the path never appears raw
|
||||
// on the cmd.exe command line.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca command-code home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
expect(new CommandCodeHookService().install().state).toBe('installed')
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(spaceHome, '.commandcode', 'settings.json'), 'utf8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
|
||||
const command = config.hooks.PreToolUse[0].hooks[0].command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('installs a hook script that can recover the endpoint when Command Code strips token env', () => {
|
||||
new CommandCodeHookService().install()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
@@ -45,7 +46,9 @@ function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
@@ -58,8 +58,14 @@ describe('CursorHookService', () => {
|
||||
expect(Object.keys(config.hooks).sort()).toEqual([...CURSOR_EVENTS].sort())
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const definition = config.hooks[eventName]?.[0]
|
||||
expect(definition?.command).toContain('cursor-hook')
|
||||
expect(definition?.command).toContain(join(homeDir, '.orca'))
|
||||
expect(definition?.command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: /cursor-hook/
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(definition?.command).toContain(join(homeDir, '.orca'))
|
||||
}
|
||||
expect(definition?.hooks).toBeUndefined()
|
||||
}
|
||||
|
||||
@@ -75,6 +81,35 @@ describe('CursorHookService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path with a space used to be written
|
||||
// verbatim as the hook command, so the agent split it at the space. The
|
||||
// managed command must use an encoded launcher so the path never appears raw
|
||||
// on the cmd.exe command line.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca cursor home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
expect(new CursorHookService().install().state).toBe('installed')
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(spaceHome, '.cursor', 'hooks.json'), 'utf8')
|
||||
) as { hooks: Record<string, { command?: string }[]> }
|
||||
|
||||
for (const eventName of ['beforeSubmitPrompt', 'stop']) {
|
||||
const command = config.hooks[eventName]?.[0]?.command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves user-authored Cursor hook entries and removes stale managed entries', () => {
|
||||
const configPath = join(homeDir, '.cursor', 'hooks.json')
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
@@ -107,7 +142,11 @@ describe('CursorHookService', () => {
|
||||
const promptCommands = config.hooks.beforeSubmitPrompt.map((definition) => definition.command)
|
||||
expect(promptCommands).toContain('/usr/local/bin/user-hook')
|
||||
expect(
|
||||
promptCommands.filter((command) => command?.includes(CURSOR_SCRIPT_FILE_NAME))
|
||||
promptCommands.filter((command) =>
|
||||
process.platform === 'win32'
|
||||
? command?.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
|
||||
: command?.includes(CURSOR_SCRIPT_FILE_NAME)
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(config.hooks.retiredEvent.map((definition) => definition.command)).toEqual([
|
||||
'/usr/local/bin/retired-user-hook'
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
@@ -59,7 +60,9 @@ function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
@@ -1424,6 +1424,38 @@ describe('createPtySubprocess', () => {
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes MSYS drive cwd before spawning daemon PowerShell on Windows', () => {
|
||||
const proc = mockPtyProcess()
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
spawnMock.mockImplementation((_shell, _args, options) => {
|
||||
if (options.cwd === '/c/Users/alice/project') {
|
||||
throw new Error('Cannot create process, error code: 267')
|
||||
}
|
||||
return proc
|
||||
})
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/c/Users/alice/project',
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
POWERSHELL_OSC133_COMMAND_ARGS,
|
||||
expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' })
|
||||
)
|
||||
})
|
||||
|
||||
it('validates the requested Windows cwd before launching WSL on Windows', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
@@ -70,11 +70,46 @@ describe('DroidHookService', () => {
|
||||
expect(config.hooks.PreToolUse[0].matcher).toBe('*')
|
||||
expect(config.hooks.PermissionRequest[0].matcher).toBe('*')
|
||||
expect(config.hooks.UserPromptSubmit[0].matcher).toBeUndefined()
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('droid-hook')
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: /droid-hook/
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
}
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir)
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path with a space used to be written
|
||||
// verbatim as the hook command, so the agent split it at the space. The
|
||||
// managed command must use an encoded launcher so the path never appears raw
|
||||
// on the cmd.exe command line.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca droid home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
expect(new DroidHookService().install().state).toBe('installed')
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(spaceHome, '.factory', 'settings.json'), 'utf8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
|
||||
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
|
||||
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('reports partial when Factory has hooks disabled globally', () => {
|
||||
const configPath = join(homeDir, '.factory', 'settings.json')
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
@@ -54,9 +55,12 @@ function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
// Why: Factory invokes the .cmd directly via cmd.exe (no bash), so native
|
||||
// backslashes are correct on Windows. Matches the codex/cursor pattern.
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
// Why: Factory invokes the .cmd via cmd.exe, but the raw path still splits at
|
||||
// whitespace when the user profile contains a space (e.g. `C:\Users\Jane Doe`).
|
||||
// The shared Windows wrapper keeps the path out of cmd.exe's raw command line. #6078.
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(): string {
|
||||
|
||||
@@ -100,12 +100,46 @@ describe('GeminiHookService', () => {
|
||||
expect(config.hooks.PreToolUse).toBeUndefined()
|
||||
expect(config.hooks.BeforeAgent).toHaveLength(2)
|
||||
expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent')
|
||||
expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath)
|
||||
expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath)
|
||||
expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath)
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath)
|
||||
const managedCommandPattern =
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: new RegExp(managedHookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
expect(config.hooks.BeforeAgent[1].hooks[0].command).toMatch(managedCommandPattern)
|
||||
expect(config.hooks.AfterAgent[0].hooks[0].command).toMatch(managedCommandPattern)
|
||||
expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(managedCommandPattern)
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern)
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path with a space used to be written
|
||||
// verbatim as the hook command, so the agent split it at the space. The
|
||||
// managed command must use an encoded launcher so the path never appears raw
|
||||
// on the cmd.exe command line.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca gemini home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
expect(new GeminiHookService().install().state).toBe('installed')
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(spaceHome, '.gemini', 'settings.json'), 'utf8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
|
||||
for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool']) {
|
||||
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => {
|
||||
const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh'
|
||||
const staleManagedHookPath =
|
||||
@@ -147,6 +181,10 @@ describe('GeminiHookService', () => {
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(preToolCommands).toEqual(['echo user-authored'])
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName)
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: new RegExp(managedHookFileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
@@ -43,7 +44,9 @@ function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
@@ -110,7 +110,8 @@ import {
|
||||
updatePRTitle,
|
||||
_getMergeQueueCacheSizeForTests,
|
||||
_resetOwnerRepoCache,
|
||||
_resetMergeQueueCacheForTests
|
||||
_resetMergeQueueCacheForTests,
|
||||
__resetTrackedUpstreamBranchCacheForTests
|
||||
} from './client'
|
||||
|
||||
describe('checkOrcaStarred', () => {
|
||||
@@ -179,6 +180,7 @@ describe('getPRForBranch', () => {
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
_resetOwnerRepoCache()
|
||||
_resetMergeQueueCacheForTests()
|
||||
__resetTrackedUpstreamBranchCacheForTests()
|
||||
})
|
||||
|
||||
it('queries GitHub by head branch when the remote is on github.com', async () => {
|
||||
@@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
|
||||
)
|
||||
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('no-pr-branch@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent missing tracked-upstream probes', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error("fatal: no upstream configured for branch 'no-pr-branch'")
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
getPRForBranch('/repo-root', 'no-pr-branch'),
|
||||
getPRForBranch('/repo-root', 'no-pr-branch'),
|
||||
getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
])
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('no-pr-branch@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
|
||||
)
|
||||
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('no-pr-branch@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(2)
|
||||
expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' })
|
||||
expect(trackedUpstreamCalls[1][1]).toEqual({
|
||||
cwd: '/repo-root',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'"))
|
||||
.mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' })
|
||||
|
||||
await getPRForBranch('/repo-root', 'feature')
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
await getPRForBranch('/repo-root', 'feature')
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('feature@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the tracked upstream remote owner for fork branch lookup', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
|
||||
@@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = {
|
||||
branchName: string
|
||||
}
|
||||
|
||||
const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000
|
||||
|
||||
type TrackedUpstreamNullCacheEntry = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const trackedUpstreamNullCache = new Map<string, TrackedUpstreamNullCacheEntry>()
|
||||
const trackedUpstreamInFlight = new Map<string, Promise<TrackedUpstreamBranch | null>>()
|
||||
|
||||
export function __resetTrackedUpstreamBranchCacheForTests(): void {
|
||||
trackedUpstreamNullCache.clear()
|
||||
trackedUpstreamInFlight.clear()
|
||||
}
|
||||
|
||||
function parseTrackedUpstreamBranch(
|
||||
upstreamRef: string,
|
||||
branchName: string
|
||||
@@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch(
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<TrackedUpstreamBranch | null> {
|
||||
// Why: branches without configured upstreams are stable misses during PR
|
||||
// polling; cache only nulls so positive PR discovery stays fresh.
|
||||
const cacheKey = getTrackedUpstreamBranchCacheKey(
|
||||
repoPath,
|
||||
branchName,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const now = Date.now()
|
||||
const cachedNull = trackedUpstreamNullCache.get(cacheKey)
|
||||
if (cachedNull && cachedNull.expiresAt > now) {
|
||||
return null
|
||||
}
|
||||
if (cachedNull) {
|
||||
trackedUpstreamNullCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const inFlight = trackedUpstreamInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions)
|
||||
trackedUpstreamInFlight.set(cacheKey, probe)
|
||||
try {
|
||||
const result = await probe
|
||||
if (result) {
|
||||
trackedUpstreamNullCache.delete(cacheKey)
|
||||
} else {
|
||||
trackedUpstreamNullCache.set(cacheKey, {
|
||||
expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS
|
||||
})
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (trackedUpstreamInFlight.get(cacheKey) === probe) {
|
||||
trackedUpstreamInFlight.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackedUpstreamBranchCacheKey(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): string {
|
||||
const runtimeKey = connectionId
|
||||
? `ssh:${connectionId}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
return [runtimeKey, repoPath, branchName].join('\0')
|
||||
}
|
||||
|
||||
async function probeTrackedUpstreamBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<TrackedUpstreamBranch | null> {
|
||||
const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`]
|
||||
try {
|
||||
|
||||
@@ -59,8 +59,14 @@ describe('GrokHookService', () => {
|
||||
expect(config.hooks.PreToolUse[0].matcher).toBe('*')
|
||||
expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*')
|
||||
expect(config.hooks.Notification[0].matcher).toBeUndefined()
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook')
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
|
||||
process.platform === 'win32'
|
||||
? /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
: /grok-hook/
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
}
|
||||
|
||||
const script = readFileSync(
|
||||
join(homeDir, '.orca', 'agent-hooks', GROK_SCRIPT_FILE_NAME),
|
||||
@@ -74,6 +80,35 @@ describe('GrokHookService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path with a space used to be written
|
||||
// verbatim as the hook command, so the agent split it at the space. The
|
||||
// managed command must use an encoded launcher so the path never appears raw
|
||||
// on the cmd.exe command line.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca grok home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
expect(new GrokHookService().install().state).toBe('installed')
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(spaceHome, '.grok', 'hooks', 'orca-status.json'), 'utf8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
|
||||
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
|
||||
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(
|
||||
/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves user-authored hook entries in the Orca Grok config file', () => {
|
||||
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
@@ -99,6 +134,12 @@ describe('GrokHookService', () => {
|
||||
definition.hooks.map((hook) => hook.command)
|
||||
)
|
||||
expect(commands).toContain('/usr/local/bin/user-hook')
|
||||
expect(commands.some((command) => command.includes(GROK_SCRIPT_FILE_NAME))).toBe(true)
|
||||
expect(
|
||||
commands.some((command) =>
|
||||
process.platform === 'win32'
|
||||
? command.startsWith('powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')
|
||||
: command.includes(GROK_SCRIPT_FILE_NAME)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
@@ -55,7 +56,9 @@ function getManagedScriptPath(): string {
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
|
||||
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
|
||||
|
||||
export async function hydrateShellPathForAgentDetection(
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<void> {
|
||||
if (getPreflightWslTarget(context)) {
|
||||
return
|
||||
}
|
||||
// Why: remote runtime servers may inherit a sparse daemon/SSH PATH even
|
||||
// though the user's shell can run the agents.
|
||||
const hydration = await hydrateShellPath()
|
||||
if (hydration.ok) {
|
||||
mergePathSegments(hydration.segments)
|
||||
}
|
||||
}
|
||||
+45
-10
@@ -1,14 +1,24 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock, destroySystemTrayMock } =
|
||||
vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
appExitMock: vi.fn(),
|
||||
appQuitMock: vi.fn(),
|
||||
appRelaunchMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
destroySystemTrayMock: vi.fn()
|
||||
}))
|
||||
const {
|
||||
handlers,
|
||||
appExitMock,
|
||||
appQuitMock,
|
||||
appRelaunchMock,
|
||||
execFileMock,
|
||||
destroySystemTrayMock,
|
||||
showOpenDialogMock,
|
||||
grantFloatingWorkspaceDirectoryMock
|
||||
} = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
appExitMock: vi.fn(),
|
||||
appQuitMock: vi.fn(),
|
||||
appRelaunchMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
destroySystemTrayMock: vi.fn(),
|
||||
showOpenDialogMock: vi.fn(),
|
||||
grantFloatingWorkspaceDirectoryMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock
|
||||
@@ -26,7 +36,7 @@ vi.mock('electron', () => ({
|
||||
fromWebContents: vi.fn(() => null)
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: vi.fn()
|
||||
showOpenDialog: showOpenDialogMock
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
||||
@@ -43,6 +53,12 @@ vi.mock('../tray/system-tray', () => ({
|
||||
destroySystemTray: destroySystemTrayMock
|
||||
}))
|
||||
|
||||
vi.mock('./floating-workspace-directory', () => ({
|
||||
ensureDefaultFloatingWorkspacePath: vi.fn(),
|
||||
grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock,
|
||||
resolveFloatingTerminalCwd: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerAppHandlers } from './app'
|
||||
|
||||
describe('registerAppHandlers', () => {
|
||||
@@ -56,6 +72,8 @@ describe('registerAppHandlers', () => {
|
||||
appRelaunchMock.mockReset()
|
||||
execFileMock.mockReset()
|
||||
destroySystemTrayMock.mockReset()
|
||||
showOpenDialogMock.mockReset()
|
||||
grantFloatingWorkspaceDirectoryMock.mockReset()
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
|
||||
})
|
||||
|
||||
@@ -177,4 +195,21 @@ describe('registerAppHandlers', () => {
|
||||
await expect(resultPromise).resolves.toBeNull()
|
||||
expect(killMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('picks an existing floating workspace directory without enabling native directory creation', async () => {
|
||||
const store = {}
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: ['/Users/kaylee/notes']
|
||||
})
|
||||
registerAppHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('app:pickFloatingWorkspaceDirectory')?.({ sender: {} })
|
||||
).resolves.toBe('/Users/kaylee/notes')
|
||||
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes')
|
||||
})
|
||||
})
|
||||
|
||||
+3
-1
@@ -57,7 +57,9 @@ async function pickFloatingWorkspaceDirectory(
|
||||
): Promise<string | null> {
|
||||
const parentWindow = BrowserWindow.fromWebContents(event.sender)
|
||||
const options = {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
// Why: this picker grants access to an existing workspace directory.
|
||||
// Creation belongs to explicit file/write actions, not typeahead input.
|
||||
properties: ['openDirectory']
|
||||
} satisfies Electron.OpenDialogOptions
|
||||
const result = parentWindow
|
||||
? await dialog.showOpenDialog(parentWindow, options)
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({
|
||||
import {
|
||||
_resetPreflightCache,
|
||||
detectInstalledAgents,
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
registerPreflightHandlers,
|
||||
runPreflightCheck
|
||||
} from './preflight'
|
||||
@@ -105,6 +106,7 @@ describe('preflight', () => {
|
||||
handleMock.mockReset()
|
||||
execFileAsyncMock.mockReset()
|
||||
hydrateShellPathMock.mockReset()
|
||||
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
|
||||
mergePathSegmentsMock.mockReset()
|
||||
getActiveMultiplexerMock.mockReset()
|
||||
getBitbucketAuthStatusMock.mockReset()
|
||||
@@ -575,6 +577,63 @@ describe('preflight', () => {
|
||||
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor'])
|
||||
})
|
||||
|
||||
it('hydrates shell PATH before user-facing agent detection', async () => {
|
||||
const originalPath = process.env.PATH
|
||||
process.env.PATH = '/usr/bin'
|
||||
hydrateShellPathMock.mockResolvedValueOnce({
|
||||
segments: ['/home/test/.local/bin'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => {
|
||||
process.env.PATH = [...segments, '/usr/bin'].join(':')
|
||||
return segments
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) {
|
||||
return { stdout: '/home/test/.local/bin/codex\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex'])
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
}
|
||||
expect(hydrateShellPathMock).toHaveBeenCalledWith()
|
||||
expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin'])
|
||||
})
|
||||
|
||||
it('does not run host shell hydration for WSL agent detection', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'wsl.exe') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
const script = String(args[5])
|
||||
if (script.includes("'claude'")) {
|
||||
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(
|
||||
detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' })
|
||||
).resolves.toEqual(['claude'])
|
||||
expect(hydrateShellPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects Mistral Vibe from the installed vibe executable', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command'
|
||||
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
|
||||
import { buildLocalPreflightEnv } from './preflight-local-env'
|
||||
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
|
||||
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
|
||||
|
||||
@@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext):
|
||||
return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id))
|
||||
}
|
||||
|
||||
export async function detectInstalledAgentsWithShellPathHydration(
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<string[]> {
|
||||
await hydrateShellPathForAgentDetection(context)
|
||||
return detectInstalledAgents(context)
|
||||
}
|
||||
|
||||
export type RefreshAgentsResult = {
|
||||
/** Agents detected after hydrating PATH from the user's login shell. */
|
||||
agents: string[]
|
||||
@@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'preflight:detectAgents',
|
||||
async (_event, args?: PreflightRuntimeContext): Promise<string[]> => {
|
||||
return detectInstalledAgents(args)
|
||||
}
|
||||
ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) =>
|
||||
detectInstalledAgentsWithShellPathHydration(args)
|
||||
)
|
||||
|
||||
ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => {
|
||||
|
||||
@@ -64,6 +64,14 @@ describe('repos folder pickers', () => {
|
||||
return handler(null, undefined) as Promise<string[]>
|
||||
}
|
||||
|
||||
const callPickDirectory = (): Promise<string | null> => {
|
||||
const handler = handlers.get('repos:pickDirectory')
|
||||
if (!handler) {
|
||||
throw new Error('repos:pickDirectory handler was never registered')
|
||||
}
|
||||
return handler(null, undefined) as Promise<string | null>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
@@ -101,4 +109,18 @@ describe('repos folder pickers', () => {
|
||||
|
||||
await expect(callPickFolders()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('picks an existing directory without enabling native directory creation', async () => {
|
||||
const parentDir = join(sep, 'projects')
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [parentDir]
|
||||
})
|
||||
|
||||
await expect(callPickDirectory()).resolves.toBe(parentDir)
|
||||
|
||||
expect(showOpenDialogMock).toHaveBeenCalledWith(mockWindow, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2070,7 +2070,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
// destination directory that may not be a git repo yet.
|
||||
ipcMain.handle('repos:pickDirectory', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
// Why: macOS can materialize typed partial paths when directory creation
|
||||
// is enabled; clone/create actions already create the final path on submit.
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
|
||||
@@ -132,6 +132,22 @@ describe('registerShellHandlers', () => {
|
||||
await expect(handler({})).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('picks an existing directory without enabling native directory creation', async () => {
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: ['/Users/kaylee/projects']
|
||||
})
|
||||
|
||||
const handler = getHandler('shell:pickDirectory')
|
||||
await expect(handler({}, { defaultPath: '/Users/kaylee' })).resolves.toBe(
|
||||
'/Users/kaylee/projects'
|
||||
)
|
||||
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
||||
defaultPath: '/Users/kaylee',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
})
|
||||
|
||||
describe('shell:openPath', () => {
|
||||
it('ignores relative paths', async () => {
|
||||
const handler = getHandler('shell:openPath')
|
||||
|
||||
@@ -205,7 +205,9 @@ export function registerShellHandlers(): void {
|
||||
async (_event, args: { defaultPath?: string }): Promise<string | null> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
defaultPath: args.defaultPath,
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
// Why: callers only need an existing folder grant; enabling native
|
||||
// creation can leave typed prefix directories behind on macOS.
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
|
||||
@@ -57,6 +57,7 @@ const {
|
||||
onExit: vi.fn(),
|
||||
onReplay: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
attachForReconnect: vi.fn().mockResolvedValue({}),
|
||||
shutdown: vi.fn()
|
||||
},
|
||||
mockFsProvider: {},
|
||||
@@ -285,6 +286,7 @@ describe('SSH IPC handlers', () => {
|
||||
mockPtyProvider.onData.mockReset()
|
||||
mockPtyProvider.onExit.mockReset()
|
||||
mockPtyProvider.onReplay.mockReset()
|
||||
mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({})
|
||||
mockPtyProvider.shutdown.mockReset()
|
||||
mockPortForwardManager.addForward.mockReset()
|
||||
mockPortForwardManager.updateForward.mockReset()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const worktreeChangeInvalidators = new Set<(repoId: string) => void>()
|
||||
|
||||
export function registerWorktreeChangeInvalidator(
|
||||
invalidator: (repoId: string) => void
|
||||
): () => void {
|
||||
worktreeChangeInvalidators.add(invalidator)
|
||||
return () => {
|
||||
worktreeChangeInvalidators.delete(invalidator)
|
||||
}
|
||||
}
|
||||
|
||||
export function runWorktreeChangeInvalidators(repoId: string): void {
|
||||
const invalidators = Array.from(worktreeChangeInvalidators)
|
||||
for (const invalidator of invalidators) {
|
||||
try {
|
||||
invalidator(repoId)
|
||||
} catch (error) {
|
||||
console.warn('[worktrees] worktree change invalidator failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetWorktreeChangeInvalidatorsForTests(): void {
|
||||
worktreeChangeInvalidators.clear()
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { runWorktreeChangeInvalidators } from './worktree-change-invalidators'
|
||||
|
||||
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate(
|
||||
}
|
||||
|
||||
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
// Why: invalidate detected-worktree caches before renderer observers react,
|
||||
// so follow-up listDetected reads post-change state.
|
||||
runWorktreeChangeInvalidators(repoId)
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('worktrees:changed', { repoId })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import type { CreateWorktreeResult } from '../../shared/types'
|
||||
import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
|
||||
@@ -226,9 +226,12 @@ vi.mock('./pty', () => ({
|
||||
getLocalPtyProvider: getLocalPtyProviderMock
|
||||
}))
|
||||
|
||||
import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote'
|
||||
import {
|
||||
__resetSshWorktreeCreateFetchCacheForTests,
|
||||
notifyWorktreesChanged
|
||||
} from './worktree-remote'
|
||||
import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth'
|
||||
import { registerWorktreeHandlers } from './worktrees'
|
||||
import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
|
||||
|
||||
@@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
beforeEach(() => {
|
||||
setPlatform(ORIGINAL_PLATFORM)
|
||||
__resetSshWorktreeCreateFetchCacheForTests()
|
||||
__resetDetectedWorktreeScanCacheForTests()
|
||||
invalidateAuthorizedRootsCache()
|
||||
for (const m of [
|
||||
handleMock,
|
||||
@@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses a recent authoritative detected worktree scan', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent authoritative detected worktree scans', async () => {
|
||||
listWorktreesMock.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
return [
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
|
||||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
|
||||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
])
|
||||
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rechecks detected worktree metadata while reusing a cached raw scan', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
let currentMeta = makeWorktreeMeta({ isPinned: false })
|
||||
store.getWorktreeMeta.mockImplementation(() => currentMeta)
|
||||
store.setWorktreeMeta.mockImplementation(() => currentMeta)
|
||||
const first = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
currentMeta = makeWorktreeMeta({ isPinned: true })
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(first.worktrees[0].isPinned).toBe(false)
|
||||
expect(second.worktrees[0].isPinned).toBe(true)
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rescans detected worktrees after the scan cache TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/new-worktree',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(second.worktrees.map((worktree) => worktree.path)).toEqual([
|
||||
'/workspace/repo',
|
||||
'/workspace/new-worktree'
|
||||
])
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts the detected scan cache TTL after a slow scan completes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
]),
|
||||
6_000
|
||||
)
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/new-worktree',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
await first
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo'])
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates the detected scan cache before worktree change notifications', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/new-worktree',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(second.worktrees).toHaveLength(2)
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rescans detected worktrees after the local create flow notifies worktree changes', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
const detected = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([
|
||||
'/workspace/repo',
|
||||
'/workspace/improve-dashboard'
|
||||
])
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => {
|
||||
let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {}
|
||||
listWorktreesMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void
|
||||
})
|
||||
)
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
'repo-1::/workspace/new-worktree': {
|
||||
worktreeId: 'repo-1::/workspace/new-worktree',
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: 'repo-1::/workspace/repo',
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'manual',
|
||||
capture: {
|
||||
source: 'manual-action',
|
||||
confidence: 'explicit'
|
||||
},
|
||||
createdAt: 0
|
||||
}
|
||||
})
|
||||
|
||||
const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await Promise.resolve()
|
||||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||||
resolveScan([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
await pendingList
|
||||
|
||||
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => {
|
||||
const fetchRemoteTrackingRef = vi.fn(async () => {})
|
||||
const exec = vi.fn(async (args: string[]) => {
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
cleanupUnusedWorktreePushTargetRemoteSsh,
|
||||
notifyWorktreesChanged
|
||||
} from './worktree-remote'
|
||||
import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators'
|
||||
import {
|
||||
invalidateAuthorizedRootsCache,
|
||||
isENOENT,
|
||||
@@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget(
|
||||
const loggedUnavailableSshGitProviders = new Set<string>()
|
||||
const loggedWorktreeListFailures = new Set<string>()
|
||||
const loggedMalformedWorktreeMetaKeys = new Set<string>()
|
||||
// Why: absorb renderer polling bursts while keeping external worktree-change
|
||||
// lag bounded to one short refresh window.
|
||||
const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000
|
||||
|
||||
type DetectedWorktreeScanCacheEntry = {
|
||||
expiresAt: number
|
||||
worktrees: GitWorktreeInfo[]
|
||||
}
|
||||
|
||||
type DetectedWorktreeScanResult = {
|
||||
gitWorktrees: GitWorktreeInfo[]
|
||||
fresh: boolean
|
||||
}
|
||||
|
||||
const detectedWorktreeScanCache = new Map<string, DetectedWorktreeScanCacheEntry>()
|
||||
const detectedWorktreeScanInFlight = new Map<string, Promise<GitWorktreeInfo[]>>()
|
||||
const detectedWorktreeScanGenerations = new Map<string, number>()
|
||||
|
||||
function invalidateDetectedWorktreeScanCache(repoId: string): void {
|
||||
detectedWorktreeScanCache.delete(repoId)
|
||||
detectedWorktreeScanInFlight.delete(repoId)
|
||||
detectedWorktreeScanGenerations.set(
|
||||
repoId,
|
||||
(detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache)
|
||||
|
||||
export function __resetDetectedWorktreeScanCacheForTests(): void {
|
||||
detectedWorktreeScanCache.clear()
|
||||
detectedWorktreeScanInFlight.clear()
|
||||
detectedWorktreeScanGenerations.clear()
|
||||
}
|
||||
|
||||
async function listDetectedGitWorktrees(
|
||||
store: Store,
|
||||
repo: Repo
|
||||
): Promise<DetectedWorktreeScanResult> {
|
||||
if (repo.connectionId || isFolderRepo(repo)) {
|
||||
return {
|
||||
gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)),
|
||||
fresh: true
|
||||
}
|
||||
}
|
||||
|
||||
const cached = detectedWorktreeScanCache.get(repo.id)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return { gitWorktrees: cached.worktrees, fresh: false }
|
||||
}
|
||||
|
||||
const inFlight = detectedWorktreeScanInFlight.get(repo.id)
|
||||
if (inFlight) {
|
||||
return { gitWorktrees: await inFlight, fresh: false }
|
||||
}
|
||||
|
||||
const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo))
|
||||
const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0
|
||||
detectedWorktreeScanInFlight.set(repo.id, scan)
|
||||
try {
|
||||
const gitWorktrees = await scan
|
||||
// Why: a create/remove notification can invalidate while the git scan is
|
||||
// still running. Do not let that stale scan repopulate the cache afterward.
|
||||
const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation
|
||||
if (isCurrentGeneration) {
|
||||
detectedWorktreeScanCache.set(repo.id, {
|
||||
worktrees: gitWorktrees,
|
||||
expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS
|
||||
})
|
||||
}
|
||||
return { gitWorktrees, fresh: isCurrentGeneration }
|
||||
} finally {
|
||||
if (detectedWorktreeScanInFlight.get(repo.id) === scan) {
|
||||
detectedWorktreeScanInFlight.delete(repo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnce(keySet: Set<string>, key: string, message: string, error?: unknown): void {
|
||||
if (keySet.has(key)) {
|
||||
@@ -946,6 +1024,7 @@ export function registerWorktreeHandlers(
|
||||
|
||||
try {
|
||||
let gitWorktrees: GitWorktreeInfo[]
|
||||
let freshScan = true
|
||||
if (isFolderRepo(repo)) {
|
||||
return {
|
||||
repoId: repo.id,
|
||||
@@ -966,13 +1045,14 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(
|
||||
repo,
|
||||
getLocalProjectWorktreeGitOptions(store, repo)
|
||||
)
|
||||
const scan = await listDetectedGitWorktrees(store, repo)
|
||||
gitWorktrees = scan.gitWorktrees
|
||||
freshScan = scan.fresh
|
||||
}
|
||||
if (freshScan) {
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
}
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
|
||||
@@ -18,10 +18,10 @@ vi.mock('./client', () => ({
|
||||
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
|
||||
}))
|
||||
|
||||
function makeEntry(): JiraClientForSite {
|
||||
function makeEntry(id = 'site-1'): JiraClientForSite {
|
||||
return {
|
||||
site: {
|
||||
id: 'site-1',
|
||||
id,
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Example Jira',
|
||||
@@ -60,6 +60,71 @@ describe('Jira issue operations', () => {
|
||||
).rejects.toThrow(error.message)
|
||||
})
|
||||
|
||||
it('rejects single-site search failures so the UI can surface them', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden')
|
||||
})
|
||||
|
||||
it('includes Jira status codes in surfaced single-site search failures', async () => {
|
||||
const error = Object.assign(new Error('Forbidden'), { status: 403 })
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1')])
|
||||
jiraRequestMock.mockRejectedValueOnce(error)
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(
|
||||
'Error 403: Forbidden'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps healthy sites when one site fails under an "all" search', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
|
||||
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
|
||||
})
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([
|
||||
{ key: 'BRV-1', title: 'Healthy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps healthy sites when the saved selection fans out without an explicit site', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
|
||||
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
|
||||
})
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([
|
||||
{ key: 'BRV-1', title: 'Healthy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces an error when every site fails under an "all" search', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock
|
||||
.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
.mockRejectedValueOnce(new Error('Service Unavailable'))
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden')
|
||||
})
|
||||
|
||||
it('prefers operational failures when every "all" search site fails', async () => {
|
||||
const authError = new Error('Unauthorized')
|
||||
const operationalError = new Error('Service Unavailable')
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
isAuthErrorMock.mockImplementation((error) => error === authError)
|
||||
jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError)
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable')
|
||||
expect(clearTokenMock).toHaveBeenCalledWith('site-1')
|
||||
})
|
||||
|
||||
it('paginates Jira project search results before sorting them', async () => {
|
||||
jiraRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
+53
-12
@@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number {
|
||||
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
|
||||
}
|
||||
|
||||
function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean {
|
||||
return selection !== 'all'
|
||||
type JiraIssueSearchFailure = {
|
||||
error: unknown
|
||||
auth: boolean
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object' || !('status' in error)) {
|
||||
return null
|
||||
}
|
||||
const status = (error as { status?: unknown }).status
|
||||
return typeof status === 'number' && Number.isFinite(status) ? status : null
|
||||
}
|
||||
|
||||
function toIssueSearchFailureError(error: unknown): unknown {
|
||||
const status = getErrorStatus(error)
|
||||
if (
|
||||
status === null ||
|
||||
!(error instanceof Error) ||
|
||||
error.message.startsWith(`Error ${status}:`)
|
||||
) {
|
||||
return error
|
||||
}
|
||||
return new Error(`Error ${status}: ${error.message}`)
|
||||
}
|
||||
|
||||
function shouldSurfaceSiteFailure(
|
||||
selection: JiraSiteSelection | null | undefined,
|
||||
entryCount: number
|
||||
): boolean {
|
||||
// getClients can resolve an omitted selection to the persisted 'all' choice;
|
||||
// multi-entry reads need the same resilient fan-out policy as explicit 'all'.
|
||||
return selection !== 'all' && entryCount <= 1
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JiraRecord {
|
||||
@@ -345,26 +375,37 @@ export async function searchIssues(
|
||||
return []
|
||||
}
|
||||
const safeLimit = clampLimit(limit)
|
||||
const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length })
|
||||
const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length)
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
entries.map(async (entry, index) => {
|
||||
await acquire()
|
||||
try {
|
||||
return await searchIssuesForClient(entry, jql.trim(), safeLimit)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
const authFailure = isAuthError(error)
|
||||
if (authFailure) {
|
||||
clearToken(entry.site.id)
|
||||
if (shouldThrowAuthError(siteId)) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
console.warn('[jira] searchIssues failed:', error)
|
||||
}
|
||||
return []
|
||||
if (surfaceSiteFailure) {
|
||||
throw toIssueSearchFailureError(error)
|
||||
}
|
||||
console.warn('[jira] searchIssues failed:', error)
|
||||
failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure }
|
||||
return [] as JiraIssue[]
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
)
|
||||
// 'all' fan-out: only surface an error when every connected site failed, so a
|
||||
// partial success (or a genuinely empty result) is not reported as an error.
|
||||
const recordedFailures = failures.filter(
|
||||
(failure): failure is JiraIssueSearchFailure => failure !== undefined
|
||||
)
|
||||
if (recordedFailures.length === entries.length) {
|
||||
throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error
|
||||
}
|
||||
return entries.length === 1
|
||||
? results.flat().slice(0, safeLimit)
|
||||
: sortAndLimitIssues(results.flat(), safeLimit)
|
||||
@@ -388,7 +429,7 @@ export async function getIssue(
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.site.id)
|
||||
if (shouldThrowAuthError(siteId)) {
|
||||
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
@@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise<J
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.site.id)
|
||||
if (shouldThrowAuthError(siteId)) {
|
||||
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer'
|
||||
import {
|
||||
_gitSpanSamplingBucketCountForTests,
|
||||
_resetGitSpanSamplingForTests,
|
||||
withGitSpan
|
||||
} from './instrumentation'
|
||||
|
||||
type SpanRecord = {
|
||||
readonly name: string
|
||||
readonly durationMs: number
|
||||
readonly attributes: Record<string, unknown>
|
||||
readonly exit: { readonly _tag: string; readonly cause?: string }
|
||||
}
|
||||
|
||||
type CapturedSink = TracerSink & {
|
||||
readonly records: SpanRecord[]
|
||||
}
|
||||
|
||||
function isSpanRecord(record: unknown): record is SpanRecord {
|
||||
return (
|
||||
record !== null &&
|
||||
typeof record === 'object' &&
|
||||
'name' in record &&
|
||||
typeof record.name === 'string' &&
|
||||
'durationMs' in record &&
|
||||
typeof record.durationMs === 'number' &&
|
||||
'attributes' in record &&
|
||||
record.attributes !== null &&
|
||||
typeof record.attributes === 'object' &&
|
||||
'exit' in record &&
|
||||
record.exit !== null &&
|
||||
typeof record.exit === 'object' &&
|
||||
'_tag' in record.exit &&
|
||||
typeof record.exit._tag === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function makeCapturingSink(): CapturedSink {
|
||||
const records: SpanRecord[] = []
|
||||
return {
|
||||
records,
|
||||
push(record) {
|
||||
if (!isSpanRecord(record)) {
|
||||
throw new Error('expected span record')
|
||||
}
|
||||
records.push(record)
|
||||
},
|
||||
flush() {
|
||||
/* no-op */
|
||||
},
|
||||
close() {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sink: CapturedSink
|
||||
let nowMs = 1_700_000_000_000
|
||||
|
||||
async function runGitSpan(
|
||||
meta: { args: readonly string[]; cwd?: string },
|
||||
durationMs: number,
|
||||
fail = false
|
||||
) {
|
||||
vi.setSystemTime(nowMs)
|
||||
const promise = withGitSpan(meta, async () => {
|
||||
vi.setSystemTime(nowMs + durationMs)
|
||||
if (fail) {
|
||||
throw new Error('git failed')
|
||||
}
|
||||
return 'ok'
|
||||
})
|
||||
nowMs += durationMs + 1
|
||||
return await promise
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
nowMs = 1_700_000_000_000
|
||||
_resetGitSpanSamplingForTests()
|
||||
sink = makeCapturingSink()
|
||||
setActiveSink(sink)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
_resetGitSpanSamplingForTests()
|
||||
_resetTracerForTests()
|
||||
})
|
||||
|
||||
describe('withGitSpan sampling', () => {
|
||||
it('bounds fast successful repeated git spans by subcommand and cwd while preserving important spans', async () => {
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
await runGitSpan({ args: ['status', '--short'], cwd: '/repo' }, 5)
|
||||
}
|
||||
|
||||
const repeatedFastSuccesses = sink.records.filter(
|
||||
(record) =>
|
||||
record.name === 'git.exec' &&
|
||||
record.exit._tag === 'Success' &&
|
||||
record.attributes['git.subcommand'] === 'status' &&
|
||||
record.attributes.cwd === '/repo' &&
|
||||
record.durationMs < 250
|
||||
)
|
||||
expect(repeatedFastSuccesses.length).toBeGreaterThan(0)
|
||||
expect(repeatedFastSuccesses.length).toBeLessThan(200)
|
||||
|
||||
await expect(runGitSpan({ args: ['status'], cwd: '/repo' }, 5, true)).rejects.toThrow(
|
||||
'git failed'
|
||||
)
|
||||
await runGitSpan({ args: ['status'], cwd: '/repo' }, 275)
|
||||
await runGitSpan({ args: ['branch'], cwd: '/repo' }, 5)
|
||||
await runGitSpan({ args: ['status'], cwd: '/other-repo' }, 5)
|
||||
|
||||
expect(
|
||||
sink.records.some(
|
||||
(record) =>
|
||||
record.exit._tag === 'Failure' &&
|
||||
record.attributes['git.subcommand'] === 'status' &&
|
||||
record.attributes.cwd === '/repo'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
sink.records.some(
|
||||
(record) =>
|
||||
record.exit._tag === 'Success' &&
|
||||
record.durationMs >= 250 &&
|
||||
record.attributes['git.subcommand'] === 'status' &&
|
||||
record.attributes.cwd === '/repo'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
sink.records.some(
|
||||
(record) =>
|
||||
record.exit._tag === 'Success' &&
|
||||
record.attributes['git.subcommand'] === 'branch' &&
|
||||
record.attributes.cwd === '/repo'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
sink.records.some(
|
||||
(record) =>
|
||||
record.exit._tag === 'Success' &&
|
||||
record.attributes['git.subcommand'] === 'status' &&
|
||||
record.attributes.cwd === '/other-repo'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
it('parses git subcommands after global options without changing arg count', async () => {
|
||||
await runGitSpan({ args: ['-c', 'core.quotePath=false', 'status', '--short'], cwd: '/repo' }, 5)
|
||||
|
||||
expect(sink.records[0]?.attributes['git.subcommand']).toBe('status')
|
||||
expect(sink.records[0]?.attributes['git.arg_count']).toBe(4)
|
||||
})
|
||||
|
||||
it('prunes stale git sampling buckets and caps unique cwd buckets', async () => {
|
||||
for (let i = 0; i < 700; i++) {
|
||||
await runGitSpan({ args: ['status'], cwd: `/repo-${i}` }, 5)
|
||||
}
|
||||
|
||||
expect(_gitSpanSamplingBucketCountForTests()).toBeLessThanOrEqual(512)
|
||||
|
||||
nowMs += 60_000
|
||||
await runGitSpan({ args: ['status'], cwd: '/fresh-repo' }, 5)
|
||||
|
||||
expect(_gitSpanSamplingBucketCountForTests()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -23,29 +23,164 @@
|
||||
|
||||
import { withSpan, type ActiveSpan } from './tracer'
|
||||
|
||||
const GIT_FAST_SUCCESS_THRESHOLD_MS = 250
|
||||
const GIT_FAST_SUCCESS_WINDOW_MS = 60_000
|
||||
const GIT_FAST_SUCCESS_BUDGET_PER_WINDOW = 60
|
||||
const GIT_SAMPLING_MAX_BUCKETS = 512
|
||||
|
||||
// Why: trace captures showed `git status --short` bursts dominating payloads.
|
||||
// Keep enough fast successes for timing shape while bounding memory and volume.
|
||||
const GIT_GLOBAL_OPTIONS_WITH_OPERAND = new Set([
|
||||
'-c',
|
||||
'-C',
|
||||
'--git-dir',
|
||||
'--work-tree',
|
||||
'--config-env',
|
||||
'--namespace',
|
||||
'--exec-path',
|
||||
'--super-prefix',
|
||||
'--pathspec-from-file'
|
||||
])
|
||||
const GIT_GLOBAL_FLAGS = new Set([
|
||||
'--bare',
|
||||
'--no-pager',
|
||||
'--paginate',
|
||||
'--literal-pathspecs',
|
||||
'--glob-pathspecs',
|
||||
'--noglob-pathspecs',
|
||||
'--icase-pathspecs',
|
||||
'--no-optional-locks',
|
||||
'--pathspec-file-nul'
|
||||
])
|
||||
|
||||
type GitSamplingBucket = {
|
||||
windowStartMs: number
|
||||
emitted: number
|
||||
}
|
||||
|
||||
const gitSamplingBuckets = new Map<string, GitSamplingBucket>()
|
||||
|
||||
function gitSubcommandFromArgs(args: readonly string[]): string {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index]
|
||||
if (!arg) {
|
||||
continue
|
||||
}
|
||||
if (arg === '--') {
|
||||
return '<none>'
|
||||
}
|
||||
if (GIT_GLOBAL_OPTIONS_WITH_OPERAND.has(arg)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (
|
||||
arg.startsWith('--git-dir=') ||
|
||||
arg.startsWith('--work-tree=') ||
|
||||
arg.startsWith('--config-env=') ||
|
||||
arg.startsWith('--namespace=') ||
|
||||
arg.startsWith('--exec-path=') ||
|
||||
arg.startsWith('--super-prefix=') ||
|
||||
arg.startsWith('--pathspec-from-file=') ||
|
||||
(arg.startsWith('-c') && arg.length > 2) ||
|
||||
(arg.startsWith('-C') && arg.length > 2)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (GIT_GLOBAL_FLAGS.has(arg)) {
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('-')) {
|
||||
continue
|
||||
}
|
||||
return arg
|
||||
}
|
||||
return '<none>'
|
||||
}
|
||||
|
||||
function pruneGitSamplingBuckets(nowMs: number): void {
|
||||
for (const [key, bucket] of gitSamplingBuckets) {
|
||||
if (nowMs - bucket.windowStartMs >= GIT_FAST_SUCCESS_WINDOW_MS) {
|
||||
gitSamplingBuckets.delete(key)
|
||||
}
|
||||
}
|
||||
while (gitSamplingBuckets.size > GIT_SAMPLING_MAX_BUCKETS) {
|
||||
let oldestKey: string | undefined
|
||||
let oldestWindowStartMs = Number.POSITIVE_INFINITY
|
||||
for (const [key, bucket] of gitSamplingBuckets) {
|
||||
if (bucket.windowStartMs < oldestWindowStartMs) {
|
||||
oldestKey = key
|
||||
oldestWindowStartMs = bucket.windowStartMs
|
||||
}
|
||||
}
|
||||
if (oldestKey === undefined) {
|
||||
return
|
||||
}
|
||||
gitSamplingBuckets.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function gitSamplingKey(meta: GitSpanArgs): string {
|
||||
return `${gitSubcommandFromArgs(meta.args)}\u0000${meta.cwd ?? '<none>'}`
|
||||
}
|
||||
|
||||
function shouldRecordGitSpan(
|
||||
meta: GitSpanArgs,
|
||||
record: { durationMs: number; startTimeUnixNano: string; exit: { _tag: string } }
|
||||
): boolean {
|
||||
if (record.exit._tag !== 'Success' || record.durationMs >= GIT_FAST_SUCCESS_THRESHOLD_MS) {
|
||||
return true
|
||||
}
|
||||
|
||||
const nowMs = Number(BigInt(record.startTimeUnixNano) / 1_000_000n)
|
||||
pruneGitSamplingBuckets(nowMs)
|
||||
const key = gitSamplingKey(meta)
|
||||
const bucket = gitSamplingBuckets.get(key)
|
||||
if (!bucket) {
|
||||
gitSamplingBuckets.set(key, { windowStartMs: nowMs, emitted: 1 })
|
||||
pruneGitSamplingBuckets(nowMs)
|
||||
return true
|
||||
}
|
||||
if (bucket.emitted < GIT_FAST_SUCCESS_BUDGET_PER_WINDOW) {
|
||||
bucket.emitted += 1
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function addGitAttributes(span: ActiveSpan, meta: GitSpanArgs): void {
|
||||
span.setAttribute('git.subcommand', gitSubcommandFromArgs(meta.args))
|
||||
// Why: git args can contain commit messages, branch names, remotes, or
|
||||
// paths. Keep cardinality without copying user-authored content.
|
||||
span.setAttribute('git.arg_count', meta.args.length)
|
||||
if (meta.cwd) {
|
||||
span.setAttribute('cwd', meta.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
export function _resetGitSpanSamplingForTests(): void {
|
||||
gitSamplingBuckets.clear()
|
||||
}
|
||||
|
||||
export function _gitSpanSamplingBucketCountForTests(): number {
|
||||
return gitSamplingBuckets.size
|
||||
}
|
||||
|
||||
export type GitSpanArgs = {
|
||||
readonly args: readonly string[]
|
||||
readonly cwd?: string
|
||||
}
|
||||
|
||||
/** Wrap a git execution in a `git.exec` span. The first argument typically
|
||||
* is the subcommand (`status`, `clone`, `pull`); promoting it to its own
|
||||
* attribute makes it grep-friendly without pulling the full args array
|
||||
* into a single comma-joined string in dashboards. */
|
||||
/** Wrap a git execution in a `git.exec` span. Git accepts global options before
|
||||
* the subcommand; promoting the parsed command to its own attribute makes it
|
||||
* grep-friendly without copying the full args array into dashboards. */
|
||||
export async function withGitSpan<T>(meta: GitSpanArgs, fn: () => Promise<T>): Promise<T> {
|
||||
return withSpan(
|
||||
'git.exec',
|
||||
async (span) => {
|
||||
span.setAttribute('git.subcommand', meta.args[0] ?? '<none>')
|
||||
// Why: git args can contain commit messages, branch names, remotes, or
|
||||
// paths. Keep cardinality without copying user-authored content.
|
||||
span.setAttribute('git.arg_count', meta.args.length)
|
||||
if (meta.cwd) {
|
||||
span.setAttribute('cwd', meta.cwd)
|
||||
}
|
||||
addGitAttributes(span, meta)
|
||||
return await fn()
|
||||
},
|
||||
{ attributes: { kind: 'git' } }
|
||||
{ attributes: { kind: 'git' }, shouldRecord: (record) => shouldRecordGitSpan(meta, record) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,10 +128,16 @@ export function getActiveSpanContext(): SpanContext | undefined {
|
||||
* This is the function 90% of call sites should reach for: it keeps span
|
||||
* lifetime scoped to the async work it measures.
|
||||
*/
|
||||
type SpanRecordDecision = (record: RedactableSpan) => boolean
|
||||
|
||||
export async function withSpan<T>(
|
||||
name: string,
|
||||
fn: (span: ActiveSpan) => Promise<T> | T,
|
||||
options?: { kind?: string; attributes?: Record<string, unknown> }
|
||||
options?: {
|
||||
kind?: string
|
||||
attributes?: Record<string, unknown>
|
||||
shouldRecord?: SpanRecordDecision
|
||||
}
|
||||
): Promise<T> {
|
||||
const span = startSpan(name, options)
|
||||
try {
|
||||
@@ -157,7 +163,11 @@ export async function withSpan<T>(
|
||||
*/
|
||||
export function startSpan(
|
||||
name: string,
|
||||
options?: { kind?: string; attributes?: Record<string, unknown> }
|
||||
options?: {
|
||||
kind?: string
|
||||
attributes?: Record<string, unknown>
|
||||
shouldRecord?: SpanRecordDecision
|
||||
}
|
||||
): ActiveSpan {
|
||||
if (!activeSink) {
|
||||
return noopSpan
|
||||
@@ -203,6 +213,10 @@ export function startSpan(
|
||||
exit
|
||||
}
|
||||
|
||||
if (options?.shouldRecord && !options.shouldRecord(record)) {
|
||||
return
|
||||
}
|
||||
|
||||
const redacted = redactSpan(record, 'client')
|
||||
// Wrap in a `type: 'effect-span'` envelope so the NDJSON file is
|
||||
// compatible with Effect-style span output. Effect-oriented consumers
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from './schema-helpers'
|
||||
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
||||
import type {
|
||||
OpenCodeUsageAttributedEvent,
|
||||
@@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function tableExists(db: Database.Database, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { found?: number } | undefined
|
||||
return row?.found === 1
|
||||
}
|
||||
|
||||
function columnExists(db: Database.Database, tableName: string, columnName: string): boolean {
|
||||
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
|
||||
return rows.some((row) => row.name === columnName)
|
||||
}
|
||||
|
||||
function getProjectJoin(db: Database.Database): string {
|
||||
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
|
||||
? 'LEFT JOIN project p ON p.id = s.project_id'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
// Why: OpenCode's usage scanner and the AI Vault session scanner both need to
|
||||
// probe the opencode.db schema shape across multiple DB generations. Centralizing
|
||||
// the probes here avoids two private copies and keeps the contract testable.
|
||||
type Database = SyncDatabase.Database
|
||||
|
||||
/**
|
||||
* Check whether a table exists in the given SQLite database.
|
||||
* @param db - A readonly or read-write SyncDatabase instance.
|
||||
* @param tableName - The table name to look up in sqlite_master.
|
||||
* @returns `true` if the table exists, `false` otherwise.
|
||||
*/
|
||||
export function tableExists(db: Database, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { found?: number } | undefined
|
||||
return row?.found === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a column exists on a table in the given SQLite database.
|
||||
* @param db - A readonly or read-write SyncDatabase instance.
|
||||
* @param tableName - The table to inspect via PRAGMA table_info.
|
||||
* @param columnName - The column name to find.
|
||||
* @returns `true` if the column exists on the table, `false` otherwise.
|
||||
*/
|
||||
export function columnExists(db: Database, tableName: string, columnName: string): boolean {
|
||||
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
|
||||
return rows.some((row) => row.name === columnName)
|
||||
}
|
||||
@@ -478,6 +478,9 @@ describe('Store', () => {
|
||||
expect(settings.editorAutoSaveDelayMs).toBe(1000)
|
||||
expect(settings.terminalFontSize).toBe(14)
|
||||
expect(settings.terminalFontWeight).toBe(500)
|
||||
expect(settings.terminalScrollSensitivity).toBe(1.15)
|
||||
expect(settings.terminalFastScrollSensitivity).toBe(5)
|
||||
expect(settings.terminalTuiScrollSensitivity).toBe(3)
|
||||
expect(settings.terminalUseSeparateLightTheme).toBe(true)
|
||||
expect(settings.rightSidebarOpenByDefault).toBe(true)
|
||||
expect(settings.showTasksButton).toBe(true)
|
||||
|
||||
@@ -227,6 +227,18 @@ describe('SshPtyProvider', () => {
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.attach', { id: 'pty-1' })
|
||||
})
|
||||
|
||||
it('attachForReconnect returns replay without relay notification', async () => {
|
||||
mux.request.mockResolvedValue({ replay: 'restored output' })
|
||||
|
||||
const result = await provider.attachForReconnect(scopedPty1)
|
||||
|
||||
expect(result).toEqual({ replay: 'restored output' })
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.attach', {
|
||||
id: 'pty-1',
|
||||
suppressReplayNotification: true
|
||||
})
|
||||
})
|
||||
|
||||
it('write sends pty.data notification', () => {
|
||||
provider.write(scopedPty1, 'hello')
|
||||
expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' })
|
||||
|
||||
@@ -177,6 +177,16 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) })
|
||||
}
|
||||
|
||||
async attachForReconnect(id: string): Promise<{ replay?: string }> {
|
||||
// Why: reconnect owns replay delivery so stale/duplicate attach results can
|
||||
// be filtered before they reach the renderer.
|
||||
const result = (await this.mux.request('pty.attach', {
|
||||
id: this.toRelayPtyId(id),
|
||||
suppressReplayNotification: true
|
||||
})) as { replay?: string } | undefined
|
||||
return result ?? {}
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
this.mux.notify('pty.data', { id: this.toRelayPtyId(id), data })
|
||||
}
|
||||
|
||||
@@ -89,6 +89,17 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(command).not.toContain('`e]133')
|
||||
})
|
||||
|
||||
it('normalizes MSYS drive cwd before spawning native PowerShell', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
@@ -189,6 +200,33 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
|
||||
})
|
||||
|
||||
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice',
|
||||
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it('escapes single quotes when translating a WSL cwd', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
|
||||
// The injected sh cmd must not break out of the surrounding single quotes
|
||||
|
||||
@@ -105,6 +105,19 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
|
||||
return distro ? ['-d', distro, ...shellArgs] : shellArgs
|
||||
}
|
||||
|
||||
function normalizeMsysDrivePath(cwd: string): string {
|
||||
const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/)
|
||||
if (!match) {
|
||||
return cwd
|
||||
}
|
||||
|
||||
// Why: Git Bash/MSYS launch contexts can hand Windows terminals `/d/repo`;
|
||||
// ConPTY requires the equivalent native drive path as its cwd.
|
||||
const driveLetter = match[1].toUpperCase()
|
||||
const rest = match[2]?.replace(/\//g, '\\') ?? ''
|
||||
return rest ? `${driveLetter}:\\${rest}` : `${driveLetter}:\\`
|
||||
}
|
||||
|
||||
/** Build the argv + effective cwd for a Windows shell launch.
|
||||
*
|
||||
* - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly.
|
||||
@@ -122,6 +135,8 @@ export function resolveWindowsShellLaunchArgs(
|
||||
startupCommand?: string
|
||||
): WindowsShellLaunchArgs {
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const nativeCwd = normalizeMsysDrivePath(cwd)
|
||||
const isMsysDriveCwd = nativeCwd !== cwd
|
||||
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
|
||||
@@ -133,8 +148,8 @@ export function resolveWindowsShellLaunchArgs(
|
||||
: CMD_UTF8_SETUP_COMMAND
|
||||
],
|
||||
...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}),
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,16 +162,16 @@ export function resolveWindowsShellLaunchArgs(
|
||||
...(powerShellCommand.startupCommandDeliveredInShellArgs
|
||||
? { startupCommandDeliveredInShellArgs: true }
|
||||
: {}),
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindowsGitBashShellPath(shellPath)) {
|
||||
return {
|
||||
shellArgs: ['--login', '-i'],
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,25 +184,25 @@ export function resolveWindowsShellLaunchArgs(
|
||||
validationCwd: cwd
|
||||
}
|
||||
}
|
||||
if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) {
|
||||
if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) {
|
||||
return {
|
||||
shellArgs: buildWslShellArgs(cwd, wslContext.distro),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: toWindowsWslPath(cwd, wslContext.distro)
|
||||
}
|
||||
}
|
||||
const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
|
||||
const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c'
|
||||
const driveMatch = nativeCwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
|
||||
const linuxCwd = driveMatch ? toLinuxPath(nativeCwd) : '/mnt/c'
|
||||
return {
|
||||
shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: cwd
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shellArgs: [],
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
@@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => {
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'legacy-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'stale-scoped-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'legacy-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
@@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => {
|
||||
weekly: { usedPercent: 34 }
|
||||
})
|
||||
|
||||
expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined)
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled()
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir)
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined)
|
||||
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.anthropic.com/api/oauth/usage',
|
||||
expect.objectContaining({
|
||||
@@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
runtime: 'host',
|
||||
envPatch: {},
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'scoped-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: { usedPercent: 12 },
|
||||
weekly: { usedPercent: 34 }
|
||||
})
|
||||
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir)
|
||||
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.anthropic.com/api/oauth/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer scoped-oauth-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the credentials file when Keychain access fails', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
|
||||
@@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions(
|
||||
if (!authPreparation) {
|
||||
return undefined
|
||||
}
|
||||
// Why: Claude Code 2.1+ can scope even the default config dir's macOS
|
||||
// Keychain item. Try scoped first, with legacy still handled as fallback.
|
||||
const readOptions: OAuthCredentialReadOptions = {
|
||||
credentialsFileConfigDir: authPreparation.configDir
|
||||
}
|
||||
// Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so
|
||||
// their Keychain lookup must mirror Claude's legacy service ordering.
|
||||
if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) {
|
||||
readOptions.keychainConfigDir = authPreparation.configDir
|
||||
credentialsFileConfigDir: authPreparation.configDir,
|
||||
keychainConfigDir: authPreparation.configDir
|
||||
}
|
||||
return readOptions
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ describe('RateLimitService', () => {
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: true
|
||||
allowPtyFallback: false
|
||||
})
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
@@ -464,6 +464,67 @@ describe('RateLimitService', () => {
|
||||
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback for system-default usage refreshes', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setClaudeAuthPreparationResolver(async () => ({
|
||||
configDir: '/tmp/.claude',
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: {},
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}))
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'system' }),
|
||||
allowPtyFallback: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
|
||||
const service = new RateLimitService()
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
service.setClaudeAuthPreparationResolver(async () => ({
|
||||
configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude',
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
envPatch: {},
|
||||
stripAuthEnv: true,
|
||||
provenance: 'wsl:Ubuntu:system'
|
||||
}))
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
|
||||
allowPtyFallback: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cache host Codex usage under an outgoing WSL account', async () => {
|
||||
const service = new RateLimitService()
|
||||
const wslCodexHome =
|
||||
|
||||
@@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number {
|
||||
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms))
|
||||
}
|
||||
|
||||
function isSystemDefaultClaudeAuth(
|
||||
authPreparation: ClaudeRuntimeAuthPreparation | undefined
|
||||
): boolean {
|
||||
// Why: fetch cycles classify missing Claude auth as system-default; keep the
|
||||
// PTY fallback gate aligned so background refresh cannot trigger auth flows.
|
||||
if (!authPreparation) {
|
||||
return true
|
||||
}
|
||||
const provenance = authPreparation?.provenance
|
||||
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
|
||||
}
|
||||
|
||||
export class RateLimitService {
|
||||
private state: InternalRateLimitState = {
|
||||
claude: null,
|
||||
@@ -792,10 +804,18 @@ export class RateLimitService {
|
||||
return process.platform !== 'win32'
|
||||
}
|
||||
|
||||
private shouldAllowClaudePtyFallback(): boolean {
|
||||
private shouldAllowClaudePtyFallback(
|
||||
authPreparation: ClaudeRuntimeAuthPreparation | undefined
|
||||
): boolean {
|
||||
// Why: automatic recovery uses Claude CLI as the next source, but Windows
|
||||
// hidden PTY support remains less reliable than host/WSL shells.
|
||||
return process.platform !== 'win32'
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
}
|
||||
// Why: system-default Claude is not an Orca-managed account. Background
|
||||
// quota refresh may read existing OAuth, but must not launch Claude and
|
||||
// trigger auth/browser flows for users who never configured Claude in Orca.
|
||||
return !isSystemDefaultClaudeAuth(authPreparation)
|
||||
}
|
||||
|
||||
private withFetchingStatus(
|
||||
@@ -861,7 +881,7 @@ export class RateLimitService {
|
||||
await Promise.allSettled([
|
||||
fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback()
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
|
||||
}),
|
||||
missingWslCodexHome ??
|
||||
fetchCodexRateLimits({
|
||||
@@ -1036,7 +1056,7 @@ export class RateLimitService {
|
||||
|
||||
const claude = await fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback()
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
|
||||
@@ -164,7 +164,7 @@ const {
|
||||
addGitHubIssueCommentMock,
|
||||
listGitHubLabelsMock,
|
||||
listGitHubAssignableUsersMock,
|
||||
detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
listGitLabMergeRequestsMock,
|
||||
listGitLabWorkItemsMock,
|
||||
@@ -255,7 +255,7 @@ const {
|
||||
addGitHubIssueCommentMock: vi.fn(),
|
||||
listGitHubLabelsMock: vi.fn(),
|
||||
listGitHubAssignableUsersMock: vi.fn(),
|
||||
detectInstalledAgentsMock: vi.fn(),
|
||||
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
|
||||
detectRemoteAgentsMock: vi.fn(),
|
||||
listGitLabMergeRequestsMock: vi.fn(),
|
||||
listGitLabWorkItemsMock: vi.fn(),
|
||||
@@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/preflight', () => ({
|
||||
detectInstalledAgents: detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgents: detectRemoteAgentsMock
|
||||
}))
|
||||
|
||||
@@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void {
|
||||
listGitHubLabelsMock.mockResolvedValue([])
|
||||
listGitHubAssignableUsersMock.mockReset()
|
||||
listGitHubAssignableUsersMock.mockResolvedValue([])
|
||||
detectInstalledAgentsMock.mockReset()
|
||||
detectInstalledAgentsMock.mockResolvedValue([])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockReset()
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
|
||||
detectRemoteAgentsMock.mockReset()
|
||||
detectRemoteAgentsMock.mockResolvedValue([])
|
||||
listGitLabMergeRequestsMock.mockReset()
|
||||
@@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue(['claude'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue(['claude'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-fallback-draft',
|
||||
@@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('lets explicit startup draft agents override the desktop default', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue([])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('does not auto-launch an agent for startup drafts when the default is blank', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex'])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined()
|
||||
@@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-startup-draft',
|
||||
|
||||
@@ -172,7 +172,7 @@ import {
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight'
|
||||
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
|
||||
import {
|
||||
markCodexProjectTrusted,
|
||||
markCopilotFolderTrusted,
|
||||
@@ -11436,9 +11436,10 @@ export class OrcaRuntimeService {
|
||||
if (!agent) {
|
||||
let detected: string[] = []
|
||||
try {
|
||||
// Why: startup-draft fallback can run from sparse runtime launch envs too.
|
||||
detected = repo.connectionId
|
||||
? await detectRemoteAgents({ connectionId: repo.connectionId })
|
||||
: await detectInstalledAgents()
|
||||
: await detectInstalledAgentsWithShellPathHydration()
|
||||
} catch {
|
||||
detected = []
|
||||
}
|
||||
|
||||
@@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { PREFLIGHT_METHODS } from './preflight'
|
||||
|
||||
const {
|
||||
detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
refreshShellPathAndDetectAgentsMock,
|
||||
runPreflightCheckMock
|
||||
} = vi.hoisted(() => ({
|
||||
detectInstalledAgentsMock: vi.fn(),
|
||||
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
|
||||
detectRemoteAgentsMock: vi.fn(),
|
||||
refreshShellPathAndDetectAgentsMock: vi.fn(),
|
||||
runPreflightCheckMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../ipc/preflight', () => ({
|
||||
detectInstalledAgents: detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgents: detectRemoteAgentsMock,
|
||||
refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock,
|
||||
runPreflightCheck: runPreflightCheckMock
|
||||
@@ -46,7 +46,7 @@ describe('preflight RPC methods', () => {
|
||||
})
|
||||
|
||||
it('detects agents and refreshes PATH on the server through runtime RPC', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValueOnce(['codex'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex'])
|
||||
refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({
|
||||
agents: ['codex', 'claude'],
|
||||
addedPathSegments: ['/opt/bin'],
|
||||
@@ -60,7 +60,7 @@ describe('preflight RPC methods', () => {
|
||||
const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents'))
|
||||
const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents'))
|
||||
|
||||
expect(detectInstalledAgentsMock).toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled()
|
||||
expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled()
|
||||
expect(detected).toMatchObject({ ok: true, result: ['codex'] })
|
||||
expect(refreshed).toMatchObject({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import {
|
||||
detectRemoteAgents,
|
||||
detectInstalledAgents,
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
refreshShellPathAndDetectAgents,
|
||||
runPreflightCheck
|
||||
} from '../../../ipc/preflight'
|
||||
@@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'preflight.detectAgents',
|
||||
params: null,
|
||||
handler: async () => detectInstalledAgents()
|
||||
handler: async () => detectInstalledAgentsWithShellPathHydration()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'preflight.detectRemoteAgents',
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock('../providers/ssh-pty-provider', () => ({
|
||||
onReplay = vi.fn().mockReturnValue(() => {})
|
||||
onExit = vi.fn().mockReturnValue(() => {})
|
||||
attach = vi.fn().mockResolvedValue(undefined)
|
||||
attachForReconnect = vi.fn().mockResolvedValue({})
|
||||
dispose = vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -67,7 +68,8 @@ vi.mock('../ipc/pty', () => ({
|
||||
unregisterSshPtyProvider: vi.fn(),
|
||||
getSshPtyProvider: vi.fn().mockReturnValue({
|
||||
dispose: vi.fn(),
|
||||
attach: vi.fn().mockResolvedValue(undefined)
|
||||
attach: vi.fn().mockResolvedValue(undefined),
|
||||
attachForReconnect: vi.fn().mockResolvedValue({})
|
||||
}),
|
||||
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
|
||||
clearPtyOwnershipForConnection: vi.fn(),
|
||||
@@ -355,7 +357,7 @@ describe('SshRelaySession', () => {
|
||||
const { getSshPtyProvider } = await import('../ipc/pty')
|
||||
const mockAttach = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2'])
|
||||
@@ -366,12 +368,60 @@ describe('SshRelaySession', () => {
|
||||
expect(mockAttach).toHaveBeenCalledWith('pty-2')
|
||||
})
|
||||
|
||||
it('forwards reconnect replay after the attach attempt is still current', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
await session.establish(mockConn)
|
||||
vi.clearAllMocks()
|
||||
mockDeploySuccess()
|
||||
|
||||
const { getSshPtyProvider } = await import('../ipc/pty')
|
||||
const mockAttach = vi.fn().mockResolvedValue({ replay: 'restored-output' })
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
|
||||
|
||||
await session.reconnect(mockConn)
|
||||
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', {
|
||||
id: 'ssh:target-1@@pty-1',
|
||||
data: 'restored-output'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops identical reconnect replay payloads inside one reconnect burst', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
await session.establish(mockConn)
|
||||
vi.clearAllMocks()
|
||||
mockDeploySuccess()
|
||||
|
||||
const { getSshPtyProvider } = await import('../ipc/pty')
|
||||
const mockAttach = vi.fn().mockResolvedValue({ replay: 'same-output' })
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
|
||||
|
||||
await session.reconnect(mockConn)
|
||||
await session.reconnect(mockConn)
|
||||
|
||||
const replaySends = vi
|
||||
.mocked(mockWindow.webContents.send)
|
||||
.mock.calls.filter(([channel]) => channel === 'pty:replay')
|
||||
expect(mockAttach).toHaveBeenCalledTimes(2)
|
||||
expect(replaySends).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('establish re-attaches owned PTYs after explicit disconnect', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const { getSshPtyProvider } = await import('../ipc/pty')
|
||||
const mockAttach = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1'])
|
||||
@@ -390,7 +440,7 @@ describe('SshRelaySession', () => {
|
||||
const { getSshPtyProvider } = await import('../ipc/pty')
|
||||
const mockAttach = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
@@ -419,7 +469,7 @@ describe('SshRelaySession', () => {
|
||||
})
|
||||
)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
|
||||
@@ -454,7 +504,7 @@ describe('SshRelaySession', () => {
|
||||
})
|
||||
)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1'])
|
||||
@@ -486,7 +536,7 @@ describe('SshRelaySession', () => {
|
||||
.mockRejectedValueOnce(new Error('PTY "pty-stale" not found'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-stale', 'pty-live'])
|
||||
@@ -515,7 +565,7 @@ describe('SshRelaySession', () => {
|
||||
const { getSshPtyProvider } = await import('../ipc/pty')
|
||||
const mockAttach = vi.fn().mockRejectedValue(new Error('Multiplexer disposed'))
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue({
|
||||
attach: mockAttach,
|
||||
attachForReconnect: mockAttach,
|
||||
dispose: vi.fn()
|
||||
} as unknown as ReturnType<typeof getSshPtyProvider>)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-live'])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user