mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-map-done-signal
# Conflicts: # src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts
This commit is contained in:
@@ -61,3 +61,67 @@ describe('terminal close CLI', () => {
|
||||
expect(help).toContain('durable persistence')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal split CLI', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends the old-host attribution bypass through the legacy-compatible env field', async () => {
|
||||
const call = vi.fn(async (method: string) =>
|
||||
method === 'status.get'
|
||||
? {
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}
|
||||
}
|
||||
: { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } }
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal split']({
|
||||
flags: new Map([
|
||||
['terminal', 'term-1'],
|
||||
['direction', 'horizontal']
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenNthCalledWith(1, 'status.get')
|
||||
expect(call).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'terminal.split',
|
||||
{
|
||||
terminal: 'term-1',
|
||||
direction: 'horizontal',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] }
|
||||
})
|
||||
|
||||
await expect(
|
||||
TERMINAL_HANDLERS['terminal split']({
|
||||
flags: new Map([['terminal', 'term-1']]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'runtime_update_required',
|
||||
message: expect.stringContaining('Update the host and try again')
|
||||
})
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalShow,
|
||||
RuntimeTerminalSplit,
|
||||
RuntimeTerminalWait
|
||||
RuntimeTerminalWait,
|
||||
RuntimeStatus
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
|
||||
@@ -37,6 +38,14 @@ import {
|
||||
getRequiredWorktreeSelector,
|
||||
getTerminalHandle
|
||||
} from '../selectors'
|
||||
import {
|
||||
addLegacyTerminalAttributionDisableRequest,
|
||||
hostSupportsTerminalCreateAttributionDisable,
|
||||
hostSupportsTerminalSplitAttributionDisable,
|
||||
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
|
||||
withLegacyTerminalAttributionDisabledEnv
|
||||
} from '../../shared/legacy-terminal-attribution-env'
|
||||
|
||||
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
|
||||
// timeout. Even without an explicit server timeout, the client must allow
|
||||
@@ -137,17 +146,30 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
const useRendererBackedInteractiveTerminal =
|
||||
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
|
||||
const focus = flags.get('focus') === true
|
||||
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
|
||||
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
|
||||
command,
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
// Why: interactive local agent TUIs need the renderer-backed terminal
|
||||
// path for browser-side features, but CLI creates must stay backgrounded
|
||||
// unless the caller explicitly asks for focus.
|
||||
focus,
|
||||
...(focus ? { presentation: 'focused' } : {}),
|
||||
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
|
||||
})
|
||||
const status = await client.call<RuntimeStatus>('status.get')
|
||||
if (!hostSupportsTerminalCreateAttributionDisable(status.result)) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_update_required',
|
||||
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
}
|
||||
const result = await client.call<{ terminal: RuntimeTerminalCreate }>(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
|
||||
command,
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
// Why: interactive local agent TUIs need the renderer-backed terminal
|
||||
// path for browser-side features, but CLI creates must stay backgrounded
|
||||
// unless the caller explicitly asks for focus.
|
||||
focus,
|
||||
...(focus ? { presentation: 'focused' } : {}),
|
||||
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
|
||||
},
|
||||
{ expectedRuntimeId: status.result.runtimeId }
|
||||
)
|
||||
printResult(result, json, formatTerminalCreate)
|
||||
},
|
||||
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
|
||||
@@ -168,11 +190,24 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
) {
|
||||
throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical')
|
||||
}
|
||||
const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
direction: directionFlag,
|
||||
command: getOptionalStringFlag(flags, 'command')
|
||||
})
|
||||
const status = await client.call<RuntimeStatus>('status.get')
|
||||
if (!hostSupportsTerminalSplitAttributionDisable(status.result)) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_update_required',
|
||||
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
|
||||
)
|
||||
}
|
||||
const result = await client.call<{ split: RuntimeTerminalSplit }>(
|
||||
'terminal.split',
|
||||
{
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
direction: directionFlag,
|
||||
command: getOptionalStringFlag(flags, 'command'),
|
||||
env: withLegacyTerminalAttributionDisabledEnv(undefined),
|
||||
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined)
|
||||
},
|
||||
{ expectedRuntimeId: status.result.runtimeId }
|
||||
)
|
||||
printResult(result, json, formatTerminalSplit)
|
||||
}
|
||||
}
|
||||
|
||||
+202
-82
@@ -3112,6 +3112,10 @@ describe('orca cli worktree awareness', () => {
|
||||
it('passes explicit focus through terminal.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3136,13 +3140,19 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: undefined,
|
||||
title: 'RUNNER',
|
||||
focus: true,
|
||||
presentation: 'focused'
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'RUNNER',
|
||||
focus: true,
|
||||
presentation: 'focused'
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('prints terminal.read fallback screen lines in json mode', async () => {
|
||||
@@ -3184,6 +3194,10 @@ describe('orca cli worktree awareness', () => {
|
||||
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3209,19 +3223,29 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3248,20 +3272,30 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: true,
|
||||
presentation: 'focused',
|
||||
rendererBacked: true,
|
||||
activate: true
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex',
|
||||
focus: true,
|
||||
presentation: 'focused',
|
||||
rendererBacked: true,
|
||||
activate: true
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3287,17 +3321,27 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex exec summarize',
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex exec summarize',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3323,17 +3367,27 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex review commands after long options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3359,17 +3413,27 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
|
||||
title: 'Codex review',
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex review',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex help commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3395,17 +3459,27 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --help',
|
||||
title: 'Codex help',
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --help',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex help',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3431,19 +3505,29 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 "fix the flaky test"',
|
||||
title: 'Codex prompt',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 "fix the flaky test"',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex prompt',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3469,19 +3553,29 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude',
|
||||
title: 'Claude',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Claude',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Claude print commands on the background terminal path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3507,12 +3601,18 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude -p "summarize"',
|
||||
title: 'Claude print',
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'claude -p "summarize"',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Claude print',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
|
||||
@@ -3819,6 +3919,10 @@ describe('orca cli worktree awareness', () => {
|
||||
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3842,12 +3946,18 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: undefined,
|
||||
title: undefined,
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: undefined,
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: undefined,
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('collects and formats memory diagnostics', async () => {
|
||||
@@ -3955,6 +4065,10 @@ describe('orca cli worktree awareness', () => {
|
||||
it('does not force remote Codex terminal creates through a local renderer path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_status', {
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: ['terminal.attribution-removed.v1']
|
||||
}),
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
@@ -3982,12 +4096,18 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: false
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.create',
|
||||
{
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: 'codex',
|
||||
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
|
||||
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
|
||||
title: 'Codex',
|
||||
focus: false
|
||||
},
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not resolve implicit remote browser targets from client cwd', async () => {
|
||||
|
||||
@@ -87,6 +87,7 @@ export class RuntimeClient {
|
||||
}
|
||||
: {}
|
||||
const envelope = {
|
||||
expectedRuntimeId: options?.expectedRuntimeId,
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
@@ -50,6 +50,48 @@ describe('runtime transport timeout validation', () => {
|
||||
// Why: these tests create Unix domain socket servers in temp directories.
|
||||
// Windows does not support Unix domain sockets in the same way.
|
||||
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
|
||||
it('forwards the expected runtime fence in local requests', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | null = null
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: receivedRequest.id,
|
||||
ok: true,
|
||||
result: { terminal: { handle: 'terminal-1' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
|
||||
await sendRequest(
|
||||
{
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 123,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
},
|
||||
'terminal.create',
|
||||
{ worktree: 'id:worktree-1' },
|
||||
1_000,
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
|
||||
expect(receivedRequest).toMatchObject({
|
||||
method: 'terminal.create',
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
|
||||
@@ -186,6 +186,7 @@ export async function sendRequest<TResult>(
|
||||
authToken: metadata.authToken,
|
||||
method,
|
||||
params,
|
||||
expectedRuntimeId: envelope?.expectedRuntimeId,
|
||||
orchestrationCapability: envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileStat } from '../providers/types'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
@@ -32,7 +32,7 @@ export async function statRemoteSessionFile(
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(options?.signal)
|
||||
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
recordSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent,
|
||||
path,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
const REMOTE_SCAN_ISSUE_LIMIT = 500
|
||||
|
||||
export function recordRemoteSessionScanIssue(
|
||||
issues: AiVaultScanIssue[],
|
||||
issue: AiVaultScanIssue
|
||||
): void {
|
||||
if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) {
|
||||
issues.push(issue)
|
||||
return
|
||||
}
|
||||
if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) {
|
||||
// Kinded: this row is a scan notice, not a skipped transcript — the panel
|
||||
// counts unkinded issues as skipped transcript files.
|
||||
issues.push({
|
||||
executionHostId: issue.executionHostId,
|
||||
agent: issue.agent,
|
||||
kind: 'notice',
|
||||
path: 'Agent Session History scan',
|
||||
message: 'Additional scan issues were omitted.'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
@@ -124,7 +124,7 @@ function recordRemoteDirectoryIssue(
|
||||
err: unknown
|
||||
): void {
|
||||
if (!isMissingRemoteSessionPathError(err)) {
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
recordSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent: source.agent,
|
||||
kind: 'host',
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createAntigravityWorkspaceResolver } from './session-scanner-antigravit
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
|
||||
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
|
||||
|
||||
@@ -205,7 +205,7 @@ async function scanRemoteInScopeSessions(args: {
|
||||
}
|
||||
|
||||
if (index < candidates.length && sessions.length < args.limit) {
|
||||
recordRemoteSessionScanIssue(args.issues, {
|
||||
recordSessionScanIssue(args.issues, {
|
||||
executionHostId: args.context.executionHostId,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
@@ -241,7 +241,7 @@ async function parseRemoteSessionCandidate(
|
||||
return session
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteSessionScanIssue(issues, {
|
||||
recordSessionScanIssue(issues, {
|
||||
executionHostId: context.executionHostId,
|
||||
agent: candidate.source.agent,
|
||||
path: candidate.file.path,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultFirstUserPromptResult,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
@@ -118,7 +118,9 @@ async function fileWithMtimeForPath(
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(filePath)
|
||||
// 'scan' matches the parser this feeds, so the two halves of one re-parse
|
||||
// share a lane instead of the stat jumping the live-transcript queue.
|
||||
const info = await wslGatedStat(filePath, 'scan')
|
||||
return {
|
||||
path: filePath,
|
||||
mtimeMs: info.mtimeMs,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
// Why: a stalled WSL distro or an unreachable remote host fails one probe per
|
||||
// discovered path, so an uncapped list ships one row per transcript file in the
|
||||
// tree over IPC. Local and remote scans share the bound — the failure shape is
|
||||
// the same and both feed the same panel.
|
||||
const SCAN_ISSUE_LIMIT = 500
|
||||
|
||||
/** Append a scan issue, collapsing everything past the cap into one notice. */
|
||||
export function recordSessionScanIssue(issues: AiVaultScanIssue[], issue: AiVaultScanIssue): void {
|
||||
if (issues.length < SCAN_ISSUE_LIMIT - 1) {
|
||||
issues.push(issue)
|
||||
return
|
||||
}
|
||||
if (issues.length === SCAN_ISSUE_LIMIT - 1) {
|
||||
// Kinded: this row is a scan notice, not a skipped transcript — the panel
|
||||
// counts unkinded issues as skipped transcript files. Spread first so a
|
||||
// remote issue keeps its executionHostId and a local one never gains an
|
||||
// explicit `undefined` (which would outrank the scanner's own stamping).
|
||||
issues.push({
|
||||
...issue,
|
||||
kind: 'notice',
|
||||
path: 'Agent Session History scan',
|
||||
message: 'Additional scan issues were omitted.'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values'
|
||||
|
||||
const HISTORY_MATCH_WINDOW_MS = 2_000
|
||||
|
||||
/**
|
||||
* The local-scan `readHistory`; remote scans inject their own transport. A
|
||||
* missing or unreadable history file is genuinely "no enrichment", but a gate
|
||||
* refusal must propagate so the caller records a scan issue — degrading it to
|
||||
* null lists the session with a missing cwd and no retry signal, and the
|
||||
* resolver's memo relies on the rejection to evict rather than pin a stall.
|
||||
*/
|
||||
export async function readLocalAntigravityHistory(path: string): Promise<string | null> {
|
||||
try {
|
||||
return await wslGatedReadFile(path, 'utf-8', 'scan')
|
||||
} catch (error) {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type AntigravityHistoryEntry = {
|
||||
timestampMs: number
|
||||
workspace: string
|
||||
@@ -26,8 +46,19 @@ export function createAntigravityWorkspaceResolver(
|
||||
}
|
||||
let index = indexes.get(historyPath)
|
||||
if (!index) {
|
||||
index = readHistory(historyPath).then(indexAntigravityHistory)
|
||||
indexes.set(historyPath, index)
|
||||
// Why: a read failure is transient (a stalled WSL distro refuses here),
|
||||
// so it must not be memoized — every later session under this history
|
||||
// file would inherit the rejection for the process lifetime.
|
||||
const pending: Promise<AntigravityHistoryIndex> = readHistory(historyPath)
|
||||
.then(indexAntigravityHistory)
|
||||
.catch((error: unknown) => {
|
||||
if (indexes.get(historyPath) === pending) {
|
||||
indexes.delete(historyPath)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
index = pending
|
||||
indexes.set(historyPath, pending)
|
||||
}
|
||||
const workspace = findAntigravityWorkspace(session, await index)
|
||||
return workspace ? { ...session, cwd: workspace } : session
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -26,11 +26,16 @@ export async function parseAntigravitySessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseAntigravitySessionLines({ file, lines, platform })
|
||||
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
return await parseAntigravitySessionLines({ file, lines, platform })
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-parse throw cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseAntigravitySessionContent(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type {
|
||||
@@ -8,6 +6,14 @@ import type {
|
||||
AiVaultSubagentListResult,
|
||||
AiVaultSubagentRunStatus
|
||||
} from '../../shared/ai-vault-types'
|
||||
import {
|
||||
openTranscriptReadStream,
|
||||
wslGatedReaddir,
|
||||
wslGatedReadFile,
|
||||
wslGatedStat
|
||||
} from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
|
||||
import {
|
||||
@@ -29,6 +35,10 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000
|
||||
// Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY):
|
||||
// opening every subagent read stream at once stalls over SSH/WSL UNC paths.
|
||||
const SUBAGENT_PARSE_CONCURRENCY = 8
|
||||
// 'scan', not 'exact', even though a user expands this on demand: it is bulk
|
||||
// directory work feeding the same parsers as the main scan, and the exact lane
|
||||
// stays reserved for live transcript probes.
|
||||
const SUBAGENT_FS_PRIORITY = 'scan'
|
||||
|
||||
const TASK_NOTIFICATION_MARKER = '<task-notification>'
|
||||
const TOOL_USE_RESULT_MARKER = '"toolUseResult"'
|
||||
@@ -71,8 +81,13 @@ export async function listClaudeSubagentSessions(args: {
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(subagentsDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = await wslGatedReaddir(subagentsDir, SUBAGENT_FS_PRIORITY)
|
||||
} catch (err) {
|
||||
// A gate refusal is a stalled distro, not a session without subagents —
|
||||
// report it so the panel offers a retry instead of showing an empty list.
|
||||
if (err instanceof WslTranscriptFsError) {
|
||||
recordSessionScanIssue(issues, { agent: 'claude', path: subagentsDir, message: err.message })
|
||||
}
|
||||
return { sessions: [], issues }
|
||||
}
|
||||
|
||||
@@ -130,7 +145,7 @@ async function parseSubagentTranscript(args: {
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
const fileStat = await stat(args.filePath)
|
||||
const fileStat = await wslGatedStat(args.filePath, SUBAGENT_FS_PRIORITY)
|
||||
const session = await parseClaudeSessionFile(
|
||||
{
|
||||
path: args.filePath,
|
||||
@@ -161,7 +176,11 @@ async function parseSubagentTranscript(args: {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) })
|
||||
recordSessionScanIssue(args.issues, {
|
||||
agent: 'claude',
|
||||
path: args.filePath,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -187,11 +206,13 @@ function resolveSubagentStatus(args: {
|
||||
// 'async_launched', notification 'running') are superseded by terminal ones.
|
||||
async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<string, string>> {
|
||||
const statuses = new Map<string, string>()
|
||||
const input = openTranscriptReadStream(
|
||||
parentFilePath,
|
||||
{ encoding: 'utf-8' },
|
||||
SUBAGENT_FS_PRIORITY
|
||||
)
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(parentFilePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const hasNotification = line.includes(TASK_NOTIFICATION_MARKER)
|
||||
const hasTaskResult =
|
||||
@@ -232,6 +253,11 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<
|
||||
}
|
||||
} catch {
|
||||
// A missing/unreadable parent transcript degrades to recency-only status.
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-read failure cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
@@ -269,7 +295,8 @@ function taskNotificationBlockText(block: unknown): string {
|
||||
async function readSubagentMeta(transcriptPath: string): Promise<ClaudeSubagentMeta> {
|
||||
const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json`
|
||||
try {
|
||||
const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown)
|
||||
const raw = await wslGatedReadFile(metaPath, 'utf-8', SUBAGENT_FS_PRIORITY)
|
||||
const record = asRecord(JSON.parse(raw) as unknown)
|
||||
return {
|
||||
description: normalizeTitleText(extractString(record?.description) ?? ''),
|
||||
agentType: extractString(record?.agentType)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
|
||||
@@ -42,7 +42,7 @@ export async function parseCodexSessionFile(
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
// Codex names threads lazily in <CODEX_HOME>/session_index.jsonl; transcripts
|
||||
@@ -77,7 +77,7 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<strin
|
||||
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
let signature: string
|
||||
try {
|
||||
const indexStat = await stat(indexPath)
|
||||
const indexStat = await wslGatedStat(indexPath, 'scan')
|
||||
signature = `${indexStat.size}:${indexStat.mtimeMs}`
|
||||
} catch {
|
||||
return new Map()
|
||||
@@ -88,10 +88,14 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<strin
|
||||
return cachedTitles
|
||||
}
|
||||
|
||||
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
|
||||
signature,
|
||||
titles
|
||||
}))
|
||||
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then(({ titles, refused }) => {
|
||||
// Why: a gate refusal is a stalled distro, not a title-less index — caching
|
||||
// it would pin "no titles" until the index's size or mtime changes.
|
||||
if (refused && codexSessionIndexTitleCache.get(codexHome) === pending) {
|
||||
codexSessionIndexTitleCache.delete(codexHome)
|
||||
}
|
||||
return { signature, titles }
|
||||
})
|
||||
storeCodexSessionIndexTitleCacheEntry(codexHome, pending)
|
||||
return (await pending).titles
|
||||
}
|
||||
@@ -133,11 +137,11 @@ function storeCodexSessionIndexTitleCacheEntry(
|
||||
|
||||
async function readCodexSessionIndexTitlesFromDisk(
|
||||
indexPath: string
|
||||
): Promise<Map<string, string>> {
|
||||
): Promise<{ titles: Map<string, string>; refused: boolean }> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(indexPath, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
@@ -151,8 +155,9 @@ async function readCodexSessionIndexTitlesFromDisk(
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Codex creates the index opportunistically; older homes may only have raw transcripts.
|
||||
return { titles: titleBySessionId, refused: error instanceof WslTranscriptFsError }
|
||||
}
|
||||
return titleBySessionId
|
||||
return { titles: titleBySessionId, refused: false }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -35,7 +35,7 @@ export async function parseCopilotSessionFile(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCopilotSessionLines({ file, lines, platform })
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type * as NodePathModule from 'node:path'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
open: mocks.open,
|
||||
readFile: mocks.readFile,
|
||||
readdir: mocks.readdir,
|
||||
stat: mocks.stat
|
||||
}))
|
||||
|
||||
// Derived sibling paths must retain Windows separators in this cross-platform test.
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodePathModule>()
|
||||
return { ...actual.win32, default: actual.win32 }
|
||||
})
|
||||
|
||||
import {
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests
|
||||
} from './session-scanner-parse-cache'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
function uncPath(distro: string, ...segments: string[]): string {
|
||||
return `\\\\wsl.localhost\\${distro}\\home\\ada\\${segments.join('\\')}`
|
||||
}
|
||||
|
||||
const KIMI_HOME = ['.kimi-code', 'sessions', 'wd_app_9f2', 'session_abc']
|
||||
const GROK_DIR = ['.grok', 'sessions', 'ses-1']
|
||||
const OPENCODE_ROOT = ['.local', 'share', 'opencode', 'storage']
|
||||
|
||||
const KIMI_STATE = JSON.stringify({
|
||||
title: 'Kimi session',
|
||||
createdAt: '2026-06-01T10:00:00.000Z',
|
||||
updatedAt: '2026-06-01T10:05:00.000Z',
|
||||
agents: { main: { type: 'main', parentAgentId: null } }
|
||||
})
|
||||
|
||||
const KIMI_WIRE = `${JSON.stringify({
|
||||
type: 'context.append_message',
|
||||
message: { role: 'user', origin: { kind: 'user' }, content: 'ship it' }
|
||||
})}\n`
|
||||
|
||||
const GROK_SESSION = JSON.stringify({
|
||||
info: { id: 'ses-1', cwd: '/repo/app' },
|
||||
generated_title: 'Grok session',
|
||||
created_at: '2026-06-01T10:00:00.000Z'
|
||||
})
|
||||
|
||||
const GROK_HISTORY = `${JSON.stringify({
|
||||
type: 'user',
|
||||
content: '<user_query>ship it</user_query>'
|
||||
})}\n`
|
||||
|
||||
const OPENCODE_SESSION = JSON.stringify({
|
||||
id: 'ses-1',
|
||||
title: 'OpenCode session',
|
||||
directory: '/repo/app',
|
||||
time: { created: 1780000000000 }
|
||||
})
|
||||
|
||||
const OPENCODE_MESSAGE = JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'ship it' }],
|
||||
time: { created: 1780000000000 }
|
||||
})
|
||||
|
||||
type ReadResult = { bytesRead: number; buffer: Buffer }
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls<T>(): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) } as T)
|
||||
})
|
||||
}
|
||||
|
||||
function stallingHandle() {
|
||||
return { read: vi.fn(stalls<ReadResult>), close: vi.fn(async () => {}) }
|
||||
}
|
||||
|
||||
function servingHandle(body: string) {
|
||||
const bytes = Buffer.from(body)
|
||||
return {
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const slice = bytes.subarray(position, Math.min(position + length, bytes.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
function missing(): Error {
|
||||
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
function dirent(name: string) {
|
||||
return { name, isFile: () => true }
|
||||
}
|
||||
|
||||
function candidate(agent: SessionFileCandidate['agent'], path: string): SessionFileCandidate {
|
||||
return {
|
||||
agent,
|
||||
file: {
|
||||
path,
|
||||
mtimeMs: 1,
|
||||
modifiedAt: '2026-06-01T10:05:00.000Z',
|
||||
sizeBytes: 128
|
||||
},
|
||||
codexHome: null
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRefusal(target: SessionFileCandidate): Promise<void> {
|
||||
const refusal = expect(parseAgentSessionFileCached(target, 'linux')).rejects.toBeInstanceOf(
|
||||
WslTranscriptFsError
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
await refusal
|
||||
}
|
||||
|
||||
async function releaseAndSettle(): Promise<void> {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSessionParseCacheForTests()
|
||||
mocks.open.mockReset()
|
||||
mocks.readFile.mockReset()
|
||||
mocks.readdir.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
releaseStall = undefined
|
||||
mocks.stat.mockRejectedValue(missing())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await releaseAndSettle()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Kimi session parse against a stalled WSL transcript', () => {
|
||||
it('refuses a stalled state.json read instead of reporting no session', async () => {
|
||||
const state = uncPath('KimiState', ...KIMI_HOME, 'state.json')
|
||||
mocks.readFile.mockImplementation(stalls<string>)
|
||||
|
||||
await expectRefusal(candidate('kimi', state))
|
||||
})
|
||||
|
||||
it('refuses a stalled wire read, then caches the full session once it recovers', async () => {
|
||||
const state = uncPath('KimiWire', ...KIMI_HOME, 'state.json')
|
||||
mocks.readFile.mockResolvedValue(KIMI_STATE)
|
||||
mocks.open.mockResolvedValue(stallingHandle())
|
||||
|
||||
await expectRefusal(candidate('kimi', state))
|
||||
await releaseAndSettle()
|
||||
|
||||
mocks.open.mockResolvedValue(servingHandle(KIMI_WIRE))
|
||||
const recovered = await parseAgentSessionFileCached(candidate('kimi', state), 'linux')
|
||||
|
||||
expect(recovered?.messageCount).toBe(1)
|
||||
})
|
||||
|
||||
// Pins the narrowness of the parser's `instanceof WslTranscriptFsError`
|
||||
// rethrow: widening it to every error turns this into a hard scan issue.
|
||||
it('still returns a metadata-only session when the wire file is simply missing', async () => {
|
||||
const state = uncPath('KimiMissing', ...KIMI_HOME, 'state.json')
|
||||
mocks.readFile.mockResolvedValue(KIMI_STATE)
|
||||
mocks.open.mockRejectedValue(missing())
|
||||
|
||||
const session = await parseAgentSessionFileCached(candidate('kimi', state), 'linux')
|
||||
|
||||
expect(session).toMatchObject({ agent: 'kimi', title: 'Kimi session', messageCount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Grok session parse against a stalled WSL transcript', () => {
|
||||
it('refuses a stalled chat_history read instead of reporting a summary-only session', async () => {
|
||||
const file = uncPath('GrokHistory', ...GROK_DIR, 'session.json')
|
||||
mocks.readFile.mockResolvedValue(GROK_SESSION)
|
||||
mocks.open.mockResolvedValue(stallingHandle())
|
||||
|
||||
await expectRefusal(candidate('grok', file))
|
||||
await releaseAndSettle()
|
||||
|
||||
mocks.open.mockResolvedValue(servingHandle(GROK_HISTORY))
|
||||
const recovered = await parseAgentSessionFileCached(candidate('grok', file), 'linux')
|
||||
|
||||
expect(recovered?.previewMessages).toEqual([
|
||||
expect.objectContaining({ role: 'user', text: 'ship it' })
|
||||
])
|
||||
})
|
||||
|
||||
it('still returns the summary-only session when chat_history is missing', async () => {
|
||||
const file = uncPath('GrokMissing', ...GROK_DIR, 'session.json')
|
||||
mocks.readFile.mockResolvedValue(GROK_SESSION)
|
||||
mocks.open.mockRejectedValue(missing())
|
||||
|
||||
const session = await parseAgentSessionFileCached(candidate('grok', file), 'linux')
|
||||
|
||||
expect(session).toMatchObject({
|
||||
agent: 'grok',
|
||||
title: 'Grok session',
|
||||
previewMessages: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenCode session parse against a stalled WSL transcript', () => {
|
||||
function sessionPath(distro: string): string {
|
||||
return uncPath(distro, ...OPENCODE_ROOT, 'session', 'prj', 'ses-1.json')
|
||||
}
|
||||
|
||||
it('refuses a stalled message-directory listing instead of an empty transcript', async () => {
|
||||
mocks.readFile.mockResolvedValue(OPENCODE_SESSION)
|
||||
mocks.readdir.mockImplementation(stalls<unknown[]>)
|
||||
|
||||
await expectRefusal(candidate('opencode', sessionPath('OpenCodeDir')))
|
||||
})
|
||||
|
||||
it('refuses a stalled per-message read instead of a partial transcript', async () => {
|
||||
const file = sessionPath('OpenCodeMessage')
|
||||
mocks.readdir.mockResolvedValue([dirent('msg-1.json')])
|
||||
mocks.readFile.mockImplementation((path: string) =>
|
||||
path === file ? Promise.resolve(OPENCODE_SESSION) : stalls<string>()
|
||||
)
|
||||
|
||||
await expectRefusal(candidate('opencode', file))
|
||||
await releaseAndSettle()
|
||||
|
||||
mocks.readFile.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === file ? OPENCODE_SESSION : OPENCODE_MESSAGE)
|
||||
)
|
||||
const recovered = await parseAgentSessionFileCached(candidate('opencode', file), 'linux')
|
||||
|
||||
expect(recovered?.messageCount).toBe(1)
|
||||
})
|
||||
|
||||
// Same guard as Kimi's: a live process mid-write must stay a parseable
|
||||
// session, not a refusal.
|
||||
it('still returns the session when a live process left a half-written message', async () => {
|
||||
const file = sessionPath('OpenCodeHalfWritten')
|
||||
mocks.readdir.mockResolvedValue([dirent('msg-1.json')])
|
||||
mocks.readFile.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === file ? OPENCODE_SESSION : '{"role":"user",')
|
||||
)
|
||||
|
||||
const session = await parseAgentSessionFileCached(candidate('opencode', file), 'linux')
|
||||
|
||||
expect(session).toMatchObject({ agent: 'opencode', title: 'OpenCode session', messageCount: 0 })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -33,7 +33,7 @@ export async function parseCursorSessionFile(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCursorSessionLines({ file, lines, platform })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
@@ -27,7 +27,11 @@ export async function parseDevinSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
return parseDevinSessionContent(
|
||||
file,
|
||||
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
export function parseDevinSessionContent(
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { walkSessionFiles } from './session-scanner-discovery'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
readdir: fsMocks.readdir,
|
||||
stat: fsMocks.stat
|
||||
}))
|
||||
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { discoverFiles, walkSessionFiles } from './session-scanner-discovery'
|
||||
|
||||
const SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
function dirent(name: string): Dirent {
|
||||
return { name, isDirectory: () => false, isFile: () => true } as Dirent
|
||||
}
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls<T>(): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
releaseStall = () => resolve(undefined as T)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fsMocks.readdir.mockReset()
|
||||
fsMocks.stat.mockReset()
|
||||
fsMocks.readdir.mockResolvedValue([])
|
||||
releaseStall = undefined
|
||||
})
|
||||
|
||||
// A stalled task holds its gate permit until the call settles; releasing it
|
||||
// keeps the next case from fast-failing on an already-exhausted gate.
|
||||
afterEach(async () => {
|
||||
if (!vi.isFakeTimers()) {
|
||||
return
|
||||
}
|
||||
releaseStall?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('walkSessionFiles WSL gate refusals', () => {
|
||||
it('rethrows gate refusals instead of reporting an empty tree', async () => {
|
||||
@@ -26,3 +74,64 @@ describe('walkSessionFiles WSL gate refusals', () => {
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discoverFiles containment for a stalled WSL root', () => {
|
||||
const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions'
|
||||
|
||||
it('contains a stalled readdir to one root so a Promise.all fan-out still resolves', async () => {
|
||||
vi.useFakeTimers()
|
||||
{
|
||||
fsMocks.readdir.mockImplementation((dir: string) =>
|
||||
dir === UNC_ROOT ? stalls<Dirent[]>() : Promise.resolve([])
|
||||
)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discovery = discoverFiles({
|
||||
rootDir: UNC_ROOT,
|
||||
limit: 10,
|
||||
agent: 'codex',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
})
|
||||
const healthy = discoverFiles({
|
||||
rootDir: '/home/ada/.codex/sessions',
|
||||
limit: 10,
|
||||
agent: 'codex',
|
||||
issues: [],
|
||||
extensions: ['.jsonl']
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
|
||||
await expect(Promise.all([discovery, healthy])).resolves.toEqual([
|
||||
{ agent: 'codex', rootDir: UNC_ROOT, files: [] },
|
||||
{ agent: 'codex', rootDir: '/home/ada/.codex/sessions', files: [] }
|
||||
])
|
||||
expect(issues).toEqual([{ agent: 'codex', path: UNC_ROOT, message: SLOW_MESSAGE }])
|
||||
}
|
||||
})
|
||||
|
||||
it('reports every stalled file without spending one deadline per file', async () => {
|
||||
vi.useFakeTimers()
|
||||
{
|
||||
const names = ['a', 'b', 'c', 'd', 'e'].map((name) => dirent(`${name}.jsonl`))
|
||||
fsMocks.readdir.mockResolvedValue(names)
|
||||
fsMocks.stat.mockImplementation(stalls)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discovery = discoverFiles({
|
||||
rootDir: UNC_ROOT,
|
||||
limit: 10,
|
||||
agent: 'codex',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
})
|
||||
|
||||
// Two deadlines total, not five: once the first stalled stat is marked
|
||||
// stuck, every later file fast-fails on the route instead of queueing.
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
|
||||
|
||||
await expect(discovery).resolves.toEqual({ agent: 'codex', rootDir: UNC_ROOT, files: [] })
|
||||
expect(issues).toHaveLength(names.length)
|
||||
expect(issues.every((issue) => issue.path.startsWith(UNC_ROOT))).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
@@ -15,15 +16,31 @@ export async function discoverFiles(args: {
|
||||
filePredicate?: (path: string) => boolean
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
|
||||
extensions: new Set(args.extensions),
|
||||
filePredicate: args.filePredicate,
|
||||
directoryPredicate: args.directoryPredicate
|
||||
})
|
||||
let paths: string[]
|
||||
try {
|
||||
paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
|
||||
extensions: new Set(args.extensions),
|
||||
filePredicate: args.filePredicate,
|
||||
directoryPredicate: args.directoryPredicate
|
||||
})
|
||||
} catch (err) {
|
||||
// Why: discoverAiVaultSessionSources fans out with Promise.all, so one
|
||||
// stalled distro would otherwise reject the whole vault scan — including
|
||||
// every healthy local agent. Contain it to this root.
|
||||
if (!(err instanceof WslTranscriptFsError)) {
|
||||
throw err
|
||||
}
|
||||
recordSessionScanIssue(args.issues, {
|
||||
agent: args.agent,
|
||||
path: args.rootDir,
|
||||
message: err.message
|
||||
})
|
||||
return { agent: args.agent, rootDir: args.rootDir, files: [] }
|
||||
}
|
||||
const files: FileWithMtime[] = []
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const fileStat = await stat(path)
|
||||
const fileStat = await wslGatedStat(path, 'scan')
|
||||
files.push({
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
@@ -34,7 +51,11 @@ export async function discoverFiles(args: {
|
||||
nlink: fileStat.nlink
|
||||
})
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
|
||||
recordSessionScanIssue(args.issues, {
|
||||
agent: args.agent,
|
||||
path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -64,7 +85,7 @@ export async function walkSessionFiles(
|
||||
try {
|
||||
entries = options.readDirectory
|
||||
? await options.readDirectory(dirPath)
|
||||
: await readdir(dirPath, { withFileTypes: true })
|
||||
: await wslGatedReaddir(dirPath, 'scan', options.signal)
|
||||
} catch (error) {
|
||||
options.signal?.throwIfAborted()
|
||||
// Why: a gate refusal means the scan could not run, not that the tree is
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -34,11 +34,16 @@ export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseDroidSessionLines({ file, lines, platform })
|
||||
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
return await parseDroidSessionLines({ file, lines, platform })
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-parse throw cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseDroidSessionContent(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const AI_VAULT_DIR = join(import.meta.dirname, '.')
|
||||
const NATIVE_CHAT_DIR = join(import.meta.dirname, '..', 'native-chat')
|
||||
|
||||
// Transcript I/O in these modules must go through the WSL admission gate, so a
|
||||
// stalled 9P mount cannot hang a scan or a Native Chat load (STA-4049).
|
||||
const NATIVE_CHAT_MODULES = [
|
||||
'transcript-read-cache.ts',
|
||||
'transcript-reader.ts',
|
||||
'transcript-tail-reader.ts',
|
||||
'transcript-file-version.ts',
|
||||
'transcript-incremental-reader.ts',
|
||||
'transcript-watch-engine.ts'
|
||||
]
|
||||
|
||||
// Adding to this list must be a reviewed act: each entry names why the file
|
||||
// touches a path that can never be a WSL UNC transcript.
|
||||
const ALLOWLIST = new Set([
|
||||
// `existsSync` on bundled module paths inside the app dir.
|
||||
'session-scanner-service-entry-path.ts',
|
||||
'session-scanner-service-spawn.ts',
|
||||
'session-scanner-worker-spawn.ts',
|
||||
'session-scanner-opencode-sqlite-worker-spawn.ts',
|
||||
// On-demand IPC readers, gated in the STA-4049 follow-up.
|
||||
'session-scanner-claude-subagents.ts',
|
||||
'session-scanner-omp-subagent-listing.ts',
|
||||
// Test-only fixture builder.
|
||||
'session-scanner-test-fixtures.ts'
|
||||
])
|
||||
|
||||
const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g
|
||||
|
||||
function valueImportsOfNodeFs(source: string): string[] {
|
||||
return [...source.matchAll(FS_IMPORT)]
|
||||
.map((match) => match[1]!.trim())
|
||||
.filter((clause) => !clause.startsWith('type'))
|
||||
}
|
||||
|
||||
async function guardedFiles(): Promise<{ name: string; path: string }[]> {
|
||||
const aiVault = (await readdir(AI_VAULT_DIR))
|
||||
.filter((name) => name.startsWith('session-scanner') || name === 'session-title-file-reader.ts')
|
||||
.filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
|
||||
.filter((name) => !ALLOWLIST.has(name))
|
||||
.map((name) => ({ name, path: join(AI_VAULT_DIR, name) }))
|
||||
return [
|
||||
...aiVault,
|
||||
...NATIVE_CHAT_MODULES.map((name) => ({ name, path: join(NATIVE_CHAT_DIR, name) }))
|
||||
]
|
||||
}
|
||||
|
||||
describe('WSL transcript gate import guard', () => {
|
||||
it('leaves no raw node:fs value import in a gated transcript module', async () => {
|
||||
const files = await guardedFiles()
|
||||
const offenders: string[] = []
|
||||
for (const file of files) {
|
||||
for (const clause of valueImportsOfNodeFs(await readFile(file.path, 'utf-8'))) {
|
||||
offenders.push(`${file.name}: import ${clause} from 'node:fs…'`)
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('actually covers the modules it claims to', async () => {
|
||||
const names = (await guardedFiles()).map((file) => file.name)
|
||||
expect(names).toEqual(expect.arrayContaining(NATIVE_CHAT_MODULES))
|
||||
// The V12–V17 sites the previous `*parser*` wording silently skipped.
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'session-title-file-reader.ts',
|
||||
'session-scanner.ts',
|
||||
'session-scanner-values.ts',
|
||||
'session-scanner-codex-title-index.ts',
|
||||
'session-scanner-kimi-paths.ts',
|
||||
'session-scanner-opencode-sources.ts'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
@@ -34,7 +33,11 @@ export async function parseGeminiSessionFile(
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
return parseGeminiJsonSessionContent(
|
||||
file,
|
||||
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionContent(
|
||||
@@ -83,7 +86,7 @@ export async function parseGeminiJsonlSessionFile(
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
@@ -41,7 +40,9 @@ export async function parseRovoSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
const metadata = asRecord(
|
||||
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
|
||||
)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
@@ -175,11 +176,16 @@ export async function parseMessageGraphSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseMessageGraphSessionLines({ agent, file, lines, platform })
|
||||
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
return await parseMessageGraphSessionLines({ agent, file, lines, platform })
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-parse throw cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseMessageGraphSessionContent(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
@@ -36,7 +36,7 @@ export async function parseGrokSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -62,12 +62,13 @@ async function consumeGrokChatHistory(
|
||||
accumulator: SessionAccumulator,
|
||||
sessionDir: string
|
||||
): Promise<void> {
|
||||
const input = openTranscriptReadStream(
|
||||
join(sessionDir, 'chat_history.jsonl'),
|
||||
{ encoding: 'utf-8' },
|
||||
'scan'
|
||||
)
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
@@ -111,8 +112,18 @@ async function consumeGrokChatHistory(
|
||||
seedFirstUserPrompt: false
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Summary-only sessions still provide enough metadata for the Vault list.
|
||||
} catch (error) {
|
||||
// Summary-only sessions still provide enough metadata for the Vault list. A
|
||||
// gate refusal is a different thing — the history exists but is unreachable,
|
||||
// so a message-less session must not be cached under the summary's mtime.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-read failure cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
@@ -26,7 +26,11 @@ export async function parseHermesSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
return parseHermesSessionContent(
|
||||
file,
|
||||
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
export async function parseHermesSessionContent(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const CODEX_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex'
|
||||
const CODEX_SESSION_FILE = `${CODEX_HOME}\\sessions\\2026\\01\\01\\rollout-1.jsonl`
|
||||
const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const KIMI_INDEX = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.kimi\\session_index.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ stat: vi.fn(), open: vi.fn(), readFile: vi.fn() }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
stat: mocks.stat,
|
||||
open: mocks.open,
|
||||
readFile: mocks.readFile
|
||||
}))
|
||||
|
||||
import {
|
||||
readCodexSessionIndexTitle,
|
||||
resetCodexSessionIndexTitleCacheForTests,
|
||||
_hasCodexSessionIndexTitleCacheEntryForTest
|
||||
} from './session-scanner-codex-title-index'
|
||||
import {
|
||||
clearKimiSessionIndexCache,
|
||||
hasKimiSessionIndexCacheEntryForTests,
|
||||
readKimiWorkDirBySessionId
|
||||
} from './session-scanner-kimi-paths'
|
||||
import { readJsonObjectIfExists } from './session-scanner-values'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
// Identity must not change between the refused and the recovered read, or the
|
||||
// cache would miss for that reason instead of because the entry was evicted.
|
||||
const INDEX_STATS = { size: 128, mtimeMs: 7, ctimeMs: 7 }
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls(): Promise<never> {
|
||||
return new Promise<never>((resolve) => {
|
||||
releaseStall = () => resolve(undefined as never)
|
||||
})
|
||||
}
|
||||
|
||||
function servingHandle(body: string) {
|
||||
const bytes = Buffer.from(body)
|
||||
return {
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const slice = bytes.subarray(position, Math.min(position + length, bytes.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseAndSettle(): Promise<void> {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetCodexSessionIndexTitleCacheForTests()
|
||||
clearKimiSessionIndexCache()
|
||||
mocks.stat.mockReset()
|
||||
mocks.open.mockReset()
|
||||
mocks.readFile.mockReset()
|
||||
mocks.stat.mockResolvedValue(INDEX_STATS)
|
||||
releaseStall = undefined
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await releaseAndSettle()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('memoized WSL session indexes under a stalled mount', () => {
|
||||
it('evicts the Codex title index instead of pinning "no titles"', async () => {
|
||||
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
|
||||
const refused = readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
|
||||
expect(await refused).toBeNull()
|
||||
expect(_hasCodexSessionIndexTitleCacheEntryForTest(CODEX_HOME)).toBe(false)
|
||||
|
||||
await releaseAndSettle()
|
||||
mocks.open.mockResolvedValue(
|
||||
servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Ship the gate' })}\n`)
|
||||
)
|
||||
|
||||
// Same size/mtime as the refused read: only the eviction can produce a hit.
|
||||
expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe(
|
||||
'Ship the gate'
|
||||
)
|
||||
})
|
||||
|
||||
it('evicts the Kimi work-dir index instead of pinning "no cwd"', async () => {
|
||||
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
|
||||
const refused = readKimiWorkDirBySessionId(KIMI_INDEX)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
|
||||
expect(await refused).toEqual(new Map())
|
||||
expect(hasKimiSessionIndexCacheEntryForTests(KIMI_INDEX)).toBe(false)
|
||||
|
||||
await releaseAndSettle()
|
||||
mocks.open.mockResolvedValue(
|
||||
servingHandle(`${JSON.stringify({ sessionId: SESSION_ID, workDir: '/repo/app' })}\n`)
|
||||
)
|
||||
|
||||
expect(await readKimiWorkDirBySessionId(KIMI_INDEX)).toEqual(
|
||||
new Map([[SESSION_ID, '/repo/app']])
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a refused optional JSON enrichment instead of caching it as absent', async () => {
|
||||
mocks.readFile.mockImplementation(stalls)
|
||||
const pending = readJsonObjectIfExists(`${CODEX_HOME}\\history.json`).catch(
|
||||
(error: unknown) => error
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
|
||||
// A throw, not `null`: `parseSessionCandidate` turns it into a scan issue,
|
||||
// where `null` would cache an un-enriched session that never re-reads.
|
||||
expect(await pending).toBeInstanceOf(WslTranscriptFsError)
|
||||
})
|
||||
|
||||
it('still degrades a missing optional JSON file to null', async () => {
|
||||
mocks.readFile.mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' as const })
|
||||
)
|
||||
await expect(readJsonObjectIfExists(`${CODEX_HOME}\\missing.json`)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
@@ -36,8 +36,16 @@ export async function parseKimiSessionFile(
|
||||
): Promise<AiVaultSession | null> {
|
||||
let stateRecord: Record<string, unknown> | null
|
||||
try {
|
||||
stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
} catch {
|
||||
stateRecord = asRecord(
|
||||
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: a missing/half-written state file is genuinely "no session", but a
|
||||
// gate refusal is not — returning null would let the parse cache store that
|
||||
// degraded answer under the file's unchanged mtime and never retry.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!stateRecord) {
|
||||
@@ -83,11 +91,9 @@ async function consumeKimiWireTranscript(
|
||||
}
|
||||
}
|
||||
|
||||
const input = openTranscriptReadStream(wirePath, { encoding: 'utf-8' }, 'scan')
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(wirePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
@@ -111,9 +117,18 @@ async function consumeKimiWireTranscript(
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// No transcript yet (session created but never ran a turn) — metadata-only
|
||||
// sessions still belong in the panel.
|
||||
// sessions still belong in the panel. A gate refusal instead means the wire
|
||||
// bytes exist but are unreachable; a partial session must not be cached.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-read failure cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
flushAssistant()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { asRecord, extractString } from './session-scanner-values'
|
||||
import {
|
||||
KimiSessionIndexCache,
|
||||
@@ -78,9 +78,9 @@ export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolea
|
||||
|
||||
export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map<string, string>> {
|
||||
const generation = workDirCacheByIndexPath.beginRead()
|
||||
let identity: Awaited<ReturnType<typeof stat>>
|
||||
let identity: Awaited<ReturnType<typeof wslGatedStat>>
|
||||
try {
|
||||
identity = await stat(indexPath)
|
||||
identity = await wslGatedStat(indexPath, 'scan')
|
||||
} catch {
|
||||
// Missing index (e.g. user deleted it): sessions still list, just without cwd.
|
||||
workDirCacheByIndexPath.delete(indexPath, generation)
|
||||
@@ -95,18 +95,30 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map
|
||||
sizeBytes: identity.size
|
||||
},
|
||||
generation,
|
||||
() => parseKimiSessionIndex(indexPath)
|
||||
() =>
|
||||
parseKimiSessionIndex(indexPath).then(({ map, refused }) => {
|
||||
// Why: a gate refusal is a stalled distro, not a cwd-less index — evict
|
||||
// so the partial map is not served until the index's identity changes.
|
||||
// Inside the loader, not at the call site: only the caller that missed
|
||||
// runs this, so a concurrent hit would otherwise serve and keep it.
|
||||
if (refused) {
|
||||
workDirCacheByIndexPath.delete(indexPath, generation)
|
||||
}
|
||||
return map
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, string>> {
|
||||
async function parseKimiSessionIndex(
|
||||
indexPath: string
|
||||
): Promise<{ map: Map<string, string>; refused: boolean }> {
|
||||
const map = new Map<string, string>()
|
||||
// Why: never reject. This promise is memoized and shared by every session
|
||||
// under one Kimi home; a mid-read failure (file deleted after stat, EACCES)
|
||||
// must degrade to whatever was parsed so the other sessions still list.
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(indexPath, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
@@ -126,8 +138,9 @@ async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, str
|
||||
map.set(sessionId, workDir)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Return the partial map gathered before the read error.
|
||||
return { map, refused: error instanceof WslTranscriptFsError }
|
||||
}
|
||||
return map
|
||||
return { map, refused: false }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import type {
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession,
|
||||
AiVaultSubagentListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
@@ -16,6 +18,8 @@ import { errorMessage } from './session-scanner-values'
|
||||
// Match the Claude subagent lister's deliberate parse batching: opening every
|
||||
// read stream at once stalls over WSL UNC paths.
|
||||
const OMP_SUBAGENT_PARSE_CONCURRENCY = 8
|
||||
// Bulk directory work, so 'scan' — same reasoning as the Claude lister.
|
||||
const OMP_SUBAGENT_FS_PRIORITY = 'scan'
|
||||
|
||||
/**
|
||||
* List the task-subagent transcripts of one OMP session, on demand. The main
|
||||
@@ -35,8 +39,13 @@ export async function listOmpSubagentSessions(args: {
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(artifactDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = await wslGatedReaddir(artifactDir, OMP_SUBAGENT_FS_PRIORITY)
|
||||
} catch (err) {
|
||||
// A gate refusal is a stalled distro, not a session without subagents —
|
||||
// report it so the panel offers a retry instead of showing an empty list.
|
||||
if (err instanceof WslTranscriptFsError) {
|
||||
recordSessionScanIssue(issues, { agent: 'omp', path: artifactDir, message: err.message })
|
||||
}
|
||||
return { sessions: [], issues }
|
||||
}
|
||||
|
||||
@@ -79,7 +88,7 @@ async function parseOmpSubagentTranscript(args: {
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const filePath = join(args.artifactDir, args.name)
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
const fileStat = await wslGatedStat(filePath, OMP_SUBAGENT_FS_PRIORITY)
|
||||
// The shared OMP parser decorates every parse with an artifact-dir count, so
|
||||
// a child row carries its own grandchild count. It is accurate but has no
|
||||
// renderer — subagent rows don't expand — and this lister is local-only, so
|
||||
@@ -107,7 +116,11 @@ async function parseOmpSubagentTranscript(args: {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) })
|
||||
recordSessionScanIssue(args.issues, {
|
||||
agent: 'omp',
|
||||
path: filePath,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts'
|
||||
@@ -51,7 +51,7 @@ export function isOmpSubagentTranscriptFileName(name: string, isFile: boolean):
|
||||
export async function countOmpSubagentTranscripts(transcriptFilePath: string): Promise<number> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(ompArtifactDirFor(transcriptFilePath), { withFileTypes: true })
|
||||
entries = await wslGatedReaddir(ompArtifactDirFor(transcriptFilePath), 'scan')
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { wslGatedReaddir, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { join } from 'node:path'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
@@ -26,7 +27,7 @@ export async function parseOpenCodeSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -47,8 +48,13 @@ export async function parseOpenCodeSessionFile(
|
||||
async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<string, unknown>[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(messageDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = await wslGatedReaddir(messageDir, 'scan')
|
||||
} catch (error) {
|
||||
// A session with no message dir yet is empty; a gate refusal is not — an
|
||||
// empty transcript would otherwise be cached under the session's mtime.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
const messages: { name: string; createdMs: number; message: Record<string, unknown> }[] = []
|
||||
@@ -59,11 +65,15 @@ async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<s
|
||||
let message: Record<string, unknown> | null = null
|
||||
try {
|
||||
message = asRecord(
|
||||
JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown
|
||||
JSON.parse(await wslGatedReadFile(join(messageDir, entry.name), 'utf-8', 'scan')) as unknown
|
||||
)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// A live OpenCode process can leave a half-written file; skip it rather
|
||||
// than discard the whole session.
|
||||
// than discard the whole session. A refused read is not a bad file — the
|
||||
// partial transcript it would produce must not become the cached answer.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!message) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
|
||||
const WSL_DATA_DIR = `${WSL_HOME}/.local/share/opencode`
|
||||
|
||||
const mocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
readdir: mocks.readdir,
|
||||
stat: mocks.stat
|
||||
}))
|
||||
|
||||
// The SQLite leg spawns a real worker thread, which fake timers cannot drive.
|
||||
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
|
||||
listOpenCodeSqliteSessionsViaWorker: async () => []
|
||||
}))
|
||||
|
||||
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function stalls(): Promise<never> {
|
||||
return new Promise<never>((resolve) => {
|
||||
releaseStall = () => resolve(undefined as never)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.readdir.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
releaseStall = undefined
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('OpenCode source discovery with a stalled WSL data directory', () => {
|
||||
it('reports the stalled home as an issue and still resolves every discovery', async () => {
|
||||
mocks.readdir.mockImplementation((dir: string) =>
|
||||
dir === WSL_DATA_DIR ? stalls() : Promise.resolve([] as Dirent[])
|
||||
)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discoveries = Promise.all(
|
||||
opencodeDiscoveries(
|
||||
{ opencodeStorageDir: '/home/ada/.local/share/opencode/storage' },
|
||||
[WSL_HOME],
|
||||
10,
|
||||
issues
|
||||
)
|
||||
)
|
||||
// Two deadlines: the data-dir probe, then the storage scan that queued
|
||||
// behind it before the route was flagged stuck.
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
|
||||
|
||||
// Zero databases for that home is the degraded answer; without the issue it
|
||||
// would be indistinguishable from "OpenCode was never installed there".
|
||||
const resolved = await discoveries
|
||||
expect(resolved).toHaveLength(2)
|
||||
expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true)
|
||||
expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true)
|
||||
expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a refused primary data directory instead of scanning clean', async () => {
|
||||
const previousXdg = process.env.XDG_DATA_HOME
|
||||
const previousDb = process.env.OPENCODE_DB
|
||||
process.env.XDG_DATA_HOME = WSL_HOME
|
||||
delete process.env.OPENCODE_DB
|
||||
try {
|
||||
mocks.readdir.mockImplementation(stalls)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discoveries = Promise.all(opencodeDiscoveries({}, [], 10, issues))
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
|
||||
|
||||
// The primary source is the one per-root containment cannot reach, so a
|
||||
// silent [] here reads as "no OpenCode sessions" on a clean scan.
|
||||
await expect(discoveries).resolves.toHaveLength(1)
|
||||
expect(
|
||||
issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`)
|
||||
).toBe(true)
|
||||
} finally {
|
||||
restoreEnv('XDG_DATA_HOME', previousXdg)
|
||||
restoreEnv('OPENCODE_DB', previousDb)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory'
|
||||
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
|
||||
@@ -16,7 +18,7 @@ export function opencodeDiscoveries(
|
||||
return storageDirs.map(async (storageDir, index) =>
|
||||
discoverOpenCodeSessions({
|
||||
storageDir,
|
||||
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
|
||||
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues),
|
||||
limitPerAgent: limit,
|
||||
issues
|
||||
})
|
||||
@@ -37,32 +39,47 @@ async function opencodeDbPathsForSource(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
storageDir: string,
|
||||
sourceIndex: number
|
||||
sourceIndex: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): 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))
|
||||
return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues)
|
||||
}
|
||||
if (sourceIndex === 0) {
|
||||
return listOpenCodeDatabases()
|
||||
return listOpenCodeDatabases((path, error) => {
|
||||
recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message })
|
||||
})
|
||||
}
|
||||
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
|
||||
return wslHomeDir
|
||||
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
|
||||
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues)
|
||||
: []
|
||||
}
|
||||
|
||||
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
|
||||
async function listOpenCodeDatabasesInDirectory(
|
||||
dataDir: string,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(dataDir, { withFileTypes: true })
|
||||
const entries = await wslGatedReaddir(dataDir, 'scan')
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDir, entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// A stalled WSL data dir still degrades to "no databases", but the gap has
|
||||
// to be reportable — an empty list otherwise reads as "OpenCode not used".
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
recordSessionScanIssue(issues, {
|
||||
agent: 'opencode',
|
||||
path: dataDir,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { open } from 'node:fs/promises'
|
||||
import {
|
||||
openTranscriptReadStream,
|
||||
readTranscriptSlice
|
||||
} from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
@@ -305,13 +307,8 @@ async function parseResumableCandidate(args: {
|
||||
// agent transcripts are append-only so that trade is accepted (worst case is
|
||||
// a stale vault row until the file is next truncated or the app restarts).
|
||||
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1)
|
||||
return bytesRead === 1 && buffer[0] === NEWLINE_BYTE
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
|
||||
return slice.length === 1 && slice[0] === NEWLINE_BYTE
|
||||
}
|
||||
|
||||
type JsonlReadResult = {
|
||||
@@ -336,7 +333,7 @@ async function consumeCompleteJsonlLines(args: {
|
||||
let remainderParts: Buffer[] = []
|
||||
let remainderLength = 0
|
||||
|
||||
const stream = createReadStream(args.path, { start: args.start })
|
||||
const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan')
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
bytesRead += chunk.length
|
||||
// Why check the chunk alone: the pieces held over are all mid-line, so none
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
const STALLED_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\a.jsonl'
|
||||
const SIBLING_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\b.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ open: vi.fn(), readdir: vi.fn() }))
|
||||
|
||||
// readdir too: every claude parse counts sibling subagent transcripts, and an
|
||||
// unmocked one would reach the host UNC path and stall under fake timers.
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
open: mocks.open,
|
||||
readdir: mocks.readdir
|
||||
}))
|
||||
|
||||
import {
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests
|
||||
} from './session-scanner-parse-cache'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
type ReadResult = { bytesRead: number; buffer: Buffer }
|
||||
|
||||
// A stalled task keeps the gate's single scan slot until the call settles, so
|
||||
// every case releases its stall before the next one runs.
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls(): Promise<ReadResult> {
|
||||
return new Promise<ReadResult>((resolve) => {
|
||||
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) })
|
||||
})
|
||||
}
|
||||
|
||||
function servingHandle(body: Buffer) {
|
||||
return {
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const slice = body.subarray(position, Math.min(position + length, body.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
function record(index: number, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
timestamp: new Date(1740000000000 + index * 60_000).toISOString(),
|
||||
cwd: '/repo/app',
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: text }
|
||||
})
|
||||
}
|
||||
|
||||
function body(count: number): Buffer {
|
||||
return Buffer.from(
|
||||
Array.from({ length: count }, (_, index) => `${record(index, `line ${index}`)}\n`).join('')
|
||||
)
|
||||
}
|
||||
|
||||
function candidate(path: string, bytes: Buffer, mtimeMs: number): SessionFileCandidate {
|
||||
return {
|
||||
agent: 'claude',
|
||||
file: {
|
||||
path,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: bytes.length
|
||||
},
|
||||
codexHome: null
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseAndSettle(): Promise<void> {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSessionParseCacheForTests()
|
||||
mocks.open.mockReset()
|
||||
mocks.readdir.mockReset()
|
||||
mocks.readdir.mockResolvedValue([])
|
||||
releaseStall = undefined
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await releaseAndSettle()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AI Vault session parse with a stalled WSL transcript body read', () => {
|
||||
it('refuses the stalled candidate and still parses a sibling on a healthy distro', async () => {
|
||||
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
|
||||
// Rejecting (not resolving null) is what lets `parseSessionCandidate`
|
||||
// report it as an issue instead of silently dropping the session. The
|
||||
// assertion is attached before the clock moves so the rejection is never
|
||||
// momentarily unhandled.
|
||||
const refusal = expect(
|
||||
parseAgentSessionFileCached(candidate(STALLED_PATH, body(2), 1), 'linux')
|
||||
).rejects.toBeInstanceOf(WslTranscriptFsError)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
await refusal
|
||||
|
||||
await releaseAndSettle()
|
||||
const siblingBytes = body(2)
|
||||
mocks.open.mockResolvedValue(servingHandle(siblingBytes))
|
||||
|
||||
const sibling = await parseAgentSessionFileCached(
|
||||
candidate(SIBLING_PATH, siblingBytes, 1),
|
||||
'linux'
|
||||
)
|
||||
expect(sibling?.messageCount).toBe(2)
|
||||
})
|
||||
|
||||
it('caches no resume point when the newline probe is refused mid-append', async () => {
|
||||
const first = body(2)
|
||||
mocks.open.mockResolvedValue(servingHandle(first))
|
||||
const seeded = await parseAgentSessionFileCached(candidate(STALLED_PATH, first, 1), 'linux')
|
||||
expect(seeded?.messageCount).toBe(2)
|
||||
|
||||
const grown = body(3)
|
||||
const serving = servingHandle(grown)
|
||||
mocks.open.mockResolvedValue({
|
||||
// Only the 1-byte resume probe stalls; the body read stays healthy.
|
||||
read: vi.fn((buffer: Buffer, offset: number, length: number, position: number) =>
|
||||
length === 1 ? stalls() : serving.read(buffer, offset, length, position)
|
||||
),
|
||||
close: vi.fn(async () => {})
|
||||
})
|
||||
const refused = expect(
|
||||
parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
|
||||
).rejects.toBeInstanceOf(WslTranscriptFsError)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
await refused
|
||||
|
||||
await releaseAndSettle()
|
||||
mocks.open.mockResolvedValue(servingHandle(grown))
|
||||
const recovered = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
|
||||
|
||||
resetSessionParseCacheForTests()
|
||||
const cold = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
|
||||
expect(recovered).toEqual(cold)
|
||||
expect(recovered?.messageCount).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Readable } from 'node:stream'
|
||||
import type * as NodeReadlineModule from 'node:readline'
|
||||
import type * as WslFsAccessModule from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
|
||||
// readline.close() leaves its input open, so a parser that stops consuming
|
||||
// mid-file (a throw, a break) strands the gated transcript handle until the
|
||||
// gate's deadline and delays every later scan. These pin the finally blocks.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openStream: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
createInterface: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslFsAccessModule>()),
|
||||
openTranscriptReadStream: mocks.openStream,
|
||||
wslGatedReadFile: mocks.readFile,
|
||||
wslGatedStat: mocks.stat
|
||||
}))
|
||||
|
||||
vi.mock('node:readline', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeReadlineModule>()),
|
||||
createInterface: mocks.createInterface
|
||||
}))
|
||||
|
||||
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths'
|
||||
|
||||
const PARSE_FAILURE = 'parser failed mid-transcript'
|
||||
|
||||
const opened: { path: string; stream: Readable }[] = []
|
||||
const interfaces: { close: ReturnType<typeof vi.fn> }[] = []
|
||||
|
||||
function file(path: string): FileWithMtime {
|
||||
return { path, mtimeMs: 1, modifiedAt: '2026-06-01T10:05:00.000Z', sizeBytes: 128 }
|
||||
}
|
||||
|
||||
function lastOpened(): { path: string; stream: Readable } {
|
||||
const entry = opened.at(-1)
|
||||
if (!entry) {
|
||||
throw new Error('no transcript stream was opened')
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
function expectStreamTornDown(): void {
|
||||
expect(lastOpened().stream.destroyed).toBe(true)
|
||||
expect(interfaces.at(-1)?.close).toHaveBeenCalled()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
opened.length = 0
|
||||
interfaces.length = 0
|
||||
clearKimiSessionIndexCache()
|
||||
mocks.openStream.mockReset()
|
||||
mocks.readFile.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
mocks.createInterface.mockReset()
|
||||
|
||||
mocks.openStream.mockImplementation((path: string) => {
|
||||
const stream = new Readable({
|
||||
read() {
|
||||
this.push(null)
|
||||
}
|
||||
})
|
||||
opened.push({ path, stream })
|
||||
return stream
|
||||
})
|
||||
// One line, then a consumer-side throw: the parser must still tear the
|
||||
// stream down on its way out.
|
||||
mocks.createInterface.mockImplementation(() => {
|
||||
const lines = {
|
||||
close: vi.fn(),
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield '{}'
|
||||
throw new Error(PARSE_FAILURE)
|
||||
}
|
||||
}
|
||||
interfaces.push(lines)
|
||||
return lines
|
||||
})
|
||||
})
|
||||
|
||||
describe('session parsers that stop consuming a gated transcript early', () => {
|
||||
it.each([
|
||||
['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')],
|
||||
['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')],
|
||||
['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')]
|
||||
])('destroys the stream when the %s parse throws', async (_agent, parse) => {
|
||||
await expect(parse()).rejects.toThrow(PARSE_FAILURE)
|
||||
|
||||
expectStreamTornDown()
|
||||
})
|
||||
|
||||
it('destroys the chat_history stream when the Grok parse swallows the failure', async () => {
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify({ info: { id: 'ses-1' } }))
|
||||
|
||||
// Grok degrades to a summary-only session on a non-gate failure, so the
|
||||
// teardown has no rejection to ride out on.
|
||||
await expect(
|
||||
parseGrokSessionFile(file('/w/.grok/sessions/ses-1/session.json'))
|
||||
).resolves.toBeTruthy()
|
||||
|
||||
expect(lastOpened().path).toContain('chat_history.jsonl')
|
||||
expectStreamTornDown()
|
||||
})
|
||||
|
||||
it('destroys the wire stream when the Kimi parse swallows the failure', async () => {
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify({ title: 'Kimi session' }))
|
||||
// No session_index.jsonl, so only the wire transcript opens a stream.
|
||||
mocks.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
|
||||
await expect(
|
||||
parseKimiSessionFile(file('/w/.kimi-code/sessions/wd_app/session_abc/state.json'))
|
||||
).resolves.toBeTruthy()
|
||||
|
||||
expect(lastOpened().path).toContain('wire.jsonl')
|
||||
expectStreamTornDown()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -210,7 +210,7 @@ export async function parseClaudeSessionFile(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseClaudeSessionLines({ file, lines, platform })
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { extname, join } from 'node:path'
|
||||
import {
|
||||
openTranscriptReadStream,
|
||||
wslGatedReaddir,
|
||||
wslGatedStat
|
||||
} from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathSeparators
|
||||
} from '../../shared/cross-platform-path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
@@ -26,7 +31,18 @@ export function resetProjectDirCwdCacheForTests(): void {
|
||||
projectDirCwdCache.clear()
|
||||
}
|
||||
|
||||
async function cachedProjectDirCwd(projectDir: string): Promise<string | null> {
|
||||
// A gate refusal is a stalled WSL root, not an empty one: keep the existing
|
||||
// degrade-to-empty containment but make the gap visible in the scan issues.
|
||||
function recordRefusal(issues: AiVaultScanIssue[], path: string, error: unknown): void {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
recordSessionScanIssue(issues, { agent: 'claude', path, message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function cachedProjectDirCwd(
|
||||
projectDir: string,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string | null> {
|
||||
const cached = projectDirCwdCache.get(projectDir)
|
||||
if (cached !== undefined) {
|
||||
// Refresh recency so hot in-scope dirs outlive one-off ones at the cap.
|
||||
@@ -34,7 +50,7 @@ async function cachedProjectDirCwd(projectDir: string): Promise<string | null> {
|
||||
projectDirCwdCache.set(projectDir, cached)
|
||||
return cached
|
||||
}
|
||||
const cwd = await readProjectDirCwd(projectDir)
|
||||
const cwd = await readProjectDirCwd(projectDir, issues)
|
||||
if (cwd) {
|
||||
if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) {
|
||||
const oldest = projectDirCwdCache.keys().next()
|
||||
@@ -70,8 +86,8 @@ export async function discoverInScopeClaudeFiles(args: {
|
||||
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
|
||||
const collected = new Map<string, FileWithMtime>()
|
||||
for (const rootDir of args.rootDirs) {
|
||||
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
|
||||
const cwd = await cachedProjectDirCwd(projectDir)
|
||||
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes, args.issues)) {
|
||||
const cwd = await cachedProjectDirCwd(projectDir, args.issues)
|
||||
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
|
||||
continue
|
||||
}
|
||||
@@ -148,12 +164,14 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
|
||||
|
||||
async function listProjectDirs(
|
||||
rootDir: string,
|
||||
scopeProjectPrefixes: ReadonlySet<string>
|
||||
scopeProjectPrefixes: ReadonlySet<string>,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(rootDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = await wslGatedReaddir(rootDir, 'scan')
|
||||
} catch (err) {
|
||||
recordRefusal(issues, rootDir, err)
|
||||
return []
|
||||
}
|
||||
return entries
|
||||
@@ -163,10 +181,13 @@ async function listProjectDirs(
|
||||
.map((entry) => join(rootDir, entry.name))
|
||||
}
|
||||
|
||||
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
|
||||
const files = await newestClaudeFilesInDir(projectDir)
|
||||
async function readProjectDirCwd(
|
||||
projectDir: string,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string | null> {
|
||||
const files = await newestClaudeFilesInDir(projectDir, issues)
|
||||
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
|
||||
const cwd = await readFirstCwd(file)
|
||||
const cwd = await readFirstCwd(file, issues)
|
||||
if (cwd) {
|
||||
return cwd
|
||||
}
|
||||
@@ -174,11 +195,15 @@ async function readProjectDirCwd(projectDir: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
|
||||
async function newestClaudeFilesInDir(
|
||||
projectDir: string,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(projectDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = await wslGatedReaddir(projectDir, 'scan')
|
||||
} catch (err) {
|
||||
recordRefusal(issues, projectDir, err)
|
||||
return []
|
||||
}
|
||||
const newest: { path: string; mtimeMs: number }[] = []
|
||||
@@ -190,18 +215,19 @@ async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
|
||||
try {
|
||||
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
|
||||
path,
|
||||
mtimeMs: (await stat(path)).mtimeMs
|
||||
mtimeMs: (await wslGatedStat(path, 'scan')).mtimeMs
|
||||
})
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Best effort: unreadable candidates are ignored here and reported during
|
||||
// full collection if the project directory proves in-scope.
|
||||
recordRefusal(issues, path, err)
|
||||
}
|
||||
}
|
||||
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
|
||||
}
|
||||
|
||||
async function readFirstCwd(filePath: string): Promise<string | null> {
|
||||
const input = createReadStream(filePath, { encoding: 'utf-8' })
|
||||
async function readFirstCwd(filePath: string, issues: AiVaultScanIssue[]): Promise<string | null> {
|
||||
const input = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'scan')
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
let read = 0
|
||||
try {
|
||||
@@ -214,7 +240,8 @@ async function readFirstCwd(filePath: string): Promise<string | null> {
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
recordRefusal(issues, filePath, err)
|
||||
return null
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so the early
|
||||
@@ -234,8 +261,9 @@ async function collectClaudeFiles(args: {
|
||||
}): Promise<void> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(args.projectDir, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = await wslGatedReaddir(args.projectDir, 'scan')
|
||||
} catch (err) {
|
||||
recordRefusal(args.issues, args.projectDir, err)
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
@@ -247,7 +275,7 @@ async function collectClaudeFiles(args: {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(path)
|
||||
const fileStat = await wslGatedStat(path, 'scan')
|
||||
addBoundedFile(args.collected, args.limit, {
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
@@ -255,7 +283,7 @@ async function collectClaudeFiles(args: {
|
||||
sizeBytes: fileStat.size
|
||||
})
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
|
||||
recordSessionScanIssue(args.issues, { agent: 'claude', path, message: errorMessage(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
|
||||
// Exported so discovery can prune these subtrees using the same literal that
|
||||
@@ -38,7 +38,7 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
|
||||
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true })
|
||||
entries = await wslGatedReaddir(subagentTranscriptsDirFor(transcriptFilePath), 'scan')
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, isAbsolute, join } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { asRecord } from './session-scanner-record-value'
|
||||
|
||||
export { asRecord }
|
||||
@@ -76,8 +77,15 @@ export async function readJsonObjectIfExists(
|
||||
filePath: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown)
|
||||
} catch {
|
||||
return asRecord(JSON.parse(await wslGatedReadFile(filePath, 'utf-8', 'scan')) as unknown)
|
||||
} catch (error) {
|
||||
// A missing or malformed file is genuinely "no enrichment", but a gate
|
||||
// refusal must reach `parseSessionCandidate` as an issue — degrading it to
|
||||
// null caches the un-enriched session under an unchanged mtime, and the
|
||||
// non-resumable agents that use this never re-read it.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
} from './codex-session-root-dedup'
|
||||
import {
|
||||
createAntigravityWorkspaceResolver,
|
||||
readLocalAntigravityHistory,
|
||||
type AntigravityWorkspaceResolver
|
||||
} from './session-scanner-antigravity-history'
|
||||
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
parseAgentSessionFileCached,
|
||||
type SessionParseStats
|
||||
} from './session-scanner-parse-cache'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
|
||||
import {
|
||||
DEFAULT_CODEX_HOME_DIR,
|
||||
@@ -71,7 +72,9 @@ export async function scanAiVaultSessions(
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const parseStats = createSessionParseStats()
|
||||
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile)
|
||||
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
|
||||
readLocalAntigravityHistory
|
||||
)
|
||||
// Why: persisted entries must be seeded before any candidate is parsed, or
|
||||
// the cold scan gains nothing from the cache file (#9210).
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
@@ -255,7 +258,7 @@ async function parseSessionCandidates(args: {
|
||||
|
||||
for (const result of results) {
|
||||
if (result.issue) {
|
||||
args.issues.push(result.issue)
|
||||
recordSessionScanIssue(args.issues, result.issue)
|
||||
}
|
||||
if (result.session) {
|
||||
sessions.push(result.session)
|
||||
@@ -305,14 +308,6 @@ async function parseSessionCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(path, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionExecutionHost(
|
||||
session: AiVaultSession,
|
||||
executionHostId: ExecutionHostId
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\stalled.jsonl'
|
||||
const STALLED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const LOCAL_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ lstat: vi.fn() }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof NodeFsPromisesModule>()
|
||||
return {
|
||||
...original,
|
||||
// Only the WSL request is faked; the local sibling reads a real temp file.
|
||||
lstat: (path: string, ...rest: unknown[]) =>
|
||||
path.startsWith('\\\\')
|
||||
? mocks.lstat(path)
|
||||
: (original.lstat as (...args: unknown[]) => unknown)(path, ...rest)
|
||||
}
|
||||
})
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
|
||||
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
|
||||
import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
let tempRoot: string | undefined
|
||||
|
||||
function stalls(): Promise<never> {
|
||||
return new Promise<never>((resolve) => {
|
||||
releaseStall = () => resolve(undefined as never)
|
||||
})
|
||||
}
|
||||
|
||||
function userRecord(sessionId: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId,
|
||||
timestamp: new Date(1740000000000).toISOString(),
|
||||
cwd: '/repo/app',
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: text }
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSessionParseCacheForTests()
|
||||
mocks.lstat.mockReset()
|
||||
releaseStall = undefined
|
||||
tempRoot = await mkdtemp(join(tmpdir(), 'orca-title-stall-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
tempRoot = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('AI Vault session titles with a stalled WSL transcript lstat', () => {
|
||||
it('drops only the stalled request and still returns the rest of the batch', async () => {
|
||||
const localPath = join(tempRoot!, `${LOCAL_SESSION_ID}.jsonl`)
|
||||
await writeFile(localPath, `${userRecord(LOCAL_SESSION_ID, 'hello world')}\n`)
|
||||
mocks.lstat.mockImplementation(stalls)
|
||||
vi.useFakeTimers()
|
||||
|
||||
const pending = readAiVaultSessionTitlesFromFiles([
|
||||
{ agent: 'claude', sessionId: STALLED_SESSION_ID, transcriptPath: UNC_PATH },
|
||||
{ agent: 'claude', sessionId: LOCAL_SESSION_ID, transcriptPath: localPath }
|
||||
])
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
|
||||
const { titles } = await pending
|
||||
expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import {
|
||||
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
|
||||
type AiVaultSessionTitle,
|
||||
@@ -27,7 +27,7 @@ async function readOneTitle(
|
||||
return cache?.get(request) ?? null
|
||||
}
|
||||
try {
|
||||
const stats = await lstat(transcriptPath)
|
||||
const stats = await wslGatedLstat(transcriptPath, 'scan', signal)
|
||||
if (!stats.isFile() || signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ vi.mock('../providers/windows-conpty-process-membership', () => ({
|
||||
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
|
||||
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
|
||||
import {
|
||||
LEGACY_TERMINAL_SHIM_ENV_KEYS,
|
||||
stripLegacyTerminalShimEnv
|
||||
} from '../pty/legacy-terminal-shim-dir'
|
||||
|
||||
const ORCA_SHELL_WRAPPER_ENV = [
|
||||
'ORCA_OPENCODE_CONFIG_DIR',
|
||||
@@ -1275,6 +1279,35 @@ describe('createPtySubprocess', () => {
|
||||
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not inherit legacy attribution state from a pre-upgrade daemon', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const saved = Object.fromEntries(
|
||||
[...LEGACY_TERMINAL_SHIM_ENV_KEYS, 'PATH'].map((key) => [key, process.env[key]])
|
||||
)
|
||||
process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
|
||||
process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix'
|
||||
process.env.PATH = `/tmp/orca-terminal-attribution/posix${delimiter}/usr/bin`
|
||||
|
||||
try {
|
||||
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
for (const key of LEGACY_TERMINAL_SHIM_ENV_KEYS) {
|
||||
expect(env[key]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not inherit NODE_ENV from the daemon process env', () => {
|
||||
// Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking
|
||||
// Orca's build mode into user shells breaks `next build` and Vitest.
|
||||
@@ -1295,7 +1328,9 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.NODE_ENV).toBeUndefined()
|
||||
expect(env.PATH).toBe(process.env.PATH)
|
||||
const expectedEnv = { PATH: process.env.PATH ?? '' }
|
||||
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
|
||||
expect(env.PATH).toBe(expectedEnv.PATH)
|
||||
})
|
||||
|
||||
it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => {
|
||||
@@ -2207,6 +2242,8 @@ describe('createPtySubprocess', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const expectedEnv = { PATH: process.env.PATH ?? '' }
|
||||
stripLegacyTerminalShimEnv(expectedEnv, 'win32')
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
try {
|
||||
@@ -2218,7 +2255,7 @@ describe('createPtySubprocess', () => {
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)![2].env
|
||||
expect(env.PATH).toBe(process.env.PATH)
|
||||
expect(env.PATH).toBe(expectedEnv.PATH)
|
||||
})
|
||||
|
||||
it('preserves a duplicated path block supplied by main', () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-
|
||||
import { removeInheritedNoColor } from '../pty/terminal-color-env'
|
||||
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
|
||||
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
|
||||
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
|
||||
import { resolvePathEnvKey } from '../pty/windows-environment-path'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
@@ -629,6 +630,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
// Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links.
|
||||
FORCE_HYPERLINK: '1'
|
||||
} as Record<string, string>
|
||||
// Why: an older client may not ask a newly upgraded daemon to delete inherited shim state.
|
||||
stripLegacyTerminalShimEnv(env, process.platform)
|
||||
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
|
||||
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
|
||||
if (opts.env?.TERM) {
|
||||
@@ -834,6 +837,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
|
||||
: undefined
|
||||
promoteAgentTeamsShimPath(env, requestedPath)
|
||||
// Why: raw requested PATH promotion runs after the inherited-env scrub.
|
||||
stripLegacyTerminalShimEnv(env, process.platform)
|
||||
|
||||
// Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix.
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DiffComment, FolderWorkspace } from '../shared/types'
|
||||
|
||||
// Why shape-only: this replaces folder-workspaces.ts's verbatim `Array.isArray(raw.diffComments)` read.
|
||||
// Filtering members would make the fix itself a new deletion path for user-authored prose.
|
||||
export function normalizeFolderWorkspaceDiffComments(
|
||||
value: unknown
|
||||
): Record<string, DiffComment[]> | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const normalized: Record<string, DiffComment[]> = {}
|
||||
let kept = false
|
||||
for (const [id, comments] of Object.entries(value)) {
|
||||
if (!Array.isArray(comments)) {
|
||||
continue
|
||||
}
|
||||
normalized[id] = comments as DiffComment[]
|
||||
kept = true
|
||||
}
|
||||
return kept ? normalized : undefined
|
||||
}
|
||||
|
||||
// Why derive on every write: the map self-GCs, so delete paths need no pruning code.
|
||||
export function collectFolderWorkspaceDiffComments(
|
||||
workspaces: readonly FolderWorkspace[] | undefined
|
||||
): Record<string, DiffComment[]> | undefined {
|
||||
const collected: Record<string, DiffComment[]> = {}
|
||||
let kept = false
|
||||
for (const workspace of workspaces ?? []) {
|
||||
const comments = workspace.diffComments
|
||||
if (Array.isArray(comments) && comments.length > 0) {
|
||||
collected[workspace.id] = comments
|
||||
kept = true
|
||||
}
|
||||
}
|
||||
return kept ? collected : undefined
|
||||
}
|
||||
+2
-2
@@ -185,7 +185,7 @@ import {
|
||||
logStartupMilestone
|
||||
} from './startup/startup-diagnostics'
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
|
||||
import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import {
|
||||
createServeDesktopActivationGate,
|
||||
@@ -2172,7 +2172,7 @@ void app.whenReady().then(async () => {
|
||||
const activeOrcaProfile = ensureActiveOrcaProfile()
|
||||
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
||||
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
|
||||
removeLegacyTerminalShimDir(app.getPath('userData'))
|
||||
neutralizeLegacyTerminalShimDir(app.getPath('userData'))
|
||||
const windowsShellPathHydration = createWindowsShellPathHydration()
|
||||
configureWindowsHostGitEnvironmentReadiness(
|
||||
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
|
||||
|
||||
@@ -45,6 +45,7 @@ const SNAPSHOT = {
|
||||
worktreeName: 'Dashboard',
|
||||
hostKind: 'ssh',
|
||||
executionHostId: 'ssh:build-box',
|
||||
hostLabel: 'Build box',
|
||||
workspaceKind: 'worktree',
|
||||
workspaceStatusId: 'in-review',
|
||||
workspaceStatusLabel: 'In review',
|
||||
@@ -68,6 +69,7 @@ const SNAPSHOT = {
|
||||
parentWorktreeId: 'parent-worktree-1',
|
||||
hostKind: 'ssh',
|
||||
executionHostId: 'ssh:build-box',
|
||||
hostLabel: 'Build box',
|
||||
workspaceKind: 'worktree',
|
||||
workspaceStatusId: 'in-review',
|
||||
workspaceStatusLabel: 'In review',
|
||||
@@ -143,6 +145,12 @@ describe('dashboard payload validation', () => {
|
||||
cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
cards: [{ ...SNAPSHOT.cards[0], hostLabel: 'x'.repeat(1_025) }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
@@ -236,6 +244,12 @@ describe('dashboard payload validation', () => {
|
||||
workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
workspaces: [{ ...SNAPSHOT.workspaces[0], hostLabel: 'x'.repeat(1_025) }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('validates bounded launch choices and spawn requests', () => {
|
||||
|
||||
@@ -272,6 +272,7 @@ function isDashboardCard(value: unknown): boolean {
|
||||
(card.executionHostId === undefined ||
|
||||
(isBoundedString(card.executionHostId, MAX_ID_LENGTH) &&
|
||||
normalizeExecutionHostId(card.executionHostId) !== null)) &&
|
||||
isOptionalBoundedString(card.hostLabel, MAX_LABEL_LENGTH) &&
|
||||
(card.workspaceKind === undefined ||
|
||||
(typeof card.workspaceKind === 'string' &&
|
||||
DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) &&
|
||||
|
||||
@@ -50,6 +50,7 @@ export function isDashboardWorkspace(value: unknown): value is DashboardWorkspac
|
||||
HOST_KINDS.has(workspace.hostKind) &&
|
||||
isString(workspace.executionHostId, MAX_ID_LENGTH) &&
|
||||
normalizeExecutionHostId(workspace.executionHostId) !== null &&
|
||||
isOptionalString(workspace.hostLabel, DASHBOARD_MAX_LABEL_LENGTH) &&
|
||||
typeof workspace.workspaceKind === 'string' &&
|
||||
WORKSPACE_KINDS.has(workspace.workspaceKind) &&
|
||||
isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) &&
|
||||
|
||||
@@ -248,6 +248,7 @@ import {
|
||||
type PrepareCodexSessionResume
|
||||
} from './pty'
|
||||
import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path'
|
||||
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
|
||||
import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader'
|
||||
import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell'
|
||||
import {
|
||||
@@ -4399,6 +4400,16 @@ describe('registerPtyHandlers', () => {
|
||||
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
|
||||
})
|
||||
|
||||
it('asks surviving pre-upgrade daemons to delete legacy attribution env', async () => {
|
||||
const spawnOptions = await daemonSpawnAndGetOptions({})
|
||||
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
|
||||
)
|
||||
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
|
||||
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
|
||||
})
|
||||
|
||||
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
spawn(args: {
|
||||
@@ -4436,6 +4447,38 @@ describe('registerPtyHandlers', () => {
|
||||
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
|
||||
})
|
||||
|
||||
it('asks surviving pre-upgrade daemons to delete legacy attribution env for runtime PTYs', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
spawn(args: {
|
||||
cols: number
|
||||
rows: number
|
||||
worktreeId?: string
|
||||
env?: Record<string, string>
|
||||
}): Promise<{ id: string }>
|
||||
}
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
|
||||
|
||||
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
|
||||
|
||||
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
|
||||
expect(spawnOptions.envToDelete).toEqual(
|
||||
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
|
||||
)
|
||||
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
|
||||
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
|
||||
})
|
||||
|
||||
it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => {
|
||||
// Why: the runtime controller is the `orca` CLI / automation spawn path and
|
||||
// assembles envToDelete separately from the renderer's pty:spawn handler;
|
||||
@@ -6253,7 +6296,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
})
|
||||
|
||||
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
|
||||
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
@@ -6296,22 +6339,30 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
kill: (ptyId: string) => boolean
|
||||
retireRejectedPty: (ptyId: string) => void
|
||||
}
|
||||
|
||||
try {
|
||||
expect(controller.kill('remote-pty')).toBe(true)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
controller.retireRejectedPty('remote-pty')
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
deletePtyOwnership('remote-pty')
|
||||
}
|
||||
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
|
||||
})
|
||||
|
||||
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
|
||||
|
||||
+53
-6
@@ -113,6 +113,7 @@ import {
|
||||
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
|
||||
import {
|
||||
isLegacyTerminalShimPathEntry,
|
||||
LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
|
||||
stripLegacyTerminalShimEnv
|
||||
} from '../pty/legacy-terminal-shim-dir'
|
||||
import { registerPty, unregisterPty } from '../memory/pty-registry'
|
||||
@@ -3562,6 +3563,23 @@ export function registerPtyHandlers(
|
||||
syntheticKillExitPtyIds.set(id, cleanupTimer)
|
||||
}
|
||||
|
||||
// Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late
|
||||
// synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the
|
||||
// surface the retirement just removed. Timed like the duplicate window so a reused id is free.
|
||||
const retiredRejectedPtyIds = new Map<string, NodeJS.Timeout>()
|
||||
|
||||
function rememberRetiredRejectedPty(id: string): void {
|
||||
const existing = retiredRejectedPtyIds.get(id)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
retiredRejectedPtyIds.delete(id)
|
||||
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
|
||||
cleanupTimer.unref?.()
|
||||
retiredRejectedPtyIds.set(id, cleanupTimer)
|
||||
}
|
||||
|
||||
function consumeSyntheticKillExit(id: string): boolean {
|
||||
const cleanupTimer = syntheticKillExitPtyIds.get(id)
|
||||
if (!cleanupTimer) {
|
||||
@@ -4715,6 +4733,8 @@ export function registerPtyHandlers(
|
||||
spawnOptions.envToDelete = mergePtyEnvDeletions(
|
||||
authEnvToDelete,
|
||||
args.envToDelete ?? [],
|
||||
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
|
||||
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
|
||||
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [],
|
||||
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
|
||||
getInheritedClaudeSessionStampEnvKeysToDelete(env)
|
||||
@@ -5469,19 +5489,23 @@ export function registerPtyHandlers(
|
||||
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
|
||||
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
|
||||
.then((providerExitObserved) => {
|
||||
const retired = retiredRejectedPtyIds.has(ptyId)
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
if (!providerExitObserved) {
|
||||
if (!providerExitObserved && !retired) {
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const retired = retiredRejectedPtyIds.has(ptyId)
|
||||
if (isPtyAlreadyGoneError(err)) {
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
if (!retired) {
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
@@ -5489,7 +5513,9 @@ export function registerPtyHandlers(
|
||||
)
|
||||
// Why: close runtime tails without clearing provider ownership, so
|
||||
// a retry can still target a PTY that survived the failed shutdown.
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
if (!retired) {
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -5500,12 +5526,31 @@ export function registerPtyHandlers(
|
||||
console.warn(
|
||||
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
if (!retiredRejectedPtyIds.has(ptyId)) {
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return killWithCurrentProvider()
|
||||
},
|
||||
retireRejectedPty: (ptyId) => {
|
||||
rememberRetiredRejectedPty(ptyId)
|
||||
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
|
||||
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
|
||||
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
|
||||
if (!ptyOwnership.has(ptyId)) {
|
||||
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
|
||||
return
|
||||
}
|
||||
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
|
||||
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
|
||||
connectionId ??= parsedSshId?.connectionId
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, 0, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: 0 })
|
||||
},
|
||||
markReversibleStops: (ptyIds) => {
|
||||
for (const ptyId of ptyIds) {
|
||||
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
|
||||
@@ -6354,6 +6399,8 @@ export function registerPtyHandlers(
|
||||
envToDelete,
|
||||
args.envToDelete ?? [],
|
||||
agentTeamsEnvToDelete ?? [],
|
||||
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
|
||||
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
|
||||
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [],
|
||||
getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv),
|
||||
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],
|
||||
|
||||
@@ -183,6 +183,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
@@ -197,7 +198,8 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision
|
||||
args.expectedEnvironmentPairingRevision,
|
||||
args.expectedRuntimeId ? { expectedRuntimeId: args.expectedRuntimeId } : undefined
|
||||
)
|
||||
} catch (error) {
|
||||
const failure = runtimeEnvironmentCallFailure(environment, args.method, error)
|
||||
|
||||
@@ -77,13 +77,15 @@ export function subscribeRemoteRuntimeSharedControlRequest<TResult>(
|
||||
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
onError: (error: { code: string; message: string }) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
},
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
): Promise<RemoteRuntimeSharedSubscription> {
|
||||
return getSharedControlConnection(environmentId, pairing).subscribe(
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
callbacks
|
||||
callbacks,
|
||||
envelope?.expectedRuntimeId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -193,11 +193,14 @@ export async function subscribeRuntimeEnvironment(
|
||||
| { type: 'close' }
|
||||
) => void
|
||||
onClose: () => void
|
||||
}
|
||||
},
|
||||
expectedRuntimeId?: string
|
||||
): Promise<RemoteRuntimeSubscription> {
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
const pairing = getPreferredPairingOffer(environment)
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
const envelope: RuntimeOrchestrationEnvelope | undefined =
|
||||
expectedRuntimeId === undefined ? undefined : { expectedRuntimeId }
|
||||
let markedUsed = false
|
||||
const markUsedOnce = (runtimeId: string): void => {
|
||||
if (markedUsed) {
|
||||
@@ -240,7 +243,8 @@ export async function subscribeRuntimeEnvironment(
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
callbacksWithMarkUsed
|
||||
callbacksWithMarkUsed,
|
||||
envelope
|
||||
)
|
||||
}
|
||||
return await subscribeRemoteRuntimeRequest(
|
||||
@@ -248,7 +252,9 @@ export async function subscribeRuntimeEnvironment(
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
callbacksWithMarkUsed
|
||||
callbacksWithMarkUsed,
|
||||
undefined,
|
||||
envelope
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1094,7 +1094,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const subscribe = handler<
|
||||
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
expectedRuntimeId?: string
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
await subscribe(
|
||||
@@ -1107,7 +1113,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
},
|
||||
{ selector: 'desk', method: 'browser.screencast', params: { pageId: 'page-1' } }
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'browser.screencast',
|
||||
params: { pageId: 'page-1' },
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
}
|
||||
)
|
||||
await subscribe(
|
||||
{
|
||||
@@ -1127,14 +1138,18 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'browser.screencast',
|
||||
{ pageId: 'page-1' },
|
||||
15_000,
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'terminal.multiplex',
|
||||
{ client: { id: 'client-1' } },
|
||||
15_000,
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1163,7 +1178,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const subscribe = handler<
|
||||
{ selector: string; method: string; params?: unknown; subscriptionId?: string },
|
||||
{
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
subscriptionId?: string
|
||||
expectedRuntimeId?: string
|
||||
},
|
||||
{ subscriptionId: string; requestId: string }
|
||||
>('runtimeEnvironments:subscribe')
|
||||
await expect(
|
||||
@@ -1177,7 +1198,11 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
},
|
||||
{ selector: 'desk', method: 'session.tabs.subscribeAll' }
|
||||
{
|
||||
selector: 'desk',
|
||||
method: 'session.tabs.subscribeAll',
|
||||
expectedRuntimeId: 'runtime-1'
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({ requestId: 'tabs-shared' })
|
||||
|
||||
@@ -1187,7 +1212,8 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'session.tabs.subscribeAll',
|
||||
undefined,
|
||||
15_000,
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
{ expectedRuntimeId: 'runtime-1' }
|
||||
)
|
||||
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1315,7 +1341,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'session.tabs.subscribeAll',
|
||||
undefined,
|
||||
15_000,
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(subscribeRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1659,7 +1687,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'terminal.subscribe',
|
||||
{ terminal: 't1' },
|
||||
25,
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
|
||||
|
||||
@@ -90,6 +90,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
timeoutMs?: number
|
||||
subscriptionId?: string
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
): Promise<{ subscriptionId: string; requestId: string }> => {
|
||||
const subscriptionId =
|
||||
@@ -180,7 +181,8 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
retained?.removeDestroyedListener()
|
||||
remoteRuntimeSubscriptions.delete(subscriptionId)
|
||||
}
|
||||
}
|
||||
},
|
||||
args.expectedRuntimeId
|
||||
)
|
||||
} catch (error) {
|
||||
removeDestroyedListener()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeMobileSessionClientTab } from '../../shared/runtime-types'
|
||||
import { nativeChatTranscriptIncludesPath } from './native-chat-file-provenance'
|
||||
|
||||
function terminalTab(): RuntimeMobileSessionClientTab {
|
||||
return {
|
||||
type: 'terminal',
|
||||
id: 'tab-1',
|
||||
title: 'Codex',
|
||||
parentTabId: 'parent-1',
|
||||
leafId: 'leaf-1',
|
||||
launchAgent: 'codex',
|
||||
agentStatus: {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: 'parent-1:leaf-1',
|
||||
stateHistory: [],
|
||||
agentType: 'codex',
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'session-1',
|
||||
transcriptPath: '/host/transcripts/session-1.jsonl'
|
||||
}
|
||||
},
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'term-1'
|
||||
}
|
||||
}
|
||||
|
||||
function message(role: NativeChatMessage['role'], text: string): NativeChatMessage {
|
||||
return {
|
||||
id: `${role}-1`,
|
||||
role,
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
describe('nativeChatTranscriptIncludesPath', () => {
|
||||
it('accepts a path in recent assistant output from the host-bound session', async () => {
|
||||
const readTranscript = vi.fn(async () => ({
|
||||
messages: [message('assistant', 'Open ~/orca-plans/result.html to review it.')]
|
||||
}))
|
||||
|
||||
await expect(
|
||||
nativeChatTranscriptIncludesPath({
|
||||
tabs: [terminalTab()],
|
||||
context: { tabId: 'tab-1', sessionId: 'session-1' },
|
||||
pathText: '~/orca-plans/result.html',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
readTranscript
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(readTranscript).toHaveBeenCalledWith({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
transcriptPath: '/host/transcripts/session-1.jsonl',
|
||||
limit: 2000
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a client session identity that does not match the host tab', async () => {
|
||||
const readTranscript = vi.fn()
|
||||
|
||||
await expect(
|
||||
nativeChatTranscriptIncludesPath({
|
||||
tabs: [terminalTab()],
|
||||
context: { tabId: 'tab-1', sessionId: 'forged-session' },
|
||||
pathText: '/etc/passwd',
|
||||
absolutePath: '/etc/passwd',
|
||||
readTranscript
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(readTranscript).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a path followed by a sentence-final period', async () => {
|
||||
const readTranscript = vi.fn(async () => ({
|
||||
messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.')]
|
||||
}))
|
||||
|
||||
await expect(
|
||||
nativeChatTranscriptIncludesPath({
|
||||
tabs: [terminalTab()],
|
||||
context: { tabId: 'tab-1', sessionId: 'session-1' },
|
||||
pathText: '/tmp/orca-pr14166-external.txt',
|
||||
absolutePath: '/tmp/orca-pr14166-external.txt',
|
||||
readTranscript
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('does not accept a longer filename sharing the requested path prefix', async () => {
|
||||
const readTranscript = vi.fn(async () => ({
|
||||
messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.backup')]
|
||||
}))
|
||||
|
||||
await expect(
|
||||
nativeChatTranscriptIncludesPath({
|
||||
tabs: [terminalTab()],
|
||||
context: { tabId: 'tab-1', sessionId: 'session-1' },
|
||||
pathText: '/tmp/orca-pr14166-external.txt',
|
||||
absolutePath: '/tmp/orca-pr14166-external.txt',
|
||||
readTranscript
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat user-authored transcript text as agent provenance', async () => {
|
||||
const readTranscript = vi.fn(async () => ({
|
||||
messages: [message('user', 'Please open /etc/passwd')]
|
||||
}))
|
||||
|
||||
await expect(
|
||||
nativeChatTranscriptIncludesPath({
|
||||
tabs: [terminalTab()],
|
||||
context: { tabId: 'tab-1', sessionId: 'session-1' },
|
||||
pathText: '/etc/passwd',
|
||||
absolutePath: '/etc/passwd',
|
||||
readTranscript
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
||||
import type {
|
||||
RuntimeMobileSessionClientTab,
|
||||
RuntimeMobileSessionTerminalClientTab,
|
||||
RuntimeNativeChatFileContext
|
||||
} from '../../shared/runtime-types'
|
||||
import { recentTerminalOutputIncludesPath } from '../runtime/terminal-output-path-candidates'
|
||||
import { readNativeChatTranscriptTail } from './transcript-tail-reader'
|
||||
|
||||
// Match the largest transcript window mobile can render so any visible citation remains provable.
|
||||
const NATIVE_CHAT_FILE_PROVENANCE_WINDOW = 2000
|
||||
|
||||
type TranscriptRead = (args: {
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
transcriptPath?: string
|
||||
limit: number
|
||||
}) => Promise<{ messages: NativeChatMessage[] } | { error: string }>
|
||||
|
||||
function transcriptTextIncludesPath(text: string, pathText: string, absolutePath: string): boolean {
|
||||
if (recentTerminalOutputIncludesPath(text, pathText, absolutePath)) {
|
||||
return true
|
||||
}
|
||||
// Mobile excludes a sentence-final period from the tappable path span.
|
||||
return recentTerminalOutputIncludesPath(text, `${pathText}.`, `${absolutePath}.`)
|
||||
}
|
||||
|
||||
export async function nativeChatTranscriptIncludesPath(args: {
|
||||
tabs: readonly RuntimeMobileSessionClientTab[]
|
||||
context: RuntimeNativeChatFileContext
|
||||
pathText: string
|
||||
absolutePath: string
|
||||
readTranscript?: TranscriptRead
|
||||
}): Promise<boolean> {
|
||||
const tab = args.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalClientTab =>
|
||||
candidate.type === 'terminal' && candidate.id === args.context.tabId
|
||||
)
|
||||
const providerSession = tab?.agentStatus?.providerSession
|
||||
const agent = tab?.agentStatus?.agentType ?? tab?.launchAgent
|
||||
if (
|
||||
!tab ||
|
||||
!providerSession ||
|
||||
providerSession.id !== args.context.sessionId ||
|
||||
!agent ||
|
||||
!resolveNativeChatTranscriptAgent(agent)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await (args.readTranscript ?? readNativeChatTranscriptTail)({
|
||||
agent,
|
||||
sessionId: providerSession.id,
|
||||
...(providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}),
|
||||
limit: NATIVE_CHAT_FILE_PROVENANCE_WINDOW
|
||||
})
|
||||
if (!('messages' in result)) {
|
||||
return false
|
||||
}
|
||||
return result.messages.some(
|
||||
(message) =>
|
||||
message.role === 'assistant' &&
|
||||
message.blocks.some(
|
||||
(block) =>
|
||||
block.type === 'text' &&
|
||||
transcriptTextIncludesPath(block.text, args.pathText, args.absolutePath)
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { readTranscriptSlice, wslGatedStat } from './wsl-transcript-fs-access'
|
||||
|
||||
const BOUNDARY_FINGERPRINT_BYTES = 64
|
||||
|
||||
export type TranscriptFileVersion = {
|
||||
identity: string
|
||||
@@ -7,8 +9,11 @@ export type TranscriptFileVersion = {
|
||||
ctimeMs: number
|
||||
}
|
||||
|
||||
export async function readTranscriptFileVersion(filePath: string): Promise<TranscriptFileVersion> {
|
||||
const value = await stat(filePath)
|
||||
export async function readTranscriptFileVersion(
|
||||
filePath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranscriptFileVersion> {
|
||||
const value = await wslGatedStat(filePath, 'exact', signal)
|
||||
return {
|
||||
identity: `${value.dev}:${value.ino}`,
|
||||
size: value.size,
|
||||
@@ -17,6 +22,19 @@ export async function readTranscriptFileVersion(filePath: string): Promise<Trans
|
||||
}
|
||||
}
|
||||
|
||||
export async function boundaryFingerprint(
|
||||
filePath: string,
|
||||
offset: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
if (offset <= 0) {
|
||||
return ''
|
||||
}
|
||||
const start = Math.max(0, offset - BOUNDARY_FINGERPRINT_BYTES)
|
||||
const slice = await readTranscriptSlice(filePath, start, offset - start, 'exact', signal)
|
||||
return slice.toString('base64')
|
||||
}
|
||||
|
||||
export function transcriptFileVersionChanged(
|
||||
current: TranscriptFileVersion,
|
||||
previous: TranscriptFileVersion
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import { transcriptFallbackId } from './transcript-fallback-id'
|
||||
import {
|
||||
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
|
||||
type NativeChatLineDecoder
|
||||
} from './transcript-tail-reader'
|
||||
import { openTranscriptReadStream, wslGatedStat } from './wsl-transcript-fs-access'
|
||||
|
||||
const APPEND_BATCH_MESSAGE_LIMIT = 40
|
||||
|
||||
@@ -30,16 +30,21 @@ export async function readIncrementalTranscriptMessages(
|
||||
decode: NativeChatLineDecoder,
|
||||
onBatch?: (messages: NativeChatMessage[]) => void,
|
||||
decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null,
|
||||
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void
|
||||
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<NativeChatMessage[]> {
|
||||
const end = (await stat(filePath)).size
|
||||
const end = (await wslGatedStat(filePath, 'exact', signal)).size
|
||||
if (end <= state.offset) {
|
||||
return []
|
||||
}
|
||||
const messages: NativeChatMessage[] = []
|
||||
const handle = await open(filePath, 'r')
|
||||
const stream = openTranscriptReadStream(
|
||||
filePath,
|
||||
{ start: state.offset, end: end - 1 },
|
||||
'exact',
|
||||
signal
|
||||
)
|
||||
try {
|
||||
const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false })
|
||||
let absoluteOffset = state.offset
|
||||
for await (const rawChunk of stream) {
|
||||
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk)
|
||||
@@ -62,7 +67,9 @@ export async function readIncrementalTranscriptMessages(
|
||||
}
|
||||
return messages
|
||||
} finally {
|
||||
await handle.close()
|
||||
// Early exits (throw/oversized-record bail) must not leak the fd or, on
|
||||
// UNC, the gated handle the generator's finally closes.
|
||||
stream.destroy()
|
||||
}
|
||||
|
||||
function retainPart(part: Buffer): void {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolve: vi.fn<() => Promise<string | null>>(),
|
||||
stat: vi.fn(),
|
||||
open: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-file-resolver', () => ({
|
||||
resolveSessionFilePath: mocks.resolve
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
stat: mocks.stat,
|
||||
open: mocks.open
|
||||
}))
|
||||
|
||||
import {
|
||||
clearNativeChatTranscriptCache,
|
||||
readNativeChatTranscriptCached
|
||||
} from './transcript-read-cache'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS,
|
||||
WSL_TRANSCRIPT_FS_SLOW_MESSAGE
|
||||
} from './wsl-transcript-fs-gate'
|
||||
|
||||
const BODY = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'u-0',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'hello' }
|
||||
})}\n`
|
||||
)
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls<T>(value: T): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
releaseStall = () => resolve(value)
|
||||
})
|
||||
}
|
||||
|
||||
function transcriptHandle(body = BODY) {
|
||||
return {
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const slice = body.subarray(position, Math.min(position + length, body.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNativeChatTranscriptCache()
|
||||
mocks.resolve.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
mocks.open.mockReset()
|
||||
releaseStall = undefined
|
||||
mocks.resolve.mockResolvedValue(UNC_PATH)
|
||||
// The file itself never changes across the refusal and the recovery.
|
||||
mocks.stat.mockResolvedValue({ mtimeMs: 42, size: BODY.length })
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('cached native chat transcript read after WSL gate refusals', () => {
|
||||
it('retries a refused stat instead of caching it as a missing file', async () => {
|
||||
mocks.stat.mockReturnValueOnce(stalls({ mtimeMs: 42, size: BODY.length }))
|
||||
const refused = readNativeChatTranscriptCached('claude', 'session-id')
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
|
||||
expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE })
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
mocks.open.mockResolvedValue(transcriptHandle())
|
||||
|
||||
await expect(readNativeChatTranscriptCached('claude', 'session-id')).resolves.toMatchObject({
|
||||
messages: [expect.objectContaining({ role: 'user' })]
|
||||
})
|
||||
expect(mocks.stat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries the body on the next call even though the mtime is unchanged', async () => {
|
||||
mocks.open.mockResolvedValue({
|
||||
read: vi.fn((buffer: Buffer) => stalls({ bytesRead: 0, buffer })),
|
||||
close: vi.fn(async () => {})
|
||||
})
|
||||
const refused = readNativeChatTranscriptCached('claude', 'session-id')
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
expect(await refused).toEqual({ error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE })
|
||||
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
mocks.open.mockResolvedValue(transcriptHandle())
|
||||
|
||||
const recovered = await readNativeChatTranscriptCached('claude', 'session-id')
|
||||
|
||||
expect(mocks.open).toHaveBeenCalledTimes(2)
|
||||
expect(recovered).toMatchObject({
|
||||
messages: [expect.objectContaining({ role: 'user' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolve: vi.fn<() => Promise<string | null>>(),
|
||||
stat: vi.fn(),
|
||||
read: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-file-resolver', () => ({
|
||||
resolveSessionFilePath: mocks.resolve
|
||||
}))
|
||||
vi.mock('./transcript-reader', () => ({
|
||||
readNativeChatTranscript: mocks.read
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
stat: mocks.stat
|
||||
}))
|
||||
|
||||
import {
|
||||
clearNativeChatTranscriptCache,
|
||||
readNativeChatTranscriptCached
|
||||
} from './transcript-read-cache'
|
||||
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
|
||||
|
||||
const SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.resolve.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
mocks.read.mockReset()
|
||||
mocks.resolve.mockResolvedValue(UNC_PATH)
|
||||
clearNativeChatTranscriptCache()
|
||||
})
|
||||
|
||||
describe('cached native chat transcript read with a stalled post-resolution stat', () => {
|
||||
it('reports a retryable error without notFound and leaves nothing cached', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mocks.stat.mockReturnValueOnce(new Promise(() => {}))
|
||||
const stalled = readNativeChatTranscriptCached('claude', 'session-id')
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
|
||||
const result = await stalled
|
||||
expect(result).toEqual({ error: SLOW_MESSAGE })
|
||||
expect(result).not.toHaveProperty('notFound')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-attempts the stat on the next call instead of serving a poisoned entry', async () => {
|
||||
// The stalled task above still holds its permit, so this fast-fails on the
|
||||
// stuck route rather than waiting out a second deadline — but it must still
|
||||
// reach `stat` again once a different distro answers.
|
||||
const healthyPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
mocks.resolve.mockResolvedValue(healthyPath)
|
||||
mocks.stat.mockResolvedValue({ mtimeMs: 5, size: 0 })
|
||||
mocks.read.mockResolvedValue({ messages: [] })
|
||||
|
||||
await readNativeChatTranscriptCached('claude', 'session-id')
|
||||
|
||||
expect(mocks.stat).toHaveBeenCalledWith(healthyPath)
|
||||
})
|
||||
|
||||
it('serves the cached parse rather than a retry banner once the distro stalls', async () => {
|
||||
const path = '\\\\wsl.localhost\\Fedora\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
mocks.resolve.mockResolvedValue(path)
|
||||
mocks.stat.mockResolvedValue({ mtimeMs: 9, size: 4 })
|
||||
mocks.read.mockResolvedValue({ messages: [{ role: 'user', text: 'hi' }] })
|
||||
const warm = await readNativeChatTranscriptCached('claude', 'session-id')
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mocks.stat.mockReturnValueOnce(new Promise(() => {}))
|
||||
const stalled = readNativeChatTranscriptCached('claude', 'session-id')
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
|
||||
// The stat is what the stall refuses; the parse it would have validated is
|
||||
// complete and unchanged, so the chat stays rendered.
|
||||
await expect(stalled).resolves.toBe(warm)
|
||||
expect(mocks.read).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { AgentType } from '../../shared/native-chat-types'
|
||||
import { resolveSessionFilePath } from './session-file-resolver'
|
||||
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
import { wslGatedStat } from './wsl-transcript-fs-access'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE,
|
||||
WSL_TRANSCRIPT_FS_SLOW_MESSAGE,
|
||||
WslTranscriptFsError,
|
||||
wslTranscriptFsRefusal
|
||||
} from './wsl-transcript-fs-gate'
|
||||
|
||||
// Why: both the desktop IPC handler and the runtime RPC handler read the same
|
||||
// host-filesystem transcript, so a single process-global cache keyed by the
|
||||
@@ -72,9 +77,14 @@ function cacheKey(agent: AgentType, filePath: string): string {
|
||||
|
||||
async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
|
||||
try {
|
||||
const stats = await stat(filePath)
|
||||
const stats = await wslGatedStat(filePath, 'exact')
|
||||
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Why: swallowing a gate refusal into an unknown mtime falls through to a
|
||||
// full read that burns a second deadline. Let the caller report it instead.
|
||||
if (err instanceof WslTranscriptFsError) {
|
||||
throw err
|
||||
}
|
||||
return { mtimeMs: Number.NaN, bytes: 0 }
|
||||
}
|
||||
}
|
||||
@@ -105,8 +115,22 @@ export async function readNativeChatTranscriptCached(
|
||||
}
|
||||
|
||||
const key = cacheKey(agent, filePath)
|
||||
const { mtimeMs, bytes } = await fileStat(filePath)
|
||||
const cached = cache.get(key)
|
||||
let mtimeMs: number
|
||||
let bytes: number
|
||||
try {
|
||||
;({ mtimeMs, bytes } = await fileStat(filePath))
|
||||
} catch (err) {
|
||||
// Why: the refusal says the distro stalled, not that this parse went stale —
|
||||
// a complete cached transcript beats a retry banner. With nothing cached the
|
||||
// error stands, and no `notFound`, so the next call re-stats a woken distro.
|
||||
if (cached) {
|
||||
// Bump recency so a session read through a stall survives eviction.
|
||||
setCached(key, cached)
|
||||
return cached.result
|
||||
}
|
||||
return { error: wslTranscriptFsRefusal(err).message }
|
||||
}
|
||||
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
|
||||
// Bump recency so a frequently-read session survives eviction.
|
||||
setCached(key, cached)
|
||||
@@ -114,12 +138,25 @@ export async function readNativeChatTranscriptCached(
|
||||
}
|
||||
|
||||
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
|
||||
if (Number.isFinite(mtimeMs)) {
|
||||
// Why: a body-read refusal is transient unavailability, but the file's mtime
|
||||
// is unchanged by it — caching it would serve the retryable error to every
|
||||
// later call until the transcript itself changes, even after the distro woke.
|
||||
if (Number.isFinite(mtimeMs) && !isGateRefusal(result)) {
|
||||
setCached(key, { result, mtimeMs, bytes })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// The reader flattens a refusal into its message, so that is the only handle
|
||||
// this layer has on one.
|
||||
function isGateRefusal(result: ReadTranscriptResult): boolean {
|
||||
return (
|
||||
'error' in result &&
|
||||
(result.error === WSL_TRANSCRIPT_FS_SLOW_MESSAGE ||
|
||||
result.error === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE)
|
||||
)
|
||||
}
|
||||
|
||||
/** Test-only: drop the transcript parse cache between runs. */
|
||||
export function clearNativeChatTranscriptCache(): void {
|
||||
cache.clear()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ open: vi.fn() }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
open: mocks.open
|
||||
}))
|
||||
|
||||
import { readNativeChatTranscript } from './transcript-reader'
|
||||
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
|
||||
|
||||
const SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
const LINE = JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'hi' }
|
||||
})
|
||||
|
||||
function handleServing(body: Buffer, delayMs = 0) {
|
||||
return {
|
||||
read: vi.fn(
|
||||
(buffer: Buffer, offset: number, length: number, position: number) =>
|
||||
new Promise((resolve) => {
|
||||
const slice = body.subarray(position, Math.min(position + length, body.length))
|
||||
slice.copy(buffer, offset)
|
||||
const settle = () => resolve({ bytesRead: slice.length, buffer })
|
||||
if (delayMs > 0) {
|
||||
setTimeout(settle, delayMs)
|
||||
} else {
|
||||
settle()
|
||||
}
|
||||
})
|
||||
),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.open.mockReset()
|
||||
})
|
||||
|
||||
describe('native chat transcript read with a stalled post-resolution body read', () => {
|
||||
it('surfaces a retryable error without notFound when the first chunk never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mocks.open.mockResolvedValue({
|
||||
read: vi.fn(() => new Promise(() => {})),
|
||||
close: vi.fn(async () => {})
|
||||
})
|
||||
const reading = readNativeChatTranscript('claude', 'session-id', { filePath: UNC_PATH })
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
|
||||
const result = await reading
|
||||
expect(result).toEqual({ error: SLOW_MESSAGE })
|
||||
expect(result).not.toHaveProperty('notFound')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('succeeds when every chunk is slow but under the deadline, past the whole-file budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
const healthyPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
try {
|
||||
const chunkDelayMs = WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS / 2
|
||||
mocks.open.mockResolvedValue(handleServing(Buffer.from(`${LINE}\n`), chunkDelayMs))
|
||||
const reading = readNativeChatTranscript('claude', 'session-id', { filePath: healthyPath })
|
||||
|
||||
// Two chunk admissions (body + EOF probe) span more than one exact deadline.
|
||||
await vi.advanceTimersByTimeAsync(chunkDelayMs * 4)
|
||||
|
||||
const result = await reading
|
||||
expect(result).toMatchObject({ messages: [expect.objectContaining({ id: 'u1' })] })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import type {
|
||||
AgentType,
|
||||
NativeChatMessage,
|
||||
@@ -7,6 +6,7 @@ import type {
|
||||
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
||||
import { errorMessage } from '../ai-vault/session-scanner-values'
|
||||
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
|
||||
import { openTranscriptReadStream } from './wsl-transcript-fs-access'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
import {
|
||||
decodeClaudeTranscriptLine,
|
||||
@@ -82,7 +82,7 @@ async function readTranscript(
|
||||
filePath: string,
|
||||
decode: (line: string, fallbackId: string) => NativeChatMessage | null
|
||||
): Promise<NativeChatMessage[]> {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf-8' })
|
||||
const stream = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'exact')
|
||||
const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true)
|
||||
return messages
|
||||
}
|
||||
|
||||
@@ -32,6 +32,23 @@ describe('decodeTranscriptStream', () => {
|
||||
expect(incremental.messages[0]?.id).toBe(full.messages[1]?.id)
|
||||
})
|
||||
|
||||
it('keeps a codepoint split across two Buffer chunks intact', async () => {
|
||||
const line = `{"text":"😀é中"}\n`
|
||||
const bytes = Buffer.from(line, 'utf8')
|
||||
// Split inside the emoji's 4-byte sequence, as a 1 MiB gated chunk boundary does.
|
||||
const split = Buffer.from('{"text":"', 'utf8').length + 2
|
||||
const result = await decodeTranscriptStream(
|
||||
Readable.from([bytes.subarray(0, split), bytes.subarray(split)]),
|
||||
'/chat.jsonl',
|
||||
0,
|
||||
decode,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: line.slice(0, -1) })
|
||||
expect(result.consumedBytes).toBe(bytes.length)
|
||||
})
|
||||
|
||||
it('does not consume a partial trailing JSONL record', async () => {
|
||||
const complete = '{"first":true}\n'
|
||||
const partial = '{"second"'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { transcriptFallbackId } from './transcript-fallback-id'
|
||||
|
||||
@@ -12,11 +13,14 @@ export async function decodeTranscriptStream(
|
||||
includeTrailingLine: boolean
|
||||
): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
|
||||
const messages: NativeChatMessage[] = []
|
||||
// Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would
|
||||
// both corrupt the line and shift `consumedBytes` (which seeds fallback ids).
|
||||
const decoder = new StringDecoder('utf8')
|
||||
let pending = ''
|
||||
let consumedBytes = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
||||
pending += typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk))
|
||||
let newlineIndex = pending.indexOf('\n')
|
||||
while (newlineIndex !== -1) {
|
||||
const segment = pending.slice(0, newlineIndex + 1)
|
||||
@@ -26,6 +30,7 @@ export async function decodeTranscriptStream(
|
||||
newlineIndex = pending.indexOf('\n')
|
||||
}
|
||||
}
|
||||
pending += decoder.end()
|
||||
|
||||
if (includeTrailingLine && pending.length > 0) {
|
||||
decodeLine(pending, consumedBytes)
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolve: vi.fn<() => Promise<string | null>>()
|
||||
resolve: vi.fn<() => Promise<string | null>>(),
|
||||
stat: vi.fn(),
|
||||
open: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-file-resolver', () => ({
|
||||
resolveSessionFilePath: mocks.resolve
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
stat: mocks.stat,
|
||||
open: mocks.open
|
||||
}))
|
||||
|
||||
import { readNativeChatTranscriptTail } from './transcript-tail-reader'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
const SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
describe('native chat transcript tail under WSL gate refusals', () => {
|
||||
beforeEach(() => {
|
||||
@@ -32,3 +44,69 @@ describe('native chat transcript tail under WSL gate refusals', () => {
|
||||
).rejects.toThrow('resolver crashed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('native chat transcript tail with stalled post-resolution UNC I/O', () => {
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl'
|
||||
// A stalled task holds its gate permit until the underlying call settles, so
|
||||
// each case releases its stall before the next one runs.
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls<T>(): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
releaseStall = () => resolve(undefined as T)
|
||||
})
|
||||
}
|
||||
|
||||
async function tailAfterStall(): Promise<unknown> {
|
||||
const pending = readNativeChatTranscriptTail({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-id',
|
||||
filePath: UNC_PATH,
|
||||
limit: 10
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
return pending
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.stat.mockReset()
|
||||
mocks.open.mockReset()
|
||||
releaseStall = undefined
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
releaseStall?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reports a stalled stat as retryable without notFound', async () => {
|
||||
mocks.stat.mockImplementation(stalls)
|
||||
const result = await tailAfterStall()
|
||||
expect(result).toEqual({ error: SLOW_MESSAGE })
|
||||
expect(result).not.toHaveProperty('notFound')
|
||||
})
|
||||
|
||||
it('reports a stalled open as retryable without notFound', async () => {
|
||||
mocks.stat.mockResolvedValue({ size: 4096 })
|
||||
mocks.open.mockImplementation(stalls)
|
||||
const result = await tailAfterStall()
|
||||
expect(result).toEqual({ error: SLOW_MESSAGE })
|
||||
expect(result).not.toHaveProperty('notFound')
|
||||
})
|
||||
|
||||
it('closes the handle without a second admission when a positional read stalls', async () => {
|
||||
const close = vi.fn(async () => {})
|
||||
mocks.stat.mockResolvedValue({ size: 4096 })
|
||||
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close })
|
||||
|
||||
const result = await tailAfterStall()
|
||||
|
||||
expect(result).toEqual({ error: SLOW_MESSAGE })
|
||||
expect(result).not.toHaveProperty('notFound')
|
||||
// Fire-and-forget close: never gated, never awaited, exactly once.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import type {
|
||||
AgentType,
|
||||
NativeChatMessage,
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
nativeChatTurnLifecycleDecoderForAgent,
|
||||
type NativeChatTurnLifecycleDecoder
|
||||
} from './transcript-turn-lifecycle'
|
||||
import {
|
||||
closeTranscriptHandle,
|
||||
wslGatedOpen,
|
||||
wslGatedRead,
|
||||
wslGatedStat
|
||||
} from './wsl-transcript-fs-access'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
|
||||
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
|
||||
@@ -59,12 +65,15 @@ export async function readNativeChatTranscriptTailFile(
|
||||
oversizedRecordCount?: number
|
||||
}> {
|
||||
signal?.throwIfAborted()
|
||||
const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER)
|
||||
const end = Math.min(
|
||||
(await wslGatedStat(filePath, 'exact', signal)).size,
|
||||
endOffset ?? Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
if (end === 0) {
|
||||
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
|
||||
}
|
||||
const handle = await open(filePath, 'r')
|
||||
const handle = await wslGatedOpen(filePath, 'exact', signal)
|
||||
const lineParts: Buffer[] = []
|
||||
let lineBytes = 0
|
||||
let lineOversized = false
|
||||
@@ -76,13 +85,13 @@ export async function readNativeChatTranscriptTailFile(
|
||||
signal?.throwIfAborted()
|
||||
const consumedTo = includeTrailingLine
|
||||
? end
|
||||
: await findLastCompleteLineEnd(handle, end, signal)
|
||||
: await findLastCompleteLineEnd(handle, filePath, end, signal)
|
||||
if (consumedTo === 0) {
|
||||
return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 }
|
||||
}
|
||||
const newestFirst: { message: NativeChatMessage; offset: number }[] = []
|
||||
const finalByte = Buffer.allocUnsafe(1)
|
||||
await handle.read(finalByte, 0, 1, consumedTo - 1)
|
||||
await wslGatedRead(handle, filePath, finalByte, 0, 1, consumedTo - 1, 'exact', signal)
|
||||
signal?.throwIfAborted()
|
||||
ignoreNextMalformedRecord = finalByte[0] !== 0x0a
|
||||
let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0)
|
||||
@@ -90,7 +99,16 @@ export async function readNativeChatTranscriptTailFile(
|
||||
signal?.throwIfAborted()
|
||||
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
|
||||
const buffer = Buffer.allocUnsafe(cursor - start)
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
|
||||
const { bytesRead } = await wslGatedRead(
|
||||
handle,
|
||||
filePath,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
start,
|
||||
'exact',
|
||||
signal
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
let segmentEnd = bytesRead
|
||||
for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) {
|
||||
@@ -126,7 +144,7 @@ export async function readNativeChatTranscriptTailFile(
|
||||
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
await closeTranscriptHandle(handle, filePath)
|
||||
}
|
||||
|
||||
function retainPart(part: Buffer): void {
|
||||
@@ -184,13 +202,14 @@ export async function readNativeChatTranscriptTailFile(
|
||||
}
|
||||
|
||||
async function findLastCompleteLineEnd(
|
||||
handle: Awaited<ReturnType<typeof open>>,
|
||||
handle: FileHandle,
|
||||
filePath: string,
|
||||
end: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
signal?.throwIfAborted()
|
||||
const lastByte = Buffer.allocUnsafe(1)
|
||||
await handle.read(lastByte, 0, 1, end - 1)
|
||||
await wslGatedRead(handle, filePath, lastByte, 0, 1, end - 1, 'exact', signal)
|
||||
signal?.throwIfAborted()
|
||||
if (lastByte[0] === 0x0a) {
|
||||
return end
|
||||
@@ -200,7 +219,16 @@ async function findLastCompleteLineEnd(
|
||||
signal?.throwIfAborted()
|
||||
const start = Math.max(0, cursor - TAIL_CHUNK_BYTES)
|
||||
const buffer = Buffer.allocUnsafe(cursor - start)
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
|
||||
const { bytesRead } = await wslGatedRead(
|
||||
handle,
|
||||
filePath,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
start,
|
||||
'exact',
|
||||
signal
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a)
|
||||
if (newline !== -1) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import {
|
||||
boundaryFingerprint,
|
||||
readTranscriptFileVersion,
|
||||
transcriptFileVersionChanged,
|
||||
type TranscriptFileVersion
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
SubscribeNativeChatTranscriptArgs
|
||||
} from './transcript-watch-contract'
|
||||
import { createTranscriptWatchScheduler } from './transcript-watch-scheduler'
|
||||
import { wslGatedStat } from './wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
|
||||
|
||||
const ROTATION_RETRY_MS = 25
|
||||
const MAX_ROTATION_RETRY_MS = 2_000
|
||||
@@ -27,21 +29,6 @@ export function getActiveNativeChatWatcherCount(): number {
|
||||
return activeWatcherCount
|
||||
}
|
||||
|
||||
async function boundaryFingerprint(filePath: string, offset: number): Promise<string> {
|
||||
if (offset <= 0) {
|
||||
return ''
|
||||
}
|
||||
const start = Math.max(0, offset - 64)
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(offset - start)
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
|
||||
return buffer.subarray(0, bytesRead).toString('base64')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the live-tail engine on an already-resolved file path. Returns null
|
||||
* when the file doesn't exist yet, so the caller falls back to resolve-polling.
|
||||
@@ -51,11 +38,20 @@ async function boundaryFingerprint(filePath: string, offset: number): Promise<st
|
||||
export async function installTranscriptWatcher(
|
||||
filePath: string,
|
||||
decode: (line: string, fallbackId: string) => NativeChatMessage | null,
|
||||
args: SubscribeNativeChatTranscriptArgs
|
||||
args: SubscribeNativeChatTranscriptArgs,
|
||||
/** Cancels the install probe so an unsubscribe during it detaches the gate
|
||||
* waiter immediately instead of at the 30s deadline. */
|
||||
signal?: AbortSignal
|
||||
): Promise<NativeChatTranscriptSubscription | null> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch {
|
||||
await wslGatedStat(filePath, 'exact', signal)
|
||||
} catch (error) {
|
||||
// Why: "not flushed yet" degrades to resolve-polling, but a stalled distro
|
||||
// must reach the caller so it can surface a retryable message instead of
|
||||
// stranding the client at `loading`.
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args
|
||||
@@ -75,6 +71,11 @@ export async function installTranscriptWatcher(
|
||||
// a persistently-failing retry loop can't spam the subscriber with error frames.
|
||||
let initialErrorEmitted = false
|
||||
let closed = false
|
||||
// Why: every gated call on the drain path must detach the moment we
|
||||
// unsubscribe, instead of holding a waiter until its 30s deadline, and an
|
||||
// aborted signal also makes the gate refuse admission for anything the
|
||||
// in-flight drain would start after teardown.
|
||||
const gateAbort = new AbortController()
|
||||
let reading = false
|
||||
let pendingReadRequested = false
|
||||
let rotationRetryCount = 0
|
||||
@@ -106,7 +107,8 @@ export async function installTranscriptWatcher(
|
||||
decodeLifecycle ?? undefined,
|
||||
(nextLifecycle) => {
|
||||
lifecycle = nextLifecycle
|
||||
}
|
||||
},
|
||||
gateAbort.signal
|
||||
)
|
||||
if (!closed && (remaining.length > 0 || lifecycle)) {
|
||||
onAppend(remaining, lifecycle)
|
||||
@@ -114,8 +116,8 @@ export async function installTranscriptWatcher(
|
||||
}
|
||||
|
||||
async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise<void> {
|
||||
watchedBoundary = await boundaryFingerprint(filePath, state.offset)
|
||||
const completedVersion = await readTranscriptFileVersion(filePath)
|
||||
watchedBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
|
||||
const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
||||
if (transcriptFileVersionChanged(completedVersion, startVersion)) {
|
||||
// Why: a write racing this drain needs another pass even when the reader
|
||||
// happened to reach its new EOF; timestamp-only rewrites may need replace.
|
||||
@@ -135,8 +137,8 @@ export async function installTranscriptWatcher(
|
||||
}
|
||||
|
||||
async function drainOnce(): Promise<void> {
|
||||
const current = await readTranscriptFileVersion(filePath)
|
||||
const currentBoundary = await boundaryFingerprint(filePath, state.offset)
|
||||
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
||||
const currentBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -168,7 +170,8 @@ export async function installTranscriptWatcher(
|
||||
decode,
|
||||
false,
|
||||
undefined,
|
||||
decodeLifecycle
|
||||
decodeLifecycle,
|
||||
gateAbort.signal
|
||||
)
|
||||
: null
|
||||
if (closed) {
|
||||
@@ -196,7 +199,8 @@ export async function installTranscriptWatcher(
|
||||
decode,
|
||||
false,
|
||||
undefined,
|
||||
decodeLifecycle
|
||||
decodeLifecycle,
|
||||
gateAbort.signal
|
||||
)
|
||||
: null
|
||||
if (closed) {
|
||||
@@ -225,7 +229,8 @@ export async function installTranscriptWatcher(
|
||||
decodeLifecycle ?? undefined,
|
||||
(nextLifecycle) => {
|
||||
lifecycle = nextLifecycle
|
||||
}
|
||||
},
|
||||
gateAbort.signal
|
||||
)
|
||||
if (closed) {
|
||||
return
|
||||
@@ -253,7 +258,7 @@ export async function installTranscriptWatcher(
|
||||
pendingReadRequested = false
|
||||
try {
|
||||
await drainOnce()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Why: unlink/recreate can detach fs.watch from the pathname. Keep one
|
||||
// capped-backoff retry alive until a successor appears or we unsubscribe.
|
||||
// A still-pending initial drain also surfaces one error snapshot so a
|
||||
@@ -261,7 +266,12 @@ export async function installTranscriptWatcher(
|
||||
// throwing; initialDrain stays true so a recovered read can still win.
|
||||
if (!closed && initialDrain && onInitialSnapshot && !initialErrorEmitted) {
|
||||
initialErrorEmitted = true
|
||||
onInitialSnapshot([], false, 0, 'Transcript unavailable')
|
||||
onInitialSnapshot(
|
||||
[],
|
||||
false,
|
||||
0,
|
||||
error instanceof WslTranscriptFsError ? error.message : 'Transcript unavailable'
|
||||
)
|
||||
}
|
||||
scheduleRotationRetry()
|
||||
break
|
||||
@@ -277,7 +287,7 @@ export async function installTranscriptWatcher(
|
||||
return
|
||||
}
|
||||
try {
|
||||
const current = await readTranscriptFileVersion(filePath)
|
||||
const current = await readTranscriptFileVersion(filePath, gateAbort.signal)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -317,6 +327,7 @@ export async function installTranscriptWatcher(
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
gateAbort.abort(new Error('Native Chat transcript watcher unsubscribed'))
|
||||
scheduler.dispose()
|
||||
nativeWatcher.dispose()
|
||||
activeWatcherCount--
|
||||
|
||||
@@ -68,7 +68,12 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => {
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(mocks.install).toHaveBeenCalledWith(ROLLOUT_UNC, expect.anything(), expect.anything())
|
||||
expect(mocks.install).toHaveBeenCalledWith(
|
||||
ROLLOUT_UNC,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.install.mock.calls.every(([path]) => path !== ROLLOUT_LINUX)).toBe(true)
|
||||
subscription.unsubscribe()
|
||||
})
|
||||
@@ -84,7 +89,12 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => {
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(mocks.install).toHaveBeenCalledWith(ROLLOUT_LINUX, expect.anything(), expect.anything())
|
||||
expect(mocks.install).toHaveBeenCalledWith(
|
||||
ROLLOUT_LINUX,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
subscription.unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\session.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolve: vi.fn<() => Promise<string | null>>(),
|
||||
stat: vi.fn(),
|
||||
open: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-file-resolver', () => ({ resolveSessionFilePath: mocks.resolve }))
|
||||
vi.mock('./transcript-native-watcher', () => ({
|
||||
createTranscriptNativeWatcher: () => ({
|
||||
bind: () => true,
|
||||
needsRebind: () => false,
|
||||
invalidate: () => {},
|
||||
dispose: () => {}
|
||||
})
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
stat: mocks.stat,
|
||||
open: mocks.open
|
||||
}))
|
||||
|
||||
import { subscribeNativeChatTranscript } from './transcript-watch'
|
||||
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
|
||||
|
||||
const SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
type Snapshot = [NativeChatMessage[], boolean, number, string | undefined]
|
||||
|
||||
let releaseStall: (() => void) | undefined
|
||||
let unhandled: unknown[] = []
|
||||
|
||||
function stalls<T>(): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
releaseStall = () => resolve(undefined as T)
|
||||
})
|
||||
}
|
||||
|
||||
function subscribeCollecting(snapshots: Snapshot[]) {
|
||||
return subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'session-id',
|
||||
onAppend: () => {},
|
||||
onInitialSnapshot: (messages, hasMore, beforeOffset, error) => {
|
||||
snapshots.push([messages, hasMore, beforeOffset, error])
|
||||
},
|
||||
initialLimit: 10,
|
||||
resolvePollIntervalMs: 50,
|
||||
reconciliationIntervalMs: 1_000
|
||||
})
|
||||
}
|
||||
|
||||
function trackUnhandled(reason: unknown): void {
|
||||
unhandled.push(reason)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.resolve.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
mocks.open.mockReset()
|
||||
releaseStall = undefined
|
||||
unhandled = []
|
||||
process.on('unhandledRejection', trackUnhandled)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
releaseStall?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
process.off('unhandledRejection', trackUnhandled)
|
||||
})
|
||||
|
||||
const EMPTY_STATS = { size: 0, mtimeMs: 1, ctimeMs: 1, ino: 1, dev: 1, mtime: new Date(0) }
|
||||
|
||||
describe('native chat transcript subscription with a stalled install stat', () => {
|
||||
it('keeps watching and emits exactly one retryable snapshot, then the real one', async () => {
|
||||
mocks.resolve.mockResolvedValue(UNC_PATH)
|
||||
mocks.stat.mockImplementationOnce(stalls).mockResolvedValue(EMPTY_STATS)
|
||||
const snapshots: Snapshot[] = []
|
||||
|
||||
// Not awaited yet: the setup install is itself blocked on the stalled stat.
|
||||
const subscribing = subscribeCollecting(snapshots)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
const subscription = await subscribing
|
||||
expect(subscription.watching).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
expect(snapshots).toEqual([[[], false, 0, SLOW_MESSAGE]])
|
||||
|
||||
// Later poll ticks fast-fail on the still-stuck route without re-emitting.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(snapshots).toHaveLength(1)
|
||||
|
||||
// The distro wakes: the same subscription still delivers a real snapshot.
|
||||
releaseStall?.()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(snapshots.length).toBeGreaterThan(1)
|
||||
expect(snapshots.at(-1)?.[3]).toBeUndefined()
|
||||
subscription.unsubscribe()
|
||||
expect(unhandled).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the poll loop alive on a non-gate resolve failure without emitting a frame', async () => {
|
||||
// First resolve misses (transcript not flushed) so setup falls through to
|
||||
// the poll loop; every later tick throws a non-gate error.
|
||||
mocks.resolve
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }))
|
||||
const snapshots: Snapshot[] = []
|
||||
|
||||
const subscription = await subscribeCollecting(snapshots)
|
||||
const attemptsAfterSetup = mocks.resolve.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(snapshots).toEqual([])
|
||||
// Still retrying: the rethrow-free branch must not kill `void runAttempt()`.
|
||||
expect(mocks.resolve.mock.calls.length).toBeGreaterThan(attemptsAfterSetup)
|
||||
expect(unhandled).toEqual([])
|
||||
subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('detaches drain waiters on unsubscribe and starts no further gated work', async () => {
|
||||
mocks.resolve.mockResolvedValue(UNC_PATH)
|
||||
mocks.stat.mockResolvedValue({ ...EMPTY_STATS, size: 64 })
|
||||
let releaseOpen: ((handle: unknown) => void) | undefined
|
||||
mocks.open.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
releaseOpen = resolve
|
||||
})
|
||||
)
|
||||
const handle = {
|
||||
read: vi.fn(async () => ({ bytesRead: 0, buffer: Buffer.alloc(0) })),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
|
||||
const subscription = await subscribeCollecting([])
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(mocks.open).toHaveBeenCalledTimes(1)
|
||||
const statCallsAtTeardown = mocks.stat.mock.calls.length
|
||||
|
||||
subscription.unsubscribe()
|
||||
// No timer advance: the waiter must detach on unsubscribe, not 30s later.
|
||||
releaseOpen?.(handle)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// The drain that was in flight must not admit anything more.
|
||||
expect(handle.read).not.toHaveBeenCalled()
|
||||
expect(mocks.stat.mock.calls.length).toBe(statCallsAtTeardown)
|
||||
// Nobody is left to own the late handle, so the gate closes it.
|
||||
expect(handle.close).toHaveBeenCalledTimes(1)
|
||||
expect(unhandled).toEqual([])
|
||||
})
|
||||
|
||||
it('tears down cleanly while an admission is still stalled', async () => {
|
||||
mocks.resolve.mockResolvedValue(UNC_PATH)
|
||||
mocks.stat.mockImplementation(stalls)
|
||||
const snapshots: Snapshot[] = []
|
||||
|
||||
const subscribing = subscribeCollecting(snapshots)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
const subscription = await subscribing
|
||||
|
||||
expect(() => subscription.unsubscribe()).not.toThrow()
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
expect(unhandled).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
SubscribeNativeChatTranscriptArgs
|
||||
} from './transcript-watch-contract'
|
||||
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
|
||||
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
||||
|
||||
export { readNativeChatTranscriptTail } from './transcript-tail-reader'
|
||||
export { getActiveNativeChatWatcherCount } from './transcript-watch-engine'
|
||||
@@ -33,7 +33,7 @@ async function attemptInstall(
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
const installed = await installTranscriptWatcher(filePath, decode, args)
|
||||
const installed = await installTranscriptWatcher(filePath, decode, args, signal)
|
||||
if (signal?.aborted) {
|
||||
installed?.unsubscribe()
|
||||
signal.throwIfAborted()
|
||||
@@ -78,6 +78,9 @@ function subscribeViaResolvePoll(
|
||||
// the exact-path install doesn't wait on the slower id-glob (#10326).
|
||||
let hostReadableExactPath: string | null = null
|
||||
let lastWslTranslateAt = 0
|
||||
// Latches only once a frame was actually emitted, so a subscriber without the
|
||||
// callback can't suppress it for a later one.
|
||||
let gateErrorEmitted = false
|
||||
const resolveController = new AbortController()
|
||||
|
||||
function scheduleAttempt(): void {
|
||||
@@ -141,9 +144,18 @@ function subscribeViaResolvePoll(
|
||||
lastFallbackResolveAt = Date.now()
|
||||
result = await attemptInstall(args, decode, resolveController.signal)
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Why: a transient resolve failure (EACCES/EIO during the glob) must not
|
||||
// kill the poll loop with an unhandled rejection — retry like a miss.
|
||||
// kill the poll loop with an unhandled rejection — retry like a miss. A
|
||||
// stalled WSL distro would otherwise poll silently forever, leaving the
|
||||
// client at 'loading'; emit its retryable message once and keep polling,
|
||||
// so a later tick's real snapshot still replaces it. Narrowed with a bare
|
||||
// instanceof, never wslTranscriptFsRefusal — that helper rethrows, and
|
||||
// runAttempt is invoked as `void runAttempt()`.
|
||||
if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) {
|
||||
gateErrorEmitted = true
|
||||
args.onInitialSnapshot([], false, 0, error.message)
|
||||
}
|
||||
result = null
|
||||
}
|
||||
if (closed) {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsModule from 'node:fs'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type * as GateModule from './wsl-transcript-fs-gate'
|
||||
|
||||
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl'
|
||||
const OTHER_DISTRO_UNC_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\a.jsonl'
|
||||
const LEGACY_UNC_PATH = '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\a.jsonl'
|
||||
const WINDOWS_PATH = 'C:\\Users\\ada\\.codex\\sessions\\a.jsonl'
|
||||
const POSIX_PATH = '/home/ada/.codex/sessions/a.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
stat: vi.fn(),
|
||||
lstat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
open: vi.fn(),
|
||||
createReadStream: vi.fn(),
|
||||
runTask: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsModule>()),
|
||||
createReadStream: mocks.createReadStream
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
stat: mocks.stat,
|
||||
lstat: mocks.lstat,
|
||||
readdir: mocks.readdir,
|
||||
readFile: mocks.readFile,
|
||||
open: mocks.open
|
||||
}))
|
||||
|
||||
vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof GateModule>()
|
||||
mocks.runTask.mockImplementation(original.runWslTranscriptFsTask)
|
||||
return { ...original, runWslTranscriptFsTask: mocks.runTask }
|
||||
})
|
||||
|
||||
import {
|
||||
closeTranscriptHandle,
|
||||
openTranscriptReadStream,
|
||||
readTranscriptSlice,
|
||||
wslGatedLstat,
|
||||
wslGatedOpen,
|
||||
wslGatedRead,
|
||||
wslGatedReaddir,
|
||||
wslGatedReadFile,
|
||||
wslGatedStat,
|
||||
WSL_TRANSCRIPT_READ_CHUNK_BYTES
|
||||
} from './wsl-transcript-fs-access'
|
||||
import {
|
||||
resetWslTranscriptFsGateForTests,
|
||||
WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from './wsl-transcript-fs-gate'
|
||||
|
||||
function fakeHandle() {
|
||||
return { read: vi.fn(), close: vi.fn(async () => {}) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The gate mock delegates to the real implementation, so its module state
|
||||
// survives between cases — a case that leaves a task stalled would otherwise
|
||||
// mark that route stuck and fast-fail every later case on it.
|
||||
resetWslTranscriptFsGateForTests()
|
||||
// runTask keeps the real gate implementation installed by the mock factory;
|
||||
// only its call log is cleared.
|
||||
mocks.runTask.mockClear()
|
||||
for (const [name, mock] of Object.entries(mocks)) {
|
||||
if (name !== 'runTask') {
|
||||
mock.mockReset()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('transcript filesystem accessor off WSL UNC', () => {
|
||||
it.each([
|
||||
['a posix path', POSIX_PATH],
|
||||
['a Windows drive path', WINDOWS_PATH]
|
||||
])('never enters the gate for %s', async (_label, path) => {
|
||||
mocks.stat.mockResolvedValue({ size: 1 })
|
||||
mocks.lstat.mockResolvedValue({ size: 1 })
|
||||
mocks.readdir.mockResolvedValue([])
|
||||
mocks.readFile.mockResolvedValue('body')
|
||||
const handle = fakeHandle()
|
||||
handle.read.mockResolvedValue({ bytesRead: 0, buffer: Buffer.alloc(0) })
|
||||
mocks.open.mockResolvedValue(handle)
|
||||
mocks.createReadStream.mockReturnValue('raw-stream')
|
||||
|
||||
await wslGatedStat(path, 'exact')
|
||||
await wslGatedLstat(path, 'scan')
|
||||
await wslGatedReaddir(path, 'scan')
|
||||
await wslGatedReadFile(path, 'utf-8', 'scan')
|
||||
const opened = await wslGatedOpen(path, 'exact')
|
||||
await wslGatedRead(opened, path, Buffer.alloc(1), 0, 1, 0, 'exact')
|
||||
const stream = openTranscriptReadStream(path, { encoding: 'utf-8' }, 'scan')
|
||||
|
||||
expect(mocks.runTask).not.toHaveBeenCalled()
|
||||
expect(mocks.stat).toHaveBeenCalledWith(path)
|
||||
expect(mocks.readdir).toHaveBeenCalledWith(path, { withFileTypes: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith(path, 'utf-8')
|
||||
expect(mocks.open).toHaveBeenCalledWith(path, 'r')
|
||||
// Off UNC the raw stream is handed back verbatim, encoding included.
|
||||
expect(stream).toBe('raw-stream')
|
||||
expect(mocks.createReadStream).toHaveBeenCalledWith(path, {
|
||||
encoding: 'utf-8',
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the caller signal so the local stream honours cancellation', () => {
|
||||
mocks.createReadStream.mockReturnValue('raw-stream')
|
||||
const controller = new AbortController()
|
||||
|
||||
openTranscriptReadStream(POSIX_PATH, { start: 4 }, 'exact', controller.signal)
|
||||
|
||||
expect(mocks.createReadStream).toHaveBeenCalledWith(POSIX_PATH, {
|
||||
start: 4,
|
||||
signal: controller.signal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcript filesystem accessor on WSL UNC', () => {
|
||||
it.each([
|
||||
['wsl.localhost', UNC_PATH],
|
||||
['wsl$', LEGACY_UNC_PATH]
|
||||
])('routes %s paths through the gate', async (_label, path) => {
|
||||
mocks.stat.mockResolvedValue({ size: 7 })
|
||||
mocks.lstat.mockResolvedValue({ size: 7 })
|
||||
mocks.readdir.mockResolvedValue([])
|
||||
mocks.readFile.mockResolvedValue('body')
|
||||
|
||||
await wslGatedStat(path, 'exact')
|
||||
await wslGatedLstat(path, 'scan')
|
||||
await wslGatedReaddir(path, 'scan')
|
||||
await wslGatedReadFile(path, 'utf-8', 'scan')
|
||||
|
||||
expect(mocks.runTask.mock.calls.map(([options]) => options.operation)).toEqual([
|
||||
'stat',
|
||||
'lstat',
|
||||
'readdir',
|
||||
'readfile'
|
||||
])
|
||||
})
|
||||
|
||||
it('opts positional reads and opens out of coalescing', async () => {
|
||||
const handle = fakeHandle()
|
||||
handle.read.mockResolvedValue({ bytesRead: 1, buffer: Buffer.alloc(1) })
|
||||
mocks.open.mockResolvedValue(handle)
|
||||
|
||||
const opened = await wslGatedOpen(UNC_PATH, 'exact')
|
||||
await wslGatedRead(opened, UNC_PATH, Buffer.alloc(1), 0, 1, 0, 'exact')
|
||||
|
||||
for (const call of mocks.runTask.mock.calls) {
|
||||
expect(call[0]).toMatchObject({ dedupe: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a slice and closes the handle even when the read rejects', async () => {
|
||||
const handle = fakeHandle()
|
||||
handle.read.mockRejectedValue(new Error('EIO'))
|
||||
mocks.open.mockResolvedValue(handle)
|
||||
|
||||
await expect(readTranscriptSlice(UNC_PATH, 4, 8, 'scan')).rejects.toThrow('EIO')
|
||||
expect(handle.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('yields Buffer chunks and closes the handle when the consumer destroys the stream', async () => {
|
||||
const handle = fakeHandle()
|
||||
handle.read.mockImplementation(
|
||||
async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const body = Buffer.from('{"a":1}\n{"b":2}\n')
|
||||
const slice = body.subarray(position, Math.min(position + length, body.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}
|
||||
)
|
||||
mocks.open.mockResolvedValue(handle)
|
||||
|
||||
const stream = openTranscriptReadStream(UNC_PATH, {}, 'exact')
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
break
|
||||
}
|
||||
stream.destroy()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(Buffer.isBuffer(chunks[0])).toBe(true)
|
||||
expect((chunks[0] as Buffer).toString('utf-8')).toBe('{"a":1}\n{"b":2}\n')
|
||||
expect(handle.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('swallows close failures so teardown never rejects', async () => {
|
||||
const handle = fakeHandle()
|
||||
handle.close.mockRejectedValue(new Error('stalled close'))
|
||||
await expect(closeTranscriptHandle(handle as never, UNC_PATH)).resolves.toBeUndefined()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
})
|
||||
|
||||
it('drains handle closes one at a time so teardown cannot flood the thread pool', async () => {
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const first = fakeHandle()
|
||||
first.close.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
)
|
||||
const second = fakeHandle()
|
||||
|
||||
await closeTranscriptHandle(first as never, UNC_PATH)
|
||||
await closeTranscriptHandle(second as never, UNC_PATH)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// A blocked uv_fs_close holds a libuv thread the gate cannot see, so the
|
||||
// second one must wait rather than occupy a thread of its own.
|
||||
expect(first.close).toHaveBeenCalledTimes(1)
|
||||
expect(second.close).not.toHaveBeenCalled()
|
||||
|
||||
releaseFirst?.()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(second.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a blocked close on one distro from stranding teardown on another', async () => {
|
||||
let releaseStuck: (() => void) | undefined
|
||||
const stuck = fakeHandle()
|
||||
stuck.close.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseStuck = resolve
|
||||
})
|
||||
)
|
||||
const healthy = fakeHandle()
|
||||
|
||||
try {
|
||||
await closeTranscriptHandle(stuck as never, UNC_PATH)
|
||||
await closeTranscriptHandle(healthy as never, OTHER_DISTRO_UNC_PATH)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// A close that never settles on a stalled mount would hold a shared lane
|
||||
// for the process lifetime, leaking every later descriptor with it.
|
||||
expect(stuck.close).toHaveBeenCalledTimes(1)
|
||||
expect(healthy.close).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
releaseStuck?.()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['stat', wslGatedStat, mocks.stat],
|
||||
['lstat', wslGatedLstat, mocks.lstat]
|
||||
])(
|
||||
'detaches a cancelled %s waiter without waiting out the deadline',
|
||||
async (_op, gated, mock) => {
|
||||
let release: (() => void) | undefined
|
||||
mock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve({ size: 0 })
|
||||
})
|
||||
)
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('unsubscribed')
|
||||
const pending = gated(UNC_PATH, 'exact', controller.signal)
|
||||
const settled = pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error
|
||||
)
|
||||
// Let the gate admit and start the syscall, so this covers the waiter on a
|
||||
// RUNNING unabortable call rather than a still-queued one.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mock).toHaveBeenCalledTimes(1)
|
||||
|
||||
controller.abort(reason)
|
||||
|
||||
// No timer advance: the waiter must be gone the moment the caller cancels,
|
||||
// not 30s later when its deadline fires.
|
||||
await expect(settled).resolves.toBe(reason)
|
||||
} finally {
|
||||
release?.()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('closes an abandoned open whose syscall lands after the caller gave up', async () => {
|
||||
vi.useFakeTimers()
|
||||
let release: ((handle: unknown) => void) | undefined
|
||||
mocks.open.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
try {
|
||||
const handle = fakeHandle()
|
||||
const refused = wslGatedOpen(UNC_PATH, 'exact').catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
await expect(refused).resolves.toBeInstanceOf(WslTranscriptFsError)
|
||||
|
||||
release?.(handle)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Nobody received the handle, so the gate owns closing it.
|
||||
expect(handle.close).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcript handle close off WSL UNC', () => {
|
||||
it('awaits teardown and surfaces the failure, as the raw close did', async () => {
|
||||
const handle = fakeHandle()
|
||||
let closed = false
|
||||
handle.close.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
closed = true
|
||||
})
|
||||
|
||||
await closeTranscriptHandle(handle as never, POSIX_PATH)
|
||||
expect(closed).toBe(true)
|
||||
|
||||
handle.close.mockRejectedValue(new Error('EIO on close'))
|
||||
await expect(closeTranscriptHandle(handle as never, WINDOWS_PATH)).rejects.toThrow(
|
||||
'EIO on close'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-chunk admission', () => {
|
||||
it('carries a codepoint straddling the 1 MiB chunk boundary across chunks', async () => {
|
||||
const emoji = Buffer.from('😀', 'utf8')
|
||||
// Two of the emoji's four bytes land in chunk 1, two in chunk 2 — derived
|
||||
// from the production constant so the fixture cannot drift off the boundary.
|
||||
const body = Buffer.concat([
|
||||
Buffer.alloc(WSL_TRANSCRIPT_READ_CHUNK_BYTES - 2, 0x61),
|
||||
emoji,
|
||||
Buffer.from('tail\n', 'utf8')
|
||||
])
|
||||
const handle = fakeHandle()
|
||||
handle.read.mockImplementation(
|
||||
async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const slice = body.subarray(position, Math.min(position + length, body.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}
|
||||
)
|
||||
mocks.open.mockResolvedValue(handle)
|
||||
|
||||
const stream = openTranscriptReadStream(UNC_PATH, { encoding: 'utf-8' }, 'exact')
|
||||
let decoded = ''
|
||||
for await (const chunk of stream) {
|
||||
expect(typeof chunk).toBe('string')
|
||||
decoded += chunk as string
|
||||
}
|
||||
|
||||
expect(decoded).not.toContain('�')
|
||||
expect(decoded).toBe(body.toString('utf8'))
|
||||
expect(handle.read.mock.calls.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('surfaces a gate refusal mid-stream as an error event', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let served = 0
|
||||
const handle = fakeHandle()
|
||||
handle.read.mockImplementation((buffer: Buffer) => {
|
||||
if (served++ === 0) {
|
||||
buffer.write('x')
|
||||
return Promise.resolve({ bytesRead: 1, buffer })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
mocks.open.mockResolvedValue(handle)
|
||||
|
||||
const stream = openTranscriptReadStream(UNC_PATH, {}, 'exact')
|
||||
const drained = (async () => {
|
||||
for await (const chunk of stream) {
|
||||
void chunk
|
||||
}
|
||||
})()
|
||||
const settled = drained.then(
|
||||
() => null,
|
||||
(error: unknown) => error
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1)
|
||||
|
||||
await expect(settled).resolves.toBeInstanceOf(WslTranscriptFsError)
|
||||
expect(handle.close).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { createReadStream, type Dirent, type Stats } from 'node:fs'
|
||||
import { lstat, open, readdir, readFile, stat, type FileHandle } from 'node:fs/promises'
|
||||
import { Readable } from 'node:stream'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { runWslTranscriptFsTask, type WslTranscriptFsTaskPriority } from './wsl-transcript-fs-gate'
|
||||
import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route'
|
||||
|
||||
/** Never nest a gated call inside another — that deadlocks the scan slot. */
|
||||
|
||||
// Why: one deadline per chunk instead of one for the whole file, so a large
|
||||
// healthy-but-slow transcript is not false-failed by a whole-file timeout.
|
||||
export const WSL_TRANSCRIPT_READ_CHUNK_BYTES = 1024 * 1024
|
||||
type Operation = Parameters<typeof runWslTranscriptFsTask>[0]['operation']
|
||||
|
||||
function runPathOperation<T>(
|
||||
operation: Operation,
|
||||
path: string,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal: AbortSignal | undefined,
|
||||
task: () => Promise<T>,
|
||||
options?: { dedupe?: boolean; onAbandonedResult?: (value: T) => void }
|
||||
): Promise<T> {
|
||||
return isWslUncPath(path)
|
||||
? runWslTranscriptFsTask({ operation, path, priority, signal, ...options }, task)
|
||||
: task()
|
||||
}
|
||||
|
||||
export function wslGatedStat(
|
||||
path: string,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<Stats> {
|
||||
return runPathOperation('stat', path, priority, signal, () => stat(path))
|
||||
}
|
||||
|
||||
export function wslGatedLstat(
|
||||
path: string,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<Stats> {
|
||||
return runPathOperation('lstat', path, priority, signal, () => lstat(path))
|
||||
}
|
||||
|
||||
export function wslGatedReaddir(
|
||||
path: string,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<Dirent[]> {
|
||||
return runPathOperation('readdir', path, priority, signal, () =>
|
||||
readdir(path, { withFileTypes: true })
|
||||
)
|
||||
}
|
||||
|
||||
export function wslGatedReadFile(
|
||||
path: string,
|
||||
encoding: BufferEncoding,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
return runPathOperation('readfile', path, priority, signal, () => readFile(path, encoding))
|
||||
}
|
||||
|
||||
// dedupe:false — two joiners would share one FileHandle and both close it.
|
||||
export function wslGatedOpen(
|
||||
path: string,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<FileHandle> {
|
||||
return runPathOperation('open', path, priority, signal, () => open(path, 'r'), {
|
||||
dedupe: false,
|
||||
// An unabortable open can still succeed after its caller timed out or
|
||||
// cancelled; without this the descriptor leaks for the process lifetime.
|
||||
onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `path` is only the gate's route key and UNC guard — the handle carries no
|
||||
* path and is never re-opened. dedupe:false because `read` fills the CALLER's
|
||||
* buffer: a joiner would receive the first caller's buffer while its own
|
||||
* (often `Buffer.allocUnsafe`) stays uninitialized.
|
||||
*/
|
||||
export function wslGatedRead(
|
||||
handle: FileHandle,
|
||||
path: string,
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }> {
|
||||
return runPathOperation(
|
||||
'read',
|
||||
path,
|
||||
priority,
|
||||
signal,
|
||||
() => handle.read(buffer, offset, length, position),
|
||||
{ dedupe: false }
|
||||
)
|
||||
}
|
||||
|
||||
// A blocked `uv_fs_close` holds a libuv threadpool thread just like a blocked
|
||||
// read does, but it is invisible to the gate — so UNC teardown drains one handle
|
||||
// at a time. Unbounded fire-and-forget closes are what would exhaust the pool the
|
||||
// gate's two permits are sized against, re-creating the process-wide stall the
|
||||
// gate exists to prevent; serializing them costs at most one extra busy thread.
|
||||
// The queue cannot grow without bound: opens on a stuck route already fast-fail.
|
||||
// Keyed by route like the gate's own admission, because a close that blocks on a
|
||||
// stalled distro never settles — a shared queue would strand every later close,
|
||||
// including handles on healthy distros, for the process lifetime.
|
||||
const MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE = 1
|
||||
type RouteCloseQueue = { queued: FileHandle[]; active: number }
|
||||
const closeQueuesByRoute = new Map<string, RouteCloseQueue>()
|
||||
|
||||
function drainQueuedCloses(route: string): void {
|
||||
const lane = closeQueuesByRoute.get(route)
|
||||
if (!lane) {
|
||||
return
|
||||
}
|
||||
while (lane.active < MAX_CONCURRENT_UNC_CLOSES_PER_ROUTE) {
|
||||
const handle = lane.queued.shift()
|
||||
if (!handle) {
|
||||
if (lane.active === 0) {
|
||||
closeQueuesByRoute.delete(route)
|
||||
}
|
||||
return
|
||||
}
|
||||
lane.active += 1
|
||||
void handle
|
||||
.close()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
lane.active -= 1
|
||||
drainQueuedCloses(route)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Never gated. Off UNC this is the prior contract verbatim — the caller awaits
|
||||
* fd teardown and a close failure surfaces. On UNC it is fire-and-forget:
|
||||
* closing a handle on a stalled mount can itself block, and a gated close would
|
||||
* burn a permit and a waiter deadline purely for teardown. One leaked fd until
|
||||
* the OS unblocks beats a second blocked waiter.
|
||||
*/
|
||||
export function closeTranscriptHandle(handle: FileHandle, path: string): Promise<void> {
|
||||
if (!isWslUncPath(path)) {
|
||||
return handle.close()
|
||||
}
|
||||
const route = wslTranscriptFsRouteKey(path)
|
||||
const lane = closeQueuesByRoute.get(route) ?? { queued: [], active: 0 }
|
||||
closeQueuesByRoute.set(route, lane)
|
||||
lane.queued.push(handle)
|
||||
drainQueuedCloses(route)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open, read one slice, close — for callers that want bytes at an offset and
|
||||
* never touch the handle. Each of the two syscalls is admitted separately, so a
|
||||
* stalled mount fails at whichever one it blocks on.
|
||||
*/
|
||||
export async function readTranscriptSlice(
|
||||
path: string,
|
||||
position: number,
|
||||
length: number,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<Buffer> {
|
||||
const handle = await wslGatedOpen(path, priority, signal)
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(length)
|
||||
const { bytesRead } = await wslGatedRead(
|
||||
handle,
|
||||
path,
|
||||
buffer,
|
||||
0,
|
||||
length,
|
||||
position,
|
||||
priority,
|
||||
signal
|
||||
)
|
||||
return buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
await closeTranscriptHandle(handle, path)
|
||||
}
|
||||
}
|
||||
|
||||
export type TranscriptReadStreamOptions = {
|
||||
start?: number
|
||||
/** Inclusive, matching `createReadStream`. */
|
||||
end?: number
|
||||
/** Set it to get decoded string chunks on both branches; leave it unset and
|
||||
* the UNC branch yields `Buffer`s the consumer must decode incrementally
|
||||
* itself, since a chunk boundary can split a multibyte codepoint. */
|
||||
encoding?: BufferEncoding
|
||||
}
|
||||
|
||||
async function* gatedChunks(
|
||||
path: string,
|
||||
options: TranscriptReadStreamOptions,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): AsyncGenerator<Buffer | string> {
|
||||
const handle = await wslGatedOpen(path, priority, signal)
|
||||
// Why: chunk boundaries fall mid-codepoint, so decoding each slice
|
||||
// independently would emit U+FFFD on both sides of any straddling character.
|
||||
const decoder = options.encoding ? new StringDecoder(options.encoding) : null
|
||||
try {
|
||||
let position = options.start ?? 0
|
||||
for (;;) {
|
||||
const length =
|
||||
options.end === undefined
|
||||
? WSL_TRANSCRIPT_READ_CHUNK_BYTES
|
||||
: Math.min(WSL_TRANSCRIPT_READ_CHUNK_BYTES, options.end - position + 1)
|
||||
if (length <= 0) {
|
||||
break
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(length)
|
||||
const { bytesRead } = await wslGatedRead(
|
||||
handle,
|
||||
path,
|
||||
buffer,
|
||||
0,
|
||||
length,
|
||||
position,
|
||||
priority,
|
||||
signal
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
break
|
||||
}
|
||||
position += bytesRead
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
if (!decoder) {
|
||||
yield chunk
|
||||
continue
|
||||
}
|
||||
const decoded = decoder.write(chunk)
|
||||
if (decoded) {
|
||||
yield decoded
|
||||
}
|
||||
}
|
||||
// Trailing bytes of an incomplete sequence at EOF, replacement-char'd once.
|
||||
const trailing = decoder?.end()
|
||||
if (trailing) {
|
||||
yield trailing
|
||||
}
|
||||
} finally {
|
||||
// Runs on `.destroy()` too (Readable.from calls the generator's return()),
|
||||
// so an aborted head-read cannot leak the gated handle.
|
||||
await closeTranscriptHandle(handle, path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read stream whose UNC branch admits and deadlines each 1 MiB chunk
|
||||
* separately. A gate refusal mid-stream surfaces as an `'error'` event carrying
|
||||
* the `WslTranscriptFsError`, which every existing consumer funnels into its
|
||||
* `catch`.
|
||||
*/
|
||||
export function openTranscriptReadStream(
|
||||
path: string,
|
||||
options: TranscriptReadStreamOptions,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Readable {
|
||||
if (!isWslUncPath(path)) {
|
||||
// Node destroys the stream with an AbortError on abort, matching how the
|
||||
// gated branch surfaces cancellation to the same consumers.
|
||||
return createReadStream(path, { ...options, signal })
|
||||
}
|
||||
return Readable.from(gatedChunks(path, options, priority, signal))
|
||||
}
|
||||
@@ -139,6 +139,29 @@ describe('WSL transcript filesystem task scheduling', () => {
|
||||
expect(duplicateTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never joins an exact probe onto the same file queued as scan work', async () => {
|
||||
const transcript = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\live.jsonl'
|
||||
const statTask = (priority: 'exact' | 'scan', task: () => Promise<string>): Promise<string> =>
|
||||
runWslTranscriptFsTask({ operation: 'stat', path: transcript, priority }, task)
|
||||
// Hold the single scan slot on another distro so the same-file scan stat
|
||||
// cannot run — a joiner would inherit exactly that queued position.
|
||||
const blocked = deferred<string>()
|
||||
const holderTask = vi.fn(() => blocked.promise)
|
||||
const holder = run('\\\\wsl.localhost\\Debian\\tree', 'scan', holderTask)
|
||||
await vi.waitFor(() => expect(holderTask).toHaveBeenCalledOnce())
|
||||
|
||||
const scanTask = vi.fn(async () => 'scan')
|
||||
const scanned = statTask('scan', scanTask)
|
||||
const exactTask = vi.fn(async () => 'exact')
|
||||
|
||||
await expect(statTask('exact', exactTask)).resolves.toBe('exact')
|
||||
expect(exactTask).toHaveBeenCalledOnce()
|
||||
expect(scanTask).not.toHaveBeenCalled()
|
||||
|
||||
blocked.resolve('holder')
|
||||
await expect(Promise.all([holder, scanned])).resolves.toEqual(['holder', 'scan'])
|
||||
})
|
||||
|
||||
it('keeps shared work needed by a remaining waiter', async () => {
|
||||
const pending = deferred<string>()
|
||||
const task = vi.fn(() => pending.promise)
|
||||
@@ -632,3 +655,74 @@ describe('WSL transcript filesystem task scheduling', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL transcript filesystem task coalescing opt-out', () => {
|
||||
const READ_PATH = '\\\\wsl.localhost\\Alpine\\home\\ada\\transcript.jsonl'
|
||||
|
||||
function gatedRead(task: (signal: AbortSignal) => Promise<Buffer>): Promise<Buffer> {
|
||||
return runWslTranscriptFsTask(
|
||||
{ operation: 'read', path: READ_PATH, priority: 'exact', dedupe: false },
|
||||
task
|
||||
)
|
||||
}
|
||||
|
||||
it('fills each caller its own buffer for identical positional reads', async () => {
|
||||
const first = Buffer.alloc(4, 0xa1)
|
||||
const second = Buffer.alloc(4, 0xb2)
|
||||
const bodies = [Buffer.from('AAAA'), Buffer.from('BBBB')]
|
||||
|
||||
const reads = Promise.all([
|
||||
gatedRead(async () => {
|
||||
bodies.shift()!.copy(first)
|
||||
return first
|
||||
}),
|
||||
gatedRead(async () => {
|
||||
bodies.shift()!.copy(second)
|
||||
return second
|
||||
})
|
||||
])
|
||||
|
||||
await expect(reads).resolves.toEqual([first, second])
|
||||
expect(first.toString()).toBe('AAAA')
|
||||
expect(second.toString()).toBe('BBBB')
|
||||
// Neither caller kept its sentinel nor received the other's bytes.
|
||||
expect(first.equals(second)).toBe(false)
|
||||
})
|
||||
|
||||
it('gives each open call a distinct handle', async () => {
|
||||
const handles = [
|
||||
{ id: 1, closed: false },
|
||||
{ id: 2, closed: false }
|
||||
]
|
||||
let served = 0
|
||||
const opened = await Promise.all(
|
||||
handles.map(() =>
|
||||
runWslTranscriptFsTask(
|
||||
{ operation: 'open', path: READ_PATH, priority: 'exact', dedupe: false },
|
||||
async () => handles[served++]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
expect(opened[0]).not.toBe(opened[1])
|
||||
opened[0].closed = true
|
||||
expect(opened[1].closed).toBe(false)
|
||||
})
|
||||
|
||||
it('still coalesces identical work when dedupe is omitted', async () => {
|
||||
const stalled = deferred<string>()
|
||||
const task = vi.fn(() => stalled.promise)
|
||||
const path = '\\\\wsl.localhost\\Alpine\\home\\ada'
|
||||
const first = run(path, 'scan', task)
|
||||
await vi.waitFor(() => expect(task).toHaveBeenCalledOnce())
|
||||
const joiner = run(
|
||||
path,
|
||||
'scan',
|
||||
vi.fn(async () => 'never')
|
||||
)
|
||||
|
||||
stalled.resolve('shared')
|
||||
await expect(Promise.all([first, joiner])).resolves.toEqual(['shared', 'shared'])
|
||||
expect(task).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { wslTranscriptFsRouteKey } from './wsl-transcript-fs-route'
|
||||
|
||||
const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2
|
||||
export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000
|
||||
@@ -6,9 +6,9 @@ export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000
|
||||
// Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely.
|
||||
export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64
|
||||
export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64
|
||||
const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
|
||||
export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
|
||||
export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
|
||||
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
|
||||
|
||||
export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable'
|
||||
@@ -47,6 +47,8 @@ type ScheduledTask<T> = {
|
||||
route: string
|
||||
priority: WslTranscriptFsTaskPriority
|
||||
operation: (signal: AbortSignal) => Promise<T>
|
||||
/** Dispose a value no waiter is left to own (see settleTask). */
|
||||
onAbandonedResult?: (value: T) => void
|
||||
controller: AbortController
|
||||
waiters: Set<TaskWaiter<T>>
|
||||
state: 'queued' | 'running' | 'settled'
|
||||
@@ -59,6 +61,7 @@ type ScheduledTask<T> = {
|
||||
type UnknownScheduledTask = ScheduledTask<unknown>
|
||||
|
||||
let activeScanCount = 0
|
||||
let undedupedTaskId = 0
|
||||
const activeLaneKeys = new Set<string>()
|
||||
const queuedTasks: UnknownScheduledTask[] = []
|
||||
const inFlightTasks = new Map<string, UnknownScheduledTask>()
|
||||
@@ -68,18 +71,6 @@ function abortReason(signal: AbortSignal): unknown {
|
||||
return signal.reason ?? new Error('WSL transcript filesystem task aborted')
|
||||
}
|
||||
|
||||
// wsl$ and wsl.localhost spellings of one distro stay distinct routes on
|
||||
// purpose (provider spelling can change behavior), so a stuck spelling never
|
||||
// fast-fails its twin — the twin is bounded by its own waiter deadlines.
|
||||
function routeKey(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i)
|
||||
if (match) {
|
||||
return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}`
|
||||
}
|
||||
return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path
|
||||
}
|
||||
|
||||
function removeQueuedTask(task: UnknownScheduledTask): void {
|
||||
const index = queuedTasks.indexOf(task)
|
||||
if (index !== -1) {
|
||||
@@ -159,11 +150,9 @@ function stuckBlocker(
|
||||
|
||||
// Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields
|
||||
// before the waiter is attached, so no aborted-signal recheck is needed.
|
||||
function attachWaiter<T>(
|
||||
task: ScheduledTask<T>,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
// The task's own priority is every waiter's deadline: the dedupe key carries
|
||||
// priority, so a joiner can only ever join a task scheduled at its own.
|
||||
function attachWaiter<T>(task: ScheduledTask<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) {
|
||||
return Promise.reject(capacityError())
|
||||
}
|
||||
@@ -190,7 +179,7 @@ function attachWaiter<T>(
|
||||
}
|
||||
reject(error)
|
||||
abandonTaskIfUnused(task as UnknownScheduledTask, error)
|
||||
}, timeoutMs(priority))
|
||||
}, timeoutMs(task.priority))
|
||||
waiter.timeout.unref?.()
|
||||
})
|
||||
}
|
||||
@@ -207,6 +196,10 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
|
||||
activeTasks.delete(task as UnknownScheduledTask)
|
||||
clearTimeout(task.stuckTimer)
|
||||
clearTask(task as UnknownScheduledTask)
|
||||
// Why: an unabortable syscall can still succeed after its last waiter timed
|
||||
// out or cancelled. A resource-valued result (open's FileHandle) then has no
|
||||
// owner left to close it, so ownership passes to the task's disposer.
|
||||
const abandoned = task.waiters.size === 0
|
||||
for (const waiter of task.waiters) {
|
||||
removeWaiter(task, waiter)
|
||||
if ('value' in result) {
|
||||
@@ -216,6 +209,15 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
|
||||
}
|
||||
}
|
||||
task.waiters.clear()
|
||||
if (abandoned && 'value' in result) {
|
||||
// Contained: a disposer that throws must not skip the pump below and wedge
|
||||
// every queued task behind this one.
|
||||
try {
|
||||
task.onAbandonedResult?.(result.value)
|
||||
} catch {
|
||||
// Best-effort teardown; nothing left to report it to.
|
||||
}
|
||||
}
|
||||
pumpTasks()
|
||||
}
|
||||
|
||||
@@ -281,13 +283,39 @@ function pumpTasks(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: drop every task and counter so a case that leaves a task stalled
|
||||
* (holding a permit, marking its route stuck) cannot fast-fail the next one.
|
||||
* Tasks are marked settled first, so a late resolution from the unabortable
|
||||
* syscall they are still blocked on cannot decrement counters this just zeroed.
|
||||
*/
|
||||
export function resetWslTranscriptFsGateForTests(): void {
|
||||
for (const task of [...activeTasks, ...queuedTasks]) {
|
||||
task.state = 'settled'
|
||||
clearTimeout(task.stuckTimer)
|
||||
for (const waiter of task.waiters) {
|
||||
removeWaiter(task, waiter)
|
||||
}
|
||||
}
|
||||
activeTasks.clear()
|
||||
queuedTasks.length = 0
|
||||
inFlightTasks.clear()
|
||||
activeLaneKeys.clear()
|
||||
activeScanCount = 0
|
||||
}
|
||||
|
||||
/** Bound 9P work without letting scans delay exact transcript probes. */
|
||||
export function runWslTranscriptFsTask<T>(
|
||||
options: {
|
||||
operation: 'access' | 'readdir'
|
||||
operation: 'access' | 'readdir' | 'stat' | 'lstat' | 'open' | 'read' | 'readfile'
|
||||
path: string
|
||||
priority: WslTranscriptFsTaskPriority
|
||||
signal?: AbortSignal
|
||||
/** Opt out of coalescing when the result is not the whole answer (`open`,
|
||||
* positional `read`): a joiner would share the handle or the buffer. */
|
||||
dedupe?: boolean
|
||||
/** Release a late result no waiter is left to own (see settleTask). */
|
||||
onAbandonedResult?: (value: T) => void
|
||||
},
|
||||
task: (signal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
@@ -295,8 +323,15 @@ export function runWslTranscriptFsTask<T>(
|
||||
return Promise.reject(abortReason(options.signal))
|
||||
}
|
||||
// Why: route spelling and Linux path case can change provider behavior, so
|
||||
// only byte-identical filesystem requests are safe to share.
|
||||
const key = JSON.stringify([options.operation, options.path])
|
||||
// only byte-identical filesystem requests are safe to share. Priority is part
|
||||
// of the key because it selects the task's lane, scan slot and deadline: an
|
||||
// exact probe that joined a queued scan task would inherit scan scheduling and
|
||||
// starve behind the very traffic the two priorities exist to bypass. A counter
|
||||
// key never collides, so an un-deduped task simply never finds a join target.
|
||||
const key =
|
||||
options.dedupe === false
|
||||
? `undeduped:${++undedupedTaskId}`
|
||||
: JSON.stringify([options.operation, options.path, options.priority])
|
||||
const existing = inFlightTasks.get(key) as ScheduledTask<T> | undefined
|
||||
if (existing) {
|
||||
// A stuck target — or a queued target that stuck I/O keeps from ever
|
||||
@@ -308,9 +343,9 @@ export function runWslTranscriptFsTask<T>(
|
||||
) {
|
||||
return Promise.reject(unavailableError())
|
||||
}
|
||||
return attachWaiter(existing, options.priority, options.signal)
|
||||
return attachWaiter(existing, options.signal)
|
||||
}
|
||||
const route = routeKey(options.path)
|
||||
const route = wslTranscriptFsRouteKey(options.path)
|
||||
// Fail fast when the permit, route, or scan slot this task needs is held by
|
||||
// stuck I/O — queueing would only burn the caller's full deadline.
|
||||
if (stuckBlocker(route, options.priority)) {
|
||||
@@ -326,13 +361,14 @@ export function runWslTranscriptFsTask<T>(
|
||||
route,
|
||||
priority: options.priority,
|
||||
operation: task,
|
||||
onAbandonedResult: options.onAbandonedResult,
|
||||
controller: new AbortController(),
|
||||
waiters: new Set(),
|
||||
state: 'queued',
|
||||
stuck: false
|
||||
}
|
||||
inFlightTasks.set(key, scheduled as UnknownScheduledTask)
|
||||
const result = attachWaiter(scheduled, options.priority, options.signal)
|
||||
const result = attachWaiter(scheduled, options.signal)
|
||||
queuedTasks.push(scheduled as UnknownScheduledTask)
|
||||
queueMicrotask(pumpTasks)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
/**
|
||||
* The isolation unit for WSL transcript filesystem work: one stalled distro
|
||||
* must not fast-fail or strand work on any other. Shared by the gate's
|
||||
* admission and by the ungated close queue so both isolate on the same key.
|
||||
*
|
||||
* wsl$ and wsl.localhost spellings of one distro stay distinct routes on
|
||||
* purpose (provider spelling can change behavior), so a stuck spelling never
|
||||
* fast-fails its twin — the twin is bounded by its own waiter deadlines.
|
||||
*/
|
||||
export function wslTranscriptFsRouteKey(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i)
|
||||
if (match) {
|
||||
return `${match[1].toLowerCase()}/${match[2].trim().toLowerCase()}`
|
||||
}
|
||||
return parseWslUncPath(path)?.distro.trim().toLowerCase() ?? path
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
|
||||
const UNC_DATA_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode'
|
||||
const UNC_DATABASE = `${UNC_DATA_DIR}\\opencode.db`
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveDataDirectory: vi.fn<() => string>(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../opencode/opencode-data-directory', () => ({
|
||||
resolveOpenCodeDataDirectory: mocks.resolveDataDirectory
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
readdir: mocks.readdir,
|
||||
stat: mocks.stat
|
||||
}))
|
||||
|
||||
import { listOpenCodeDatabases } from './scanner'
|
||||
import {
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
|
||||
WslTranscriptFsError
|
||||
} from '../native-chat/wsl-transcript-fs-gate'
|
||||
|
||||
// A stalled task holds the gate's single scan slot until it settles, so every
|
||||
// case releases its stall before the next one runs.
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls<T>(): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
releaseStall = () => resolve([] as T)
|
||||
})
|
||||
}
|
||||
|
||||
function dirent(name: string) {
|
||||
return { name, isFile: () => true }
|
||||
}
|
||||
|
||||
// The point of the gate: an ungated syscall on a stalled 9P mount never returns,
|
||||
// so the call must still be pending at the deadline and settle just after it.
|
||||
async function settlesOnlyAtTheScanDeadline(pending: Promise<string[]>): Promise<string[]> {
|
||||
let settled = false
|
||||
const tracked = pending.then((value) => {
|
||||
settled = true
|
||||
return value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS - 1)
|
||||
expect(settled).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
return await tracked
|
||||
}
|
||||
|
||||
let originalDatabaseOverride: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalDatabaseOverride = process.env.OPENCODE_DB
|
||||
delete process.env.OPENCODE_DB
|
||||
mocks.resolveDataDirectory.mockReset()
|
||||
mocks.readdir.mockReset()
|
||||
mocks.stat.mockReset()
|
||||
releaseStall = undefined
|
||||
mocks.resolveDataDirectory.mockReturnValue(UNC_DATA_DIR)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.useRealTimers()
|
||||
if (originalDatabaseOverride === undefined) {
|
||||
delete process.env.OPENCODE_DB
|
||||
} else {
|
||||
process.env.OPENCODE_DB = originalDatabaseOverride
|
||||
}
|
||||
})
|
||||
|
||||
// Why this file exists: the AI Vault's primary OpenCode source reaches these
|
||||
// syscalls transitively, so the import-guard that pins direct `node:fs` use in
|
||||
// the transcript modules never saw them (STA-4049).
|
||||
describe('OpenCode database discovery on a stalled WSL data directory', () => {
|
||||
it('gates the data-directory listing instead of hanging the scan', async () => {
|
||||
mocks.readdir.mockImplementation(stalls)
|
||||
// Asserted because an empty list alone also matches a missing data dir; the
|
||||
// AI Vault's OpenCode source turns this callback into the scan issue.
|
||||
const onRefusal = vi.fn()
|
||||
|
||||
expect(await settlesOnlyAtTheScanDeadline(listOpenCodeDatabases(onRefusal))).toEqual([])
|
||||
expect(onRefusal).toHaveBeenCalledWith(UNC_DATA_DIR, expect.any(WslTranscriptFsError))
|
||||
})
|
||||
|
||||
it('gates an absolute UNC OPENCODE_DB probe instead of hanging the scan', async () => {
|
||||
process.env.OPENCODE_DB = UNC_DATABASE
|
||||
mocks.stat.mockImplementation(stalls)
|
||||
const onRefusal = vi.fn()
|
||||
|
||||
expect(await settlesOnlyAtTheScanDeadline(listOpenCodeDatabases(onRefusal))).toEqual([])
|
||||
expect(mocks.readdir).not.toHaveBeenCalled()
|
||||
// Loose on the path: `isAbsolute` is host-flavoured, so a UNC override only
|
||||
// stays verbatim on Windows. The refusal reaching the caller is the contract.
|
||||
expect(onRefusal).toHaveBeenCalledWith(
|
||||
expect.stringContaining('opencode.db'),
|
||||
expect.any(WslTranscriptFsError)
|
||||
)
|
||||
})
|
||||
|
||||
it('still lists databases when the distro answers', async () => {
|
||||
mocks.readdir.mockResolvedValue([dirent('opencode.db'), dirent('notes.txt')])
|
||||
|
||||
await expect(listOpenCodeDatabases()).resolves.toEqual([join(UNC_DATA_DIR, 'opencode.db')])
|
||||
expect(mocks.readdir).toHaveBeenCalledWith(UNC_DATA_DIR, { withFileTypes: true })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */
|
||||
import { readdir, realpath, stat } from 'node:fs/promises'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
|
||||
import Database from '../sqlite/sync-database'
|
||||
@@ -75,7 +77,14 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOpenCodeDatabases(): Promise<string[]> {
|
||||
// Why gated: the AI Vault's primary OpenCode source delegates here from inside
|
||||
// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled
|
||||
// distro would otherwise hang the whole scan on a raw syscall (STA-4049).
|
||||
export async function listOpenCodeDatabases(
|
||||
/** Lets a caller report the refusal; an empty list otherwise reads as
|
||||
* "OpenCode not used" rather than "we could not look". */
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void
|
||||
): Promise<string[]> {
|
||||
const dataDirectory = resolveOpenCodeDataDirectory()
|
||||
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
|
||||
if (databaseOverride.isConfigured) {
|
||||
@@ -83,23 +92,37 @@ export async function listOpenCodeDatabases(): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return (await stat(databaseOverride.path)).isFile() ? [databaseOverride.path] : []
|
||||
} catch {
|
||||
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
|
||||
? [databaseOverride.path]
|
||||
: []
|
||||
} catch (error) {
|
||||
reportRefusal(databaseOverride.path, error, onRefusal)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(dataDirectory, { withFileTypes: true })
|
||||
const entries = await wslGatedReaddir(dataDirectory, 'scan')
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDirectory, entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
reportRefusal(dataDirectory, error, onRefusal)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function reportRefusal(
|
||||
path: string,
|
||||
error: unknown,
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void
|
||||
): void {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
onRefusal?.(path, error)
|
||||
}
|
||||
}
|
||||
|
||||
function compareOpenCodeClaimPriority(left: string, right: string): number {
|
||||
// Why: the canonical opencode.db is the live database; it must claim
|
||||
// duplicated sessions ahead of stale sibling copies. Remaining ties use
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import {
|
||||
createDefaultLocalOrcaProfile,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
|
||||
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
@@ -154,6 +155,27 @@ describe('profile index store', () => {
|
||||
expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile')
|
||||
})
|
||||
|
||||
const posixIt = process.platform === 'win32' ? it.skip : it
|
||||
posixIt('writes a fresh profile index when umask removes owner-write permission', async () => {
|
||||
const store = await loadProfileIndexStore()
|
||||
const indexPath = store.getOrcaProfileIndexPath()
|
||||
const profile = createDefaultLocalOrcaProfile(1)
|
||||
const index: OrcaProfileIndex = {
|
||||
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
activeProfileId: profile.id,
|
||||
profiles: [profile]
|
||||
}
|
||||
const originalUmask = process.umask(0o200)
|
||||
|
||||
try {
|
||||
expect(() => store.writeProfileIndex(indexPath, index)).not.toThrow()
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
}
|
||||
|
||||
expect(readJson(indexPath)).toEqual(index)
|
||||
})
|
||||
|
||||
it('recovers a corrupted profile index from the backup copy', async () => {
|
||||
const store = await loadProfileIndexStore()
|
||||
store.ensureActiveOrcaProfile()
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ONBOARDING_FLOW_VERSION
|
||||
} from '../shared/constants'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { folderWorkspaceToWorktree } from '../shared/folder-workspace-worktree'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host'
|
||||
import { SshConnectionStore } from './ssh/ssh-connection-store'
|
||||
import { setSourceControlActionDefault } from '../shared/source-control-ai-actions'
|
||||
@@ -5418,6 +5419,369 @@ describe('Store', () => {
|
||||
expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── 8b. Folder-workspace review notes across a build rollback ──
|
||||
|
||||
function makeFolderNote(id: string, body: string, workspaceId = 'fw-1') {
|
||||
return {
|
||||
id,
|
||||
worktreeId: folderWorkspaceKey(workspaceId),
|
||||
filePath: 'README.md',
|
||||
source: 'markdown' as const,
|
||||
lineNumber: 1,
|
||||
body,
|
||||
createdAt: 100,
|
||||
side: 'modified' as const
|
||||
}
|
||||
}
|
||||
|
||||
function folderWorkspaceRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'fw-1',
|
||||
projectGroupId: 'root',
|
||||
name: 'Refund fix',
|
||||
folderPath: '/workspace/platform',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10,
|
||||
lastActivityAt: 5,
|
||||
createdAt: 2,
|
||||
updatedAt: 3,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function writeFolderWorkspaceProfile(options: {
|
||||
workspaces: Record<string, unknown>[]
|
||||
diffCommentsMap?: unknown
|
||||
connectionId?: string | null
|
||||
extraTopLevel?: Record<string, unknown>
|
||||
}): void {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
parentGroupId: null,
|
||||
connectionId: options.connectionId ?? null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: options.workspaces,
|
||||
...('diffCommentsMap' in options
|
||||
? { folderWorkspaceDiffComments: options.diffCommentsMap }
|
||||
: {}),
|
||||
...options.extraTopLevel
|
||||
})
|
||||
}
|
||||
|
||||
// The previous build's normalizeFolderWorkspaces has no diffComments projection (the line this
|
||||
// checkout carries at src/shared/folder-workspaces.ts:107 does not exist on v1.4.179–v1.4.181),
|
||||
// and its full-state write re-serializes everything else verbatim (the `{ ...defaults, ...parsed }`
|
||||
// load spread and the omit-style getDurableState()).
|
||||
function simulatePreviousBuildLoadAndFlush(onDisk: PersistedState): PersistedState {
|
||||
return {
|
||||
...onDisk,
|
||||
folderWorkspaces: onDisk.folderWorkspaces.map(
|
||||
({ diffComments: _droppedByOldNormalizer, ...rest }) => rest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps folder-workspace review notes across a previous-build rollback and re-upgrade', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
name: 'Refund fix'
|
||||
})
|
||||
const note = makeFolderNote('note-1', 'Review this paragraph', workspace.id)
|
||||
store.updateFolderWorkspace(workspace.id, { diffComments: [note] })
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.folderWorkspaceDiffComments?.[workspace.id]).toEqual([note])
|
||||
expect(persisted.folderWorkspaces[0]).not.toHaveProperty('diffComments')
|
||||
// The strip lives at the serialization boundary only; live records keep their notes.
|
||||
expect(store.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
|
||||
expect(folderWorkspaceToWorktree(store.getFolderWorkspace(workspace.id)!).diffComments).toEqual(
|
||||
[note]
|
||||
)
|
||||
|
||||
writeDataFile(simulatePreviousBuildLoadAndFlush(persisted))
|
||||
|
||||
const restored = await createStore()
|
||||
expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
|
||||
restored.flush()
|
||||
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments?.[workspace.id]).toEqual([
|
||||
note
|
||||
])
|
||||
})
|
||||
|
||||
it('survives repeated rollback / re-upgrade hops', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' })
|
||||
const note = makeFolderNote('note-1', 'Review this paragraph', workspace.id)
|
||||
store.updateFolderWorkspace(workspace.id, { diffComments: [note] })
|
||||
store.flush()
|
||||
|
||||
for (let hop = 0; hop < 2; hop++) {
|
||||
writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState))
|
||||
const reupgraded = await createStore()
|
||||
expect(reupgraded.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
|
||||
reupgraded.flush()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps notes and remote provenance for an SSH folder workspace across a rollback', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
connectionId: 'ssh-1',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Remote fix' })
|
||||
expect(workspace.connectionId).toBe('ssh-1')
|
||||
const note = makeFolderNote('note-remote', 'Remote review', workspace.id)
|
||||
store.updateFolderWorkspace(workspace.id, { diffComments: [note] })
|
||||
store.flush()
|
||||
|
||||
writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState))
|
||||
|
||||
const restored = await createStore()
|
||||
expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([note])
|
||||
expect(restored.getFolderWorkspace(workspace.id)?.connectionId).toBe('ssh-1')
|
||||
})
|
||||
|
||||
it('round-trips unknown top-level state keys through load and flush', async () => {
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord({ unknownNestedField: 'from-a-newer-build' })],
|
||||
extraTopLevel: { unknownTopLevelKey: { kept: 'verbatim' } }
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
store.updateFolderWorkspace('fw-1', { comment: 'touch' })
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as PersistedState & { unknownTopLevelKey?: unknown }
|
||||
expect(
|
||||
persisted.unknownTopLevelKey,
|
||||
'Unknown top-level keys must survive load + flush. Note preservation relies on this: converting the ' +
|
||||
'`{ ...defaults, ...parsed }` load spread or the omit-style getDurableState() into an ' +
|
||||
'allowlist re-opens folder-workspace note loss for the then-previous build.'
|
||||
).toEqual({ kept: 'verbatim' })
|
||||
expect(
|
||||
persisted.folderWorkspaces[0],
|
||||
'Documents existing behavior, not a guarantee: normalizeFolderWorkspaces rebuilds each ' +
|
||||
'record field-by-field, so nested unknown fields are dropped. That is why notes moved ' +
|
||||
'to a top-level key.'
|
||||
).not.toHaveProperty('unknownNestedField')
|
||||
})
|
||||
|
||||
it('migrates legacy inline folder-workspace notes into the top-level map', async () => {
|
||||
const note = makeFolderNote('note-1', 'Legacy inline note')
|
||||
writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord({ diffComments: [note] })] })
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([note])
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.folderWorkspaceDiffComments?.['fw-1']).toEqual([note])
|
||||
expect(persisted.folderWorkspaces[0]).not.toHaveProperty('diffComments')
|
||||
})
|
||||
|
||||
it('keeps notes across a rollback when the session never edits anything', async () => {
|
||||
const note = makeFolderNote('note-1', 'Legacy inline note')
|
||||
writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord({ diffComments: [note] })] })
|
||||
|
||||
// Launch and quit on the fixed build, with no mutation: loadNeedsSave must make the
|
||||
// relocation durable on its own. This is the reported P0 sequence.
|
||||
const upgraded = await createStore()
|
||||
upgraded.flush()
|
||||
|
||||
writeDataFile(simulatePreviousBuildLoadAndFlush(readDataFile() as PersistedState))
|
||||
|
||||
const restored = await createStore()
|
||||
expect(restored.getFolderWorkspace('fw-1')?.diffComments).toEqual([note])
|
||||
})
|
||||
|
||||
it('keeps notes authored inline on a rolled-back #14112 build over a staler map entry', async () => {
|
||||
const mapped = makeFolderNote('note-mapped', 'Note from the fixed build')
|
||||
const authored = makeFolderNote('note-inline', 'Note authored while rolled back')
|
||||
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord()],
|
||||
diffCommentsMap: { 'fw-1': [mapped] }
|
||||
})
|
||||
// #14112 persists notes inline and never learned about the top-level map, so a note authored
|
||||
// there lands inline while the untouched map entry goes stale. Inline is the newer write.
|
||||
const rolledBack = readDataFile() as PersistedState
|
||||
writeDataFile({
|
||||
...rolledBack,
|
||||
folderWorkspaces: rolledBack.folderWorkspaces.map((workspace) => ({
|
||||
...workspace,
|
||||
diffComments: [authored]
|
||||
}))
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([authored])
|
||||
store.flush()
|
||||
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
|
||||
'fw-1': [authored]
|
||||
})
|
||||
})
|
||||
|
||||
it('never lets an empty or unrelated map entry delete inline notes', async () => {
|
||||
const inline = makeFolderNote('note-inline', 'Inline note')
|
||||
const mapped = makeFolderNote('note-mapped', 'Mapped note')
|
||||
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord({ diffComments: [inline] })],
|
||||
diffCommentsMap: { 'fw-1': [] }
|
||||
})
|
||||
expect((await createStore()).getFolderWorkspace('fw-1')?.diffComments).toEqual([inline])
|
||||
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord({ diffComments: [inline] })],
|
||||
diffCommentsMap: { other: [mapped] }
|
||||
})
|
||||
const store = await createStore()
|
||||
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual([inline])
|
||||
store.flush()
|
||||
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
|
||||
'fw-1': [inline]
|
||||
})
|
||||
})
|
||||
|
||||
it('drops orphaned note entries and prunes them when workspaces are deleted', async () => {
|
||||
const note = makeFolderNote('note-1', 'Kept note')
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord()],
|
||||
diffCommentsMap: { 'fw-1': [note], ghost: [makeFolderNote('note-ghost', 'Orphan', 'ghost')] }
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
store.flush()
|
||||
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
|
||||
'fw-1': [note]
|
||||
})
|
||||
|
||||
const reloaded = await createStore()
|
||||
reloaded.flush()
|
||||
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).not.toHaveProperty(
|
||||
'ghost'
|
||||
)
|
||||
|
||||
// Delete paths carry no pruning code: the map is derived from live workspaces on every write.
|
||||
expect(reloaded.removeFolderWorkspace('fw-1')).toBe(true)
|
||||
reloaded.flush()
|
||||
expect(readDataFile() as PersistedState).not.toHaveProperty('folderWorkspaceDiffComments')
|
||||
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord()],
|
||||
diffCommentsMap: { 'fw-1': [note] }
|
||||
})
|
||||
const groupDelete = await createStore()
|
||||
expect(groupDelete.deleteProjectGroup('root')).toBe(true)
|
||||
groupDelete.flush()
|
||||
expect(readDataFile() as PersistedState).not.toHaveProperty('folderWorkspaceDiffComments')
|
||||
})
|
||||
|
||||
it('writes no folderWorkspaceDiffComments key for note-free profiles', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Platform',
|
||||
parentPath: '/workspace/platform',
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'No notes' })
|
||||
store.flush()
|
||||
expect('folderWorkspaceDiffComments' in (readDataFile() as object)).toBe(false)
|
||||
|
||||
store.updateFolderWorkspace(workspace.id, { diffComments: [] })
|
||||
store.flush()
|
||||
expect('folderWorkspaceDiffComments' in (readDataFile() as object)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['null root', null, undefined],
|
||||
['string root', 'oops', undefined],
|
||||
['array root', [], undefined],
|
||||
['non-array entry value', { 'fw-1': 'oops' }, undefined],
|
||||
['array of non-DiffComment members', { 'fw-1': [7] }, [7]]
|
||||
])(
|
||||
'tolerates a corrupt folderWorkspaceDiffComments map (%s)',
|
||||
async (_label, diffCommentsMap, expected) => {
|
||||
writeFolderWorkspaceProfile({ workspaces: [folderWorkspaceRecord()], diffCommentsMap })
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getFolderWorkspace('fw-1')?.diffComments).toEqual(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes note members through verbatim on load and re-write', async () => {
|
||||
// Shape-only guard: the moment member filtering is added here, the fix itself becomes a new
|
||||
// deletion path for user-authored prose.
|
||||
const noteWithExtras = { ...makeFolderNote('note-1', 'Body'), unknownNoteField: 'kept' }
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord(), folderWorkspaceRecord({ id: 'fw-2', name: 'Second' })],
|
||||
diffCommentsMap: { 'fw-1': [7], 'fw-2': [noteWithExtras] }
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.folderWorkspaceDiffComments).toEqual({
|
||||
'fw-1': [7],
|
||||
'fw-2': [noteWithExtras]
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the written note map from live workspaces, never a stale loaded copy', async () => {
|
||||
const loadedNote = makeFolderNote('note-loaded', 'Loaded note')
|
||||
const editedNote = makeFolderNote('note-edited', 'Edited note')
|
||||
writeFolderWorkspaceProfile({
|
||||
workspaces: [folderWorkspaceRecord()],
|
||||
diffCommentsMap: { 'fw-1': [loadedNote] }
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
store.updateFolderWorkspace('fw-1', { diffComments: [editedNote] })
|
||||
store.flush()
|
||||
|
||||
expect((readDataFile() as PersistedState).folderWorkspaceDiffComments).toEqual({
|
||||
'fw-1': [editedNote]
|
||||
})
|
||||
})
|
||||
|
||||
// ── 9. Settings: get/update ────────────────────────────────────────
|
||||
|
||||
it('updateSettings merges partial updates', async () => {
|
||||
@@ -5659,6 +6023,50 @@ describe('Store', () => {
|
||||
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
|
||||
})
|
||||
|
||||
it('retires the persisted GitHub attribution setting without dropping unknown settings', async () => {
|
||||
const settledStore = await createStore()
|
||||
settledStore.flush()
|
||||
const settled = readDataFile() as { settings: Record<string, unknown> }
|
||||
settled.settings.enableGitHubAttribution = true
|
||||
settled.settings.futureSetting = { enabled: true }
|
||||
writeDataFile(settled)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings()).not.toHaveProperty('enableGitHubAttribution')
|
||||
expect(store.getSettings()).toHaveProperty('futureSetting', { enabled: true })
|
||||
vi.advanceTimersByTime(5_000)
|
||||
await store.waitForPendingWrite()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
||||
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
|
||||
expect(persisted.settings).toHaveProperty('futureSetting', { enabled: true })
|
||||
})
|
||||
|
||||
it('ignores retired GitHub attribution updates and strips stale in-memory values on save', async () => {
|
||||
const store = await createStore()
|
||||
const listener = vi.fn()
|
||||
store.onSettingsChanged(listener)
|
||||
|
||||
const updated = store.updateSettings({ enableGitHubAttribution: true } as never, {
|
||||
notifyListeners: true
|
||||
})
|
||||
|
||||
expect(updated).not.toHaveProperty('enableGitHubAttribution')
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
const settings = store.getSettings() as GlobalSettings & Record<string, unknown>
|
||||
settings.enableGitHubAttribution = false
|
||||
settings.futureSetting = 'kept'
|
||||
store.flush()
|
||||
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
||||
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
|
||||
expect(persisted.settings?.futureSetting).toBe('kept')
|
||||
})
|
||||
|
||||
it('normalizes terminal cursor style before persistence and listener broadcasts', async () => {
|
||||
const store = await createStore()
|
||||
store.updateSettings({ terminalCursorStyle: 'underline' })
|
||||
@@ -9357,6 +9765,99 @@ describe('Store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires an expected split source incarnation in the owning host partition', async () => {
|
||||
for (const hostId of [undefined, 'ssh:ssh-1']) {
|
||||
const store = await createStore()
|
||||
const sourceSession: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hostId) {
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...sourceSession,
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[`tab1:${TEST_LEAF_1}`]: 'live-incarnation'
|
||||
}
|
||||
},
|
||||
undefined
|
||||
)
|
||||
}
|
||||
store.setWorkspaceSession(sourceSession, hostId)
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding(
|
||||
{
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'pty-split',
|
||||
expectedSourceBinding: {
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-source',
|
||||
incarnationId: 'live-incarnation'
|
||||
}
|
||||
},
|
||||
hostId
|
||||
)
|
||||
).toBe(false)
|
||||
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: TEST_LEAF_1
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Why: a session restored without an incarnation map still owns a valid source binding, so the
|
||||
// split path must be able to fence on the pane alone instead of an id persistence never recorded.
|
||||
it('admits a split whose source pane has no persisted incarnation', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab1: {
|
||||
root: { type: 'leaf', leafId: TEST_LEAF_1 },
|
||||
activeLeafId: TEST_LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(
|
||||
store.persistPtyBinding({
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'pty-split',
|
||||
expectedSourceBinding: {
|
||||
worktreeId: 'wt1',
|
||||
tabId: 'tab1',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-source'
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects competing PTY and incarnation changes during reconciliation', async () => {
|
||||
const store = await createStore()
|
||||
const paneKey = `tab1:${TEST_LEAF_1}`
|
||||
|
||||
+64
-6
@@ -256,6 +256,10 @@ import { normalizeUiLanguage } from '../shared/ui-language'
|
||||
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
|
||||
import { persistedUIValuesEqual } from '../shared/persisted-ui-equality'
|
||||
import { ActiveViewPreference } from './active-view-preference'
|
||||
import {
|
||||
collectFolderWorkspaceDiffComments,
|
||||
normalizeFolderWorkspaceDiffComments
|
||||
} from './folder-workspace-diff-comments'
|
||||
import {
|
||||
normalizeFolderWorkspaceName,
|
||||
normalizeFolderWorkspaces
|
||||
@@ -651,6 +655,11 @@ type LegacyTerminalScrollbackSettings = {
|
||||
terminalScrollbackBytes?: unknown
|
||||
}
|
||||
|
||||
type RetiredGlobalSettings = {
|
||||
terminalScrollbackBytes?: unknown
|
||||
enableGitHubAttribution?: unknown
|
||||
}
|
||||
|
||||
const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3
|
||||
|
||||
function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings {
|
||||
@@ -659,12 +668,16 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal
|
||||
: {}
|
||||
}
|
||||
|
||||
function stripLegacyTerminalScrollbackBytes(
|
||||
function stripRetiredGlobalSettings(
|
||||
settings: Partial<GlobalSettings> | undefined
|
||||
): Partial<GlobalSettings> {
|
||||
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
|
||||
{}) as Partial<GlobalSettings> & { terminalScrollbackBytes?: unknown }
|
||||
const {
|
||||
terminalScrollbackBytes: _legacyScrollbackBytes,
|
||||
enableGitHubAttribution: _legacyGitHubAttribution,
|
||||
...rest
|
||||
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
|
||||
void _legacyScrollbackBytes
|
||||
void _legacyGitHubAttribution
|
||||
return rest
|
||||
}
|
||||
|
||||
@@ -2849,6 +2862,7 @@ export class Store {
|
||||
// profile avoids serializing the multi-MB recovery store on navigation.
|
||||
this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView)
|
||||
const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups()
|
||||
this.hydrateFolderWorkspaceDiffComments()
|
||||
for (const entry of normalized.migrationUnsupportedEntries) {
|
||||
setMigrationUnsupportedPty(entry)
|
||||
}
|
||||
@@ -2869,6 +2883,33 @@ export class Store {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: notes live top-level on disk so an older build's field-by-field
|
||||
// normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here.
|
||||
private hydrateFolderWorkspaceDiffComments(): void {
|
||||
const stored = this.state.folderWorkspaceDiffComments
|
||||
let relocatedInline = false
|
||||
for (const workspace of this.state.folderWorkspaces ?? []) {
|
||||
if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) {
|
||||
// Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the
|
||||
// older map untouched, so inline is the last notes-aware write. Also makes the relocation
|
||||
// durable even if the user never edits anything this session.
|
||||
relocatedInline = true
|
||||
continue
|
||||
}
|
||||
const comments = stored?.[workspace.id]
|
||||
// Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value.
|
||||
if (Array.isArray(comments) && comments.length > 0) {
|
||||
workspace.diffComments = comments
|
||||
}
|
||||
}
|
||||
if (relocatedInline) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
// Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in
|
||||
// state would make it a stale second source of truth that getDurableState() spreads back out.
|
||||
delete this.state.folderWorkspaceDiffComments
|
||||
}
|
||||
|
||||
private adaptFlatFolderScanProjectGroups(): boolean {
|
||||
// Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes.
|
||||
const groups = this.state.projectGroups ?? []
|
||||
@@ -3149,6 +3190,13 @@ export class Store {
|
||||
if (migratedTerminalScrollback.needsSave) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
if (
|
||||
parsed.settings &&
|
||||
typeof parsed.settings === 'object' &&
|
||||
Object.hasOwn(parsed.settings, 'enableGitHubAttribution')
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault(
|
||||
parsed.settings
|
||||
)
|
||||
@@ -3411,6 +3459,9 @@ export class Store {
|
||||
parsed.folderWorkspaces,
|
||||
normalizedProjectGroups
|
||||
),
|
||||
folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments(
|
||||
parsed.folderWorkspaceDiffComments
|
||||
),
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
|
||||
parsed.mobileClientTabSelectionsByDeviceId
|
||||
@@ -3421,7 +3472,7 @@ export class Store {
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
// Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed.
|
||||
...stripLegacyTerminalScrollbackBytes(parsed.settings),
|
||||
...stripRetiredGlobalSettings(parsed.settings),
|
||||
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
|
||||
parsed.settings?.prBotAuthorOverrides
|
||||
),
|
||||
@@ -4005,6 +4056,13 @@ export class Store {
|
||||
// Why: clone before encrypting secrets so in-memory this.state stays plaintext.
|
||||
const stateToSave = {
|
||||
...this.getDurableState(),
|
||||
// Why both keys unconditionally: the explicit keys always win over the spread, and
|
||||
// JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk.
|
||||
// The strip builds a new array here only; this.state records keep their notes in memory.
|
||||
folderWorkspaces: (this.state.folderWorkspaces ?? []).map(
|
||||
({ diffComments: _relocated, ...rest }) => rest
|
||||
),
|
||||
folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces),
|
||||
sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({
|
||||
...record,
|
||||
ownerLease: encryptToSentinel(
|
||||
@@ -4013,7 +4071,7 @@ export class Store {
|
||||
)
|
||||
})),
|
||||
settings: {
|
||||
...this.state.settings,
|
||||
...stripRetiredGlobalSettings(this.state.settings),
|
||||
opencodeSessionCookie: encryptToSentinel(
|
||||
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
|
||||
this.state.settings.opencodeSessionCookie
|
||||
@@ -5819,7 +5877,7 @@ export class Store {
|
||||
updates: Partial<GlobalSettings>,
|
||||
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
|
||||
): GlobalSettings {
|
||||
const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
|
||||
const sanitizedUpdates = stripRetiredGlobalSettings(updates)
|
||||
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
|
||||
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { delimiter } from 'node:path'
|
||||
import type * as MacosTccLoginShell from './macos-tcc-login-shell'
|
||||
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
|
||||
|
||||
const {
|
||||
existsSyncMock,
|
||||
@@ -576,7 +577,9 @@ describe('LocalPtyProvider', () => {
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.NODE_ENV).toBeUndefined()
|
||||
expect(spawnCall[2].env.PATH).toBe(process.env.PATH)
|
||||
const expectedEnv = { PATH: process.env.PATH ?? '' }
|
||||
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
|
||||
expect(spawnCall[2].env.PATH).toBe(expectedEnv.PATH)
|
||||
})
|
||||
|
||||
it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => {
|
||||
@@ -752,6 +755,19 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-promote a legacy attribution path for Agent Teams', async () => {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
|
||||
}
|
||||
})
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
|
||||
const keys = [
|
||||
'GIT_CONFIG_COUNT',
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready'
|
||||
import { removeInheritedNoColor } from '../pty/terminal-color-env'
|
||||
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
|
||||
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
|
||||
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { resolvePathEnvKey } from '../pty/windows-environment-path'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
||||
@@ -829,6 +830,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
finalEnv,
|
||||
requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined
|
||||
)
|
||||
// Why: raw requested PATH promotion runs after the host-env scrub.
|
||||
stripLegacyTerminalShimEnv(finalEnv, process.platform)
|
||||
|
||||
// Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10).
|
||||
const worktreeId = args.worktreeId
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
unregisterSshPtyProvider
|
||||
} from '../ipc/pty'
|
||||
import type { IPtyProvider } from './types'
|
||||
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
|
||||
|
||||
describe('PTY provider dispatch', () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
||||
@@ -147,6 +148,7 @@ describe('PTY provider dispatch', () => {
|
||||
const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0]
|
||||
expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual(
|
||||
[
|
||||
...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
|
||||
'CLAUDE_CODE_CHILD_SESSION',
|
||||
'CLAUDE_CODE_SESSION_ID',
|
||||
'CLAUDE_CODE_BRIDGE_SESSION_ID'
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const SHELL_DOLLAR = '$'
|
||||
|
||||
const POSIX_TOMBSTONE = String.raw`#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
command_name="__ORCA_COMMAND__"
|
||||
wrapper_dir="$(cd -- "$(dirname -- "${SHELL_DOLLAR}{BASH_SOURCE[0]}")" && pwd)"
|
||||
legacy_wrapper_dir="${SHELL_DOLLAR}{ORCA_ATTRIBUTION_SHIM_DIR:-}"
|
||||
cleaned_path="${SHELL_DOLLAR}{PATH:-}"
|
||||
|
||||
filter_path() {
|
||||
local legacy_target="$legacy_wrapper_dir"
|
||||
while [[ "$legacy_target" != "/" && "$legacy_target" == */ ]]; do
|
||||
legacy_target="${SHELL_DOLLAR}{legacy_target%/}"
|
||||
done
|
||||
local remaining="$cleaned_path"
|
||||
local filtered_path=""
|
||||
local separator=""
|
||||
path_entry_kept=0
|
||||
local entry normalized candidate has_more
|
||||
while true; do
|
||||
if [[ "$remaining" == *:* ]]; then
|
||||
entry="${SHELL_DOLLAR}{remaining%%:*}"
|
||||
remaining="${SHELL_DOLLAR}{remaining#*:}"
|
||||
has_more=1
|
||||
else
|
||||
entry="$remaining"
|
||||
has_more=0
|
||||
fi
|
||||
normalized="$entry"
|
||||
while [[ "$normalized" != "/" && "$normalized" == */ ]]; do
|
||||
normalized="${SHELL_DOLLAR}{normalized%/}"
|
||||
done
|
||||
candidate="${SHELL_DOLLAR}{entry:-.}"
|
||||
if [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then
|
||||
:
|
||||
elif [[ "$candidate" -ef "$wrapper_dir" ]]; then
|
||||
:
|
||||
else
|
||||
filtered_path+="$separator$entry"
|
||||
separator=":"
|
||||
path_entry_kept=1
|
||||
fi
|
||||
[[ "$has_more" == 1 ]] || break
|
||||
done
|
||||
cleaned_path="$filtered_path"
|
||||
}
|
||||
|
||||
filter_path
|
||||
unset ORCA_ENABLE_GIT_ATTRIBUTION ORCA_GIT_COMMIT_TRAILER ORCA_GH_PR_FOOTER
|
||||
unset ORCA_GH_ISSUE_FOOTER ORCA_ATTRIBUTION_SHIM_DIR ORCA_REAL_GIT ORCA_REAL_GH ORCA_ATTRIBUTION_BYPASS
|
||||
|
||||
real_command=""
|
||||
if [[ "$path_entry_kept" == 1 ]]; then
|
||||
real_command="$(PATH="$cleaned_path" type -P "$command_name" || true)"
|
||||
fi
|
||||
if [[ -n "$real_command" && "$real_command" -ef "${SHELL_DOLLAR}{BASH_SOURCE[0]}" ]]; then
|
||||
real_command=""
|
||||
fi
|
||||
if [[ -z "$real_command" ]]; then
|
||||
printf 'Orca compatibility wrapper could not locate %s on PATH.\n' "$command_name" >&2
|
||||
exit 127
|
||||
fi
|
||||
PATH="$cleaned_path" exec "$real_command" "$@"
|
||||
`
|
||||
|
||||
export function renderLegacyTerminalPosixTombstone(command: 'git' | 'gh'): string {
|
||||
return POSIX_TOMBSTONE.replaceAll('__ORCA_COMMAND__', command)
|
||||
}
|
||||
@@ -1,64 +1,209 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
__resetLegacyTerminalShimRemovalForTests,
|
||||
removeLegacyTerminalShimDir,
|
||||
__resetLegacyTerminalShimNeutralizationForTests,
|
||||
neutralizeLegacyTerminalShimDir,
|
||||
stripLegacyTerminalShimEnv
|
||||
} from './legacy-terminal-shim-dir'
|
||||
|
||||
// Why: the failure case is produced with a read-only parent dir, which Windows ignores and root bypasses.
|
||||
const itOnPosix = process.platform === 'win32' ? it.skip : it
|
||||
// Why: the failure case uses directory permissions, which Windows ignores and root bypasses.
|
||||
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
|
||||
|
||||
describe('legacy terminal shim removal', () => {
|
||||
describe('legacy terminal shim neutralization', () => {
|
||||
const tempRoots: string[] = []
|
||||
|
||||
const makeUserDataDir = (): string => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
tempRoots.push(userData)
|
||||
return userData
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLegacyTerminalShimRemovalForTests()
|
||||
__resetLegacyTerminalShimNeutralizationForTests()
|
||||
})
|
||||
|
||||
it('deletes the orphaned wrapper directory left by older installs', () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
mkdirSync(shimDir, { recursive: true })
|
||||
writeFileSync(join(shimDir, 'git'), '#!/usr/bin/env bash\n')
|
||||
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
|
||||
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
|
||||
expect(existsSync(userData)).toBe(true)
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const tempRoot of tempRoots.splice(0)) {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not throw when nothing is left to remove', () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
expect(() => removeLegacyTerminalShimDir(userData)).not.toThrow()
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('stays retryable when the removal fails, and latches once it succeeds', () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
|
||||
it('atomically replaces the legacy command paths with executable tombstones', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const legacyRoot = join(userData, 'orca-terminal-attribution')
|
||||
mkdirSync(join(legacyRoot, 'posix'), { recursive: true })
|
||||
writeFileSync(join(legacyRoot, 'posix', 'git'), '#!/usr/bin/env bash\n')
|
||||
// Why: a read-only parent makes the unlink fail the way a locked Windows wrapper does.
|
||||
chmodSync(userData, 0o500)
|
||||
const posixDir = join(legacyRoot, 'posix')
|
||||
const win32Dir = join(legacyRoot, 'win32')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
writeFileSync(join(win32Dir, 'gh.cmd'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const path of [
|
||||
join(posixDir, 'git'),
|
||||
join(posixDir, 'gh'),
|
||||
join(win32Dir, 'git.cmd'),
|
||||
join(win32Dir, 'gh.cmd')
|
||||
]) {
|
||||
expect(existsSync(path)).toBe(true)
|
||||
expect(readFileSync(path, 'utf8')).not.toContain('Co-authored-by: Orca')
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(path).mode & 0o111).not.toBe(0)
|
||||
}
|
||||
}
|
||||
expect(readFileSync(join(legacyRoot, 'VERSION'), 'utf8')).toBe('7\n')
|
||||
})
|
||||
|
||||
it('rejects stale Windows real-command paths inside the wrapper directory', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
|
||||
expect(cmd).toContain(
|
||||
'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="'
|
||||
)
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
expect(powershell).toContain('$realCommand = $null')
|
||||
expect(powershell.indexOf('[StringComparison]::OrdinalIgnoreCase')).toBeLessThan(
|
||||
powershell.indexOf('Test-Path -LiteralPath $realCommand')
|
||||
)
|
||||
})
|
||||
|
||||
it('removes every Windows PATH occurrence of both captured wrapper directories', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
|
||||
const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"'
|
||||
expect(cmd.indexOf(cmdCapture)).toBeLessThan(cmd.indexOf('set "ORCA_ATTRIBUTION_SHIM_DIR="'))
|
||||
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"')
|
||||
expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b')
|
||||
expect(cmd).toContain(
|
||||
'if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\\" exit /b'
|
||||
)
|
||||
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expect(powershell.indexOf('$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR')).toBeLessThan(
|
||||
powershell.indexOf('Remove-Item "Env:$_"')
|
||||
)
|
||||
expect(powershell).toContain('$wrapperDirs = @($wrapperDir, $legacyWrapperDir)')
|
||||
expect(powershell).toContain("$env:PATH = (($env:PATH -split ';') | Where-Object {")
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
})
|
||||
|
||||
it('does not throw when the legacy directory is absent', () => {
|
||||
const userData = makeUserDataDir()
|
||||
|
||||
expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow()
|
||||
expect(existsSync(join(userData, 'orca-terminal-attribution', 'posix', 'git'))).toBe(true)
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => {
|
||||
vi.useFakeTimers()
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const gitWrapper = join(posixDir, 'git')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
writeFileSync(gitWrapper, 'legacy attribution wrapper')
|
||||
chmodSync(posixDir, 0o500)
|
||||
try {
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
expect(existsSync(legacyRoot)).toBe(true)
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper')
|
||||
} finally {
|
||||
chmodSync(userData, 0o700)
|
||||
chmodSync(posixDir, 0o700)
|
||||
}
|
||||
|
||||
// The failed attempt must not have latched the guard.
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
expect(existsSync(legacyRoot)).toBe(false)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper')
|
||||
|
||||
// A success latches it: a directory recreated afterwards is left alone.
|
||||
mkdirSync(legacyRoot, { recursive: true })
|
||||
removeLegacyTerminalShimDir(userData)
|
||||
expect(existsSync(legacyRoot)).toBe(true)
|
||||
writeFileSync(gitWrapper, 'recreated after success')
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => {
|
||||
const userData = makeUserDataDir()
|
||||
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const shimGit = join(shimDir, 'git')
|
||||
const realBin = join(userData, 'real-bin')
|
||||
const realGit = join(realBin, 'git')
|
||||
mkdirSync(shimDir, { recursive: true })
|
||||
mkdirSync(realBin, { recursive: true })
|
||||
writeFileSync(shimGit, '#!/usr/bin/env bash\nexit 99\n', { mode: 0o755 })
|
||||
writeFileSync(
|
||||
realGit,
|
||||
"#!/usr/bin/env bash\nprintf 'arg=<%s>\\n' \"$@\"\ncat\nprintf 'fixture stderr\\n' >&2\nexit 23\n",
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
const child = spawn('bash', ['--noprofile', '--norc'], {
|
||||
cwd: shimDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${shimDir}//::${realBin}:${process.env.PATH ?? ''}`,
|
||||
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
|
||||
ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca <help@stably.ai>',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: ''
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
})
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
const ready = waitForOutput(child.stdout, '__ORCA_HASH_READY__\n')
|
||||
child.stdin.write(`hash -p ${quoteBash(shimGit)} git\nprintf '__ORCA_HASH_READY__\\n'\n`)
|
||||
|
||||
try {
|
||||
await ready
|
||||
} catch (error) {
|
||||
child.kill('SIGKILL')
|
||||
throw error
|
||||
}
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
const closed = waitForChildClose(child, 2_000)
|
||||
child.stdin.end("printf 'stdin payload\\n' | git commit -m 'subject with spaces'; exit $?\n")
|
||||
|
||||
try {
|
||||
expect(await closed).toBe(23)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
expect(stdout).toContain('arg=<commit>\narg=<-m>\narg=<subject with spaces>\nstdin payload\n')
|
||||
expect(stdout).not.toContain('Co-authored-by: Orca')
|
||||
expect(stderr).toBe('fixture stderr\n')
|
||||
})
|
||||
|
||||
it('drops inherited shim env and its PATH entry without touching real entries', () => {
|
||||
// Why: a daemon predating the removal reseeds these from its own process.env.
|
||||
const env: Record<string, string> = {
|
||||
PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`,
|
||||
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
|
||||
@@ -73,20 +218,50 @@ describe('legacy terminal shim removal', () => {
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
|
||||
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
|
||||
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
|
||||
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
|
||||
expect(env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
|
||||
expect(env.ORCA_REAL_GIT).toBeUndefined()
|
||||
expect(env.ORCA_REAL_GH).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/u')
|
||||
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' })
|
||||
})
|
||||
|
||||
it('strips the Windows shim entry on the inherited Path spelling', () => {
|
||||
it('uses the captured POSIX shim directory literally when it contains a colon', () => {
|
||||
const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix'
|
||||
const env: Record<string, string> = {
|
||||
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32`
|
||||
PATH: `/usr/local/bin:${shimDir}:/usr/bin`,
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: shimDir
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin' })
|
||||
})
|
||||
|
||||
it('treats legacy Windows environment keys case-insensitively', () => {
|
||||
const shimDir = 'C:\\Users\\orca;user\\orca-terminal-attribution\\win32'
|
||||
const env: Record<string, string> = {
|
||||
Path: `${shimDir};C:\\Windows\\System32`,
|
||||
orca_attribution_shim_dir: shimDir,
|
||||
Orca_Enable_Git_Attribution: '1',
|
||||
orca_real_git: 'C:\\Git\\git.exe'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env).toEqual({ Path: 'C:\\Windows\\System32' })
|
||||
})
|
||||
|
||||
it('strips legacy entries from every Windows PATH spelling', () => {
|
||||
const env: Record<string, string> = {
|
||||
PATH: 'C:\\Orca\\orca-terminal-attribution\\win32',
|
||||
Path: 'C:\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env.PATH).toBeUndefined()
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('matches a re-cased Windows shim path', () => {
|
||||
const env: Record<string, string> = {
|
||||
Path: 'C:\\Orca\\Orca-Terminal-Attribution\\Win32;C:\\Windows\\System32'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
@@ -94,25 +269,26 @@ describe('legacy terminal shim removal', () => {
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('matches a re-cased shim entry on case-insensitive filesystems', () => {
|
||||
const env: Record<string, string> = {
|
||||
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\Orca-Terminal-Attribution\\win32;C:\\Windows\\System32`
|
||||
}
|
||||
it('preserves explicit empty PATH values', () => {
|
||||
const windowsEnv: Record<string, string> = { PATH: '', Path: 'C:\\Windows' }
|
||||
const posixEnv: Record<string, string> = { PATH: '' }
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
|
||||
stripLegacyTerminalShimEnv(posixEnv, 'linux')
|
||||
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' })
|
||||
expect(posixEnv).toEqual({ PATH: '' })
|
||||
})
|
||||
|
||||
it('keeps neighbouring directories that merely share the name prefix', () => {
|
||||
const env: Record<string, string> = {
|
||||
PATH: '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
PATH: '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env.PATH).toBe(
|
||||
'/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
'/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -122,3 +298,46 @@ describe('legacy terminal shim removal', () => {
|
||||
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
|
||||
})
|
||||
})
|
||||
|
||||
function quoteBash(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = ''
|
||||
const onData = (chunk: string | Buffer): void => {
|
||||
output += chunk.toString()
|
||||
if (output.includes(marker)) {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const onEnd = (): void => {
|
||||
cleanup()
|
||||
reject(new Error(`Bash exited before emitting ${marker}`))
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
stream.off('data', onData)
|
||||
stream.off('end', onEnd)
|
||||
}
|
||||
stream.on('data', onData)
|
||||
stream.on('end', onEnd)
|
||||
})
|
||||
}
|
||||
|
||||
function waitForChildClose(
|
||||
child: ReturnType<typeof spawn>,
|
||||
timeoutMs: number
|
||||
): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`Bash did not exit within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
child.once('close', (exitCode) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(exitCode)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,71 +1,269 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { resolvePathEnvKey } from './windows-environment-path'
|
||||
import {
|
||||
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY,
|
||||
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
|
||||
} from '../../shared/legacy-terminal-attribution-env'
|
||||
import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone'
|
||||
|
||||
const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution'
|
||||
const LEGACY_SHIM_ENV_KEYS = [
|
||||
const LEGACY_SHIM_VERSION = '7'
|
||||
const NEUTRALIZATION_RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 30_000]
|
||||
export const LEGACY_TERMINAL_SHIM_ENV_KEYS = [
|
||||
'ORCA_ENABLE_GIT_ATTRIBUTION',
|
||||
'ORCA_GIT_COMMIT_TRAILER',
|
||||
'ORCA_GH_PR_FOOTER',
|
||||
'ORCA_GH_ISSUE_FOOTER',
|
||||
'ORCA_ATTRIBUTION_SHIM_DIR',
|
||||
'ORCA_REAL_GIT',
|
||||
'ORCA_REAL_GH'
|
||||
'ORCA_REAL_GH',
|
||||
LEGACY_TERMINAL_ATTRIBUTION_BYPASS_ENV_KEY
|
||||
] as const
|
||||
export const LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS = [
|
||||
LEGACY_TERMINAL_ATTRIBUTION_ENABLE_ENV_KEY
|
||||
] as const
|
||||
|
||||
let removed = false
|
||||
const WIN32_PASSTHROUGH_WRAPPER = String.raw`@echo off
|
||||
setlocal
|
||||
set "orca_real=%ORCA_REAL___ORCA_UPPER_COMMAND__%"
|
||||
set "orca_wrapper_dir=%~dp0"
|
||||
set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"
|
||||
set "orca_clean_path="
|
||||
for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"
|
||||
set "PATH=%orca_clean_path%"
|
||||
set "ORCA_ENABLE_GIT_ATTRIBUTION="
|
||||
set "ORCA_GIT_COMMIT_TRAILER="
|
||||
set "ORCA_GH_PR_FOOTER="
|
||||
set "ORCA_GH_ISSUE_FOOTER="
|
||||
set "ORCA_ATTRIBUTION_SHIM_DIR="
|
||||
set "ORCA_ATTRIBUTION_BYPASS="
|
||||
set "ORCA_REAL_GIT="
|
||||
set "ORCA_REAL_GH="
|
||||
if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="
|
||||
if defined orca_real if exist "%orca_real%" goto run
|
||||
for /f "delims=" %%G in ('where.exe __ORCA_COMMAND__.exe 2^>nul') do if not defined orca_real set "orca_real=%%G"
|
||||
if not defined orca_real (
|
||||
echo Orca compatibility wrapper could not locate __ORCA_COMMAND__ on PATH. 1>&2
|
||||
exit /b 127
|
||||
)
|
||||
:run
|
||||
"%orca_real%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
/** Why: a daemon that outlives an upgrade keeps seeding these from its own process.env
|
||||
* (pty-subprocess reads process.env as the authoritative base), and the wrappers they
|
||||
* point at are gated only on inherited env. Deleting the scripts makes both inert. */
|
||||
export function removeLegacyTerminalShimDir(userDataPath: string): void {
|
||||
if (removed) {
|
||||
:orca_append_path
|
||||
for %%G in ("%~1") do set "orca_path_entry_dir=%%~fG\"
|
||||
if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b
|
||||
if defined orca_legacy_wrapper_dir for %%G in ("%orca_legacy_wrapper_dir%") do if /I "%orca_path_entry_dir%"=="%%~fG\" exit /b
|
||||
if defined orca_clean_path (set "orca_clean_path=%orca_clean_path%;%~1") else set "orca_clean_path=%~1"
|
||||
exit /b
|
||||
`
|
||||
|
||||
const POWERSHELL_PASSTHROUGH_WRAPPER = String.raw`$ErrorActionPreference = 'Stop'
|
||||
$commandName = '__ORCA_COMMAND__'
|
||||
$realCommand = [Environment]::GetEnvironmentVariable('ORCA_REAL___ORCA_UPPER_COMMAND__')
|
||||
$wrapperDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR
|
||||
$wrapperDirs = @($wrapperDir, $legacyWrapperDir) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }
|
||||
$env:PATH = (($env:PATH -split ';') | Where-Object {
|
||||
$pathEntry = $_
|
||||
$pathEntry -and -not ($wrapperDirs | Where-Object {
|
||||
[string]::Equals($_, $pathEntry.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
|
||||
})
|
||||
}) -join ';'
|
||||
'ORCA_ENABLE_GIT_ATTRIBUTION', 'ORCA_GIT_COMMIT_TRAILER', 'ORCA_GH_PR_FOOTER', 'ORCA_GH_ISSUE_FOOTER', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_REAL_GIT', 'ORCA_REAL_GH', 'ORCA_ATTRIBUTION_BYPASS' | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue }
|
||||
if ($realCommand) {
|
||||
try {
|
||||
$capturedDir = Split-Path -Parent ([IO.Path]::GetFullPath($realCommand))
|
||||
if ([string]::Equals($capturedDir.TrimEnd('\'), $wrapperDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$realCommand = $null
|
||||
}
|
||||
} catch {
|
||||
$realCommand = $null
|
||||
}
|
||||
}
|
||||
if (-not $realCommand -or -not (Test-Path -LiteralPath $realCommand)) {
|
||||
$resolved = Get-Command "$commandName.exe" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$realCommand = if ($resolved) { $resolved.Source } else { $null }
|
||||
}
|
||||
if (-not $realCommand) {
|
||||
[Console]::Error.WriteLine("Orca compatibility wrapper could not locate $commandName on PATH.")
|
||||
exit 127
|
||||
}
|
||||
& $realCommand @args
|
||||
exit $LASTEXITCODE
|
||||
`
|
||||
|
||||
let neutralized = false
|
||||
let neutralizationRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let neutralizationRetryAttempt = 0
|
||||
|
||||
export function neutralizeLegacyTerminalShimDir(userDataPath: string): void {
|
||||
if (neutralized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: a surviving pre-upgrade pane can hold the wrapper open on Windows; retry like
|
||||
// the other userData removals rather than forfeiting cleanup for the whole run.
|
||||
rmSync(join(userDataPath, LEGACY_SHIM_ROOT_DIR), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 50
|
||||
})
|
||||
removed = true
|
||||
const rootDir = join(userDataPath, LEGACY_SHIM_ROOT_DIR)
|
||||
writeNeutralWrappers(rootDir)
|
||||
writeFileAtomically(join(rootDir, 'VERSION'), `${LEGACY_SHIM_VERSION}\n`, 0o644)
|
||||
neutralized = true
|
||||
clearNeutralizationRetry()
|
||||
} catch {
|
||||
// Best-effort: a locked file must not block startup, and the next launch retries.
|
||||
// Why: Windows can keep a running .cmd open briefly after startup.
|
||||
scheduleNeutralizationRetry(userDataPath)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNeutralizationRetry(userDataPath: string): void {
|
||||
if (
|
||||
neutralizationRetryTimer ||
|
||||
neutralizationRetryAttempt >= NEUTRALIZATION_RETRY_DELAYS_MS.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
const delayMs = NEUTRALIZATION_RETRY_DELAYS_MS[neutralizationRetryAttempt]
|
||||
neutralizationRetryAttempt += 1
|
||||
neutralizationRetryTimer = setTimeout(() => {
|
||||
neutralizationRetryTimer = null
|
||||
neutralizeLegacyTerminalShimDir(userDataPath)
|
||||
}, delayMs)
|
||||
neutralizationRetryTimer.unref?.()
|
||||
}
|
||||
|
||||
function clearNeutralizationRetry(): void {
|
||||
if (neutralizationRetryTimer) {
|
||||
clearTimeout(neutralizationRetryTimer)
|
||||
neutralizationRetryTimer = null
|
||||
}
|
||||
neutralizationRetryAttempt = 0
|
||||
}
|
||||
|
||||
function writeNeutralWrappers(rootDir: string): void {
|
||||
const posixDir = join(rootDir, 'posix')
|
||||
const win32Dir = join(rootDir, 'win32')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
const upperCommand = command.toUpperCase()
|
||||
writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755)
|
||||
writeFileAtomically(
|
||||
join(win32Dir, `${command}.cmd`),
|
||||
renderWindowsWrapper(WIN32_PASSTHROUGH_WRAPPER, command, upperCommand),
|
||||
0o755
|
||||
)
|
||||
writeFileAtomically(
|
||||
join(win32Dir, `${command}-wrapper.ps1`),
|
||||
renderWindowsWrapper(POWERSHELL_PASSTHROUGH_WRAPPER, command, upperCommand),
|
||||
0o755
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renderWindowsWrapper(template: string, command: string, upperCommand: string): string {
|
||||
return template
|
||||
.replaceAll('__ORCA_UPPER_COMMAND__', upperCommand)
|
||||
.replaceAll('__ORCA_COMMAND__', command)
|
||||
}
|
||||
|
||||
function writeFileAtomically(filePath: string, contents: string, mode: number): void {
|
||||
const temporaryPath = `${filePath}.orca-neutralizing-${process.pid}`
|
||||
try {
|
||||
rmSync(temporaryPath, { force: true, recursive: true })
|
||||
writeFileSync(temporaryPath, contents, { encoding: 'utf8', flag: 'wx', mode })
|
||||
chmodSync(temporaryPath, mode)
|
||||
renameSync(temporaryPath, filePath)
|
||||
} finally {
|
||||
rmSync(temporaryPath, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function isLegacyTerminalShimPathEntry(entry: string): boolean {
|
||||
return entry.replaceAll('\\', '/').toLowerCase().includes(`/${LEGACY_SHIM_ROOT_DIR}/`)
|
||||
const normalized = entry.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
|
||||
return (
|
||||
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/posix`) ||
|
||||
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/win32`)
|
||||
)
|
||||
}
|
||||
|
||||
export function stripLegacyTerminalShimEnv(
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): void {
|
||||
for (const key of LEGACY_SHIM_ENV_KEYS) {
|
||||
delete env[key]
|
||||
const windows = platform === 'win32'
|
||||
const legacyKeySet = new Set(
|
||||
LEGACY_TERMINAL_SHIM_ENV_KEYS.map((key) => (windows ? key.toLowerCase() : key))
|
||||
)
|
||||
const shimDirKey = 'ORCA_ATTRIBUTION_SHIM_DIR'.toLowerCase()
|
||||
const explicitShimDirs = Object.entries(env)
|
||||
.filter(([key]) =>
|
||||
windows ? key.toLowerCase() === shimDirKey : key === 'ORCA_ATTRIBUTION_SHIM_DIR'
|
||||
)
|
||||
.map(([, value]) => value)
|
||||
.filter(Boolean)
|
||||
for (const key of Object.keys(env)) {
|
||||
if (windows ? legacyKeySet.has(key.toLowerCase()) : legacyKeySet.has(key)) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
const pathKey = resolvePathEnvKey(env, platform)
|
||||
const current = env[pathKey]
|
||||
if (!current) {
|
||||
return
|
||||
const delimiter = windows ? ';' : ':'
|
||||
const pathKeys = windows
|
||||
? Object.keys(env).filter((key) => key.toLowerCase() === 'path')
|
||||
: ['PATH']
|
||||
for (const pathKey of pathKeys) {
|
||||
const current = env[pathKey]
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const withoutExplicitDirs = explicitShimDirs.reduce(
|
||||
(pathValue, shimDir) => removeLiteralPathEntry(pathValue, shimDir, delimiter, windows),
|
||||
current
|
||||
)
|
||||
const cleaned = withoutExplicitDirs
|
||||
.split(delimiter)
|
||||
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
|
||||
.join(delimiter)
|
||||
if (cleaned) {
|
||||
env[pathKey] = cleaned
|
||||
} else {
|
||||
delete env[pathKey]
|
||||
}
|
||||
}
|
||||
const delimiter = platform === 'win32' ? ';' : ':'
|
||||
const cleaned = current
|
||||
.split(delimiter)
|
||||
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
|
||||
.join(delimiter)
|
||||
if (cleaned) {
|
||||
env[pathKey] = cleaned
|
||||
} else {
|
||||
delete env[pathKey]
|
||||
}
|
||||
|
||||
function removeLiteralPathEntry(
|
||||
pathValue: string,
|
||||
entry: string,
|
||||
delimiter: string,
|
||||
caseInsensitive: boolean
|
||||
): string {
|
||||
let result = pathValue
|
||||
const needle = caseInsensitive ? entry.toLowerCase() : entry
|
||||
let searchStart = 0
|
||||
for (;;) {
|
||||
const comparable = caseInsensitive ? result.toLowerCase() : result
|
||||
const index = comparable.indexOf(needle, searchStart)
|
||||
if (index === -1) {
|
||||
return result
|
||||
}
|
||||
const end = index + entry.length
|
||||
const startsAtBoundary = index === 0 || result[index - 1] === delimiter
|
||||
const endsAtBoundary = end === result.length || result[end] === delimiter
|
||||
if (!startsAtBoundary || !endsAtBoundary) {
|
||||
searchStart = index + 1
|
||||
continue
|
||||
}
|
||||
if (result[end] === delimiter) {
|
||||
result = result.slice(0, index) + result.slice(end + 1)
|
||||
} else if (index > 0) {
|
||||
result = result.slice(0, index - 1) + result.slice(end)
|
||||
} else {
|
||||
result = ''
|
||||
}
|
||||
searchStart = Math.max(0, index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: the module-level once-guard would otherwise leak across cases. */
|
||||
export function __resetLegacyTerminalShimRemovalForTests(): void {
|
||||
removed = false
|
||||
export function __resetLegacyTerminalShimNeutralizationForTests(): void {
|
||||
neutralized = false
|
||||
clearNeutralizationRetry()
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ function createRuntimeFileCommands(options?: {
|
||||
resolveTerminalContext?: ReturnType<typeof vi.fn>
|
||||
resolveTerminalFileUriHostname?: ReturnType<typeof vi.fn>
|
||||
hasRecentTerminalOutputPath?: ReturnType<typeof vi.fn>
|
||||
hasRecentNativeChatOutputPath?: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
const store = {
|
||||
getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined)
|
||||
@@ -192,6 +193,9 @@ function createRuntimeFileCommands(options?: {
|
||||
? { resolveTerminalFileUriHostname: options.resolveTerminalFileUriHostname }
|
||||
: {}),
|
||||
hasRecentTerminalOutputPath: options?.hasRecentTerminalOutputPath ?? vi.fn(() => true),
|
||||
...(options?.hasRecentNativeChatOutputPath
|
||||
? { hasRecentNativeChatOutputPath: options.hasRecentNativeChatOutputPath }
|
||||
: {}),
|
||||
resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(),
|
||||
openFile: options?.openFile ?? vi.fn(),
|
||||
...(options?.openDiff ? { openDiff: options.openDiff } : {})
|
||||
@@ -945,11 +949,18 @@ describe('RuntimeFileCommands', () => {
|
||||
)
|
||||
}
|
||||
|
||||
function createRemoteTerminalArtifactGrantFixture(artifactPath = '/tmp/result.json') {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
function createRemoteTerminalArtifactGrantFixture(
|
||||
artifactPath = '/tmp/result.json',
|
||||
nativeChat = false
|
||||
) {
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
...(nativeChat ? { hasRecentNativeChatOutputPath: vi.fn(() => true) } : {})
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
let realArtifactPath = artifactPath
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 11, mtime: 3 })
|
||||
let artifactStat = { type: 'file', size: 11, mtime: 3 }
|
||||
const stat = vi.fn(async () => artifactStat)
|
||||
const readTerminalArtifact = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '{"ok":true}', isBinary: false })
|
||||
@@ -967,10 +978,20 @@ describe('RuntimeFileCommands', () => {
|
||||
writeTerminalArtifact,
|
||||
moveArtifactTarget: (nextPath: string) => {
|
||||
realArtifactPath = nextPath
|
||||
},
|
||||
replaceArtifact: () => {
|
||||
artifactStat = { type: 'file', size: 12, mtime: 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteNativeChatArtifact(commands: RuntimeFileCommands, artifactPath: string) {
|
||||
return commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a', null, true, {
|
||||
tabId: 'tab-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
}
|
||||
|
||||
it('resolves an absolute path inside the worktree to a relative path', async () => {
|
||||
const { commands } = createRuntimeFileCommands({ path: '/repo' })
|
||||
statAsFile()
|
||||
@@ -992,6 +1013,28 @@ describe('RuntimeFileCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps in-worktree resolution unchanged when chat provenance is present', async () => {
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => true)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
statAsFile()
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
'/repo/src/index.ts',
|
||||
null,
|
||||
'client-a',
|
||||
null,
|
||||
true,
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' }
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true })
|
||||
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves an absolute path through a known sibling workspace', async () => {
|
||||
const sibling = {
|
||||
id: 'wt-2',
|
||||
@@ -1367,9 +1410,13 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mint an absolute terminal artifact grant without a source terminal', async () => {
|
||||
it('keeps old-client behavior without native-chat provenance', async () => {
|
||||
const artifactPath = await tempFile('result.json', '{}')
|
||||
const { commands } = createRuntimeFileCommands({ path: '/repo' })
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => true)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
|
||||
const result = await commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a')
|
||||
|
||||
@@ -1380,6 +1427,139 @@ describe('RuntimeFileCommands', () => {
|
||||
isDirectory: false
|
||||
})
|
||||
expect(result.openTarget).toBeUndefined()
|
||||
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mints an exact-path grant for an out-of-worktree path cited by native chat', async () => {
|
||||
const artifactPath = await tempFile('chat-result.html', '<h1>Result</h1>')
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => true)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
artifactPath,
|
||||
null,
|
||||
'client-a',
|
||||
null,
|
||||
true,
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' }
|
||||
)
|
||||
|
||||
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' },
|
||||
artifactPath,
|
||||
artifactPath
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: await realpath(artifactPath),
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'local',
|
||||
absolutePath: await realpath(artifactPath),
|
||||
readOnly: true
|
||||
}
|
||||
})
|
||||
const target = absoluteFileTarget(result)
|
||||
await expect(
|
||||
commands.writeTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'<h1>Changed</h1>',
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('terminal_file_grant_read_only')
|
||||
await expect(readFile(artifactPath, 'utf8')).resolves.toBe('<h1>Result</h1>')
|
||||
})
|
||||
|
||||
it('binds a cited symlink alias to its canonical read-only target', async () => {
|
||||
const artifactPath = await tempFile('chat-target.html', '<h1>Result</h1>')
|
||||
const citedPath = join(artifactPath, '..', 'chat-citation.html')
|
||||
await symlink(artifactPath, citedPath)
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => true)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
citedPath,
|
||||
null,
|
||||
'client-a',
|
||||
null,
|
||||
true,
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' }
|
||||
)
|
||||
|
||||
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' },
|
||||
citedPath,
|
||||
citedPath
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
absolutePath: await realpath(artifactPath),
|
||||
exists: true,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: await realpath(artifactPath),
|
||||
readOnly: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an out-of-worktree chat path without transcript provenance', async () => {
|
||||
const artifactPath = await tempFile('uncited-result.html', '<h1>Secret</h1>')
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => false)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
artifactPath,
|
||||
null,
|
||||
'client-a',
|
||||
null,
|
||||
true,
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' }
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ relativePath: null, exists: false })
|
||||
expect(result.openTarget).toBeUndefined()
|
||||
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses native-chat tilde paths when the workspace runs in WSL', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => true)
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: String.raw`\\wsl.localhost\Ubuntu\work\repo`,
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
'~/.ssh/config',
|
||||
null,
|
||||
'client-a',
|
||||
null,
|
||||
true,
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' }
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ relativePath: null, absolutePath: null, exists: false })
|
||||
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mint an absolute terminal artifact grant for an unobserved path', async () => {
|
||||
@@ -2213,6 +2393,69 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(writeTerminalArtifact).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads a remote non-temp artifact cited by native chat', async () => {
|
||||
const artifactPath = '/home/me/report.json'
|
||||
const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture(
|
||||
artifactPath,
|
||||
true
|
||||
)
|
||||
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
|
||||
const target = absoluteFileTarget(result)
|
||||
|
||||
await expect(
|
||||
commands.readTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'client-a'
|
||||
)
|
||||
).resolves.toMatchObject({ content: '{"ok":true}' })
|
||||
expect(readTerminalArtifact).toHaveBeenCalledWith(
|
||||
artifactPath,
|
||||
expect.objectContaining({ expectedRealPath: artifactPath })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a retargeted remote native-chat artifact grant', async () => {
|
||||
const artifactPath = '/home/me/report.json'
|
||||
const { commands, readTerminalArtifact, moveArtifactTarget } =
|
||||
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
|
||||
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
|
||||
const target = absoluteFileTarget(result)
|
||||
|
||||
moveArtifactTarget('/home/me/private.json')
|
||||
|
||||
await expect(
|
||||
commands.readTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('terminal_file_grant_stale')
|
||||
expect(readTerminalArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a replaced remote native-chat artifact grant', async () => {
|
||||
const artifactPath = '/home/me/report.json'
|
||||
const { commands, readTerminalArtifact, replaceArtifact } =
|
||||
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
|
||||
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
|
||||
const target = absoluteFileTarget(result)
|
||||
|
||||
replaceArtifact()
|
||||
|
||||
await expect(
|
||||
commands.readTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('terminal_file_grant_stale')
|
||||
expect(readTerminalArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => {
|
||||
const { commands, readTerminalArtifact, moveArtifactTarget } =
|
||||
createRemoteTerminalArtifactGrantFixture()
|
||||
@@ -2294,6 +2537,28 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still refuses a native-chat ~/ path on a remote worktree', async () => {
|
||||
const hasRecentNativeChatOutputPath = vi.fn(() => true)
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
'~/notes.md',
|
||||
null,
|
||||
'client-a',
|
||||
null,
|
||||
true,
|
||||
{ tabId: 'tab-1', sessionId: 'session-1' }
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ relativePath: null, exists: false })
|
||||
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a missing remote file as not existing', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
RuntimeFileReadChunkResult,
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadResult,
|
||||
RuntimeNativeChatFileContext,
|
||||
RuntimeTerminalPathResolution
|
||||
} from '../../shared/runtime-types'
|
||||
import {
|
||||
@@ -183,6 +184,8 @@ type TerminalFileGrant = {
|
||||
clientId?: string
|
||||
expiresAt: number
|
||||
statIdentity: string | null
|
||||
readOnly: boolean
|
||||
provenance: 'terminal-output' | 'native-chat'
|
||||
expiryTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
@@ -524,6 +527,12 @@ export type RuntimeFileCommandHost = {
|
||||
pathText: string,
|
||||
absolutePath: string
|
||||
): boolean | Promise<boolean>
|
||||
hasRecentNativeChatOutputPath?(
|
||||
worktreeId: string,
|
||||
context: RuntimeNativeChatFileContext,
|
||||
pathText: string,
|
||||
absolutePath: string
|
||||
): boolean | Promise<boolean>
|
||||
resolveRuntimeGitTarget(
|
||||
selector: string
|
||||
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
|
||||
@@ -726,7 +735,8 @@ export class RuntimeFileCommands {
|
||||
cwd?: string | null,
|
||||
clientId?: string,
|
||||
terminalHandle?: string | null,
|
||||
crossWorkspace?: boolean
|
||||
crossWorkspace?: boolean,
|
||||
nativeChatContext?: RuntimeNativeChatFileContext | null
|
||||
): Promise<RuntimeTerminalPathResolution> {
|
||||
const store = this.host.requireStore()
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
@@ -750,9 +760,9 @@ export class RuntimeFileCommands {
|
||||
isDirectory: false
|
||||
}
|
||||
|
||||
// Why: remote home is unknown (only local os.homedir), so a tapped ~/… on a remote worktree is not-openable, not guessed.
|
||||
// Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home.
|
||||
const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\')
|
||||
if (isTilde && connectionId) {
|
||||
if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) {
|
||||
return empty
|
||||
}
|
||||
const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText
|
||||
@@ -802,6 +812,26 @@ export class RuntimeFileCommands {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nativeChatContext &&
|
||||
(await this.host.hasRecentNativeChatOutputPath?.(
|
||||
worktree.id,
|
||||
nativeChatContext,
|
||||
pathText,
|
||||
absolutePath
|
||||
))
|
||||
) {
|
||||
const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId)
|
||||
return await this.resolveAbsoluteFileGrant({
|
||||
worktreeId: worktree.id,
|
||||
artifactPath,
|
||||
connectionId,
|
||||
clientId,
|
||||
readOnly: true,
|
||||
provenance: 'native-chat'
|
||||
})
|
||||
}
|
||||
|
||||
// Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths.
|
||||
if (!normalizedTerminalHandle || !terminalCwd) {
|
||||
return { ...empty, relativePath, absolutePath }
|
||||
@@ -831,38 +861,13 @@ export class RuntimeFileCommands {
|
||||
) {
|
||||
return { ...empty, relativePath, absolutePath }
|
||||
}
|
||||
const stats = connectionId
|
||||
? await this.statRemoteTerminalPath(artifactPath, connectionId)
|
||||
: await this.statLocalTerminalPath(artifactPath)
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!isDirectory && isTerminalArtifactHardLinked(stats)) {
|
||||
return { ...empty, relativePath, absolutePath }
|
||||
}
|
||||
const grant = isDirectory
|
||||
? null
|
||||
: this.createTerminalFileGrant({
|
||||
worktreeId: worktree.id,
|
||||
absolutePath: artifactPath,
|
||||
provider: connectionId ? 'ssh' : 'local',
|
||||
connectionId,
|
||||
clientId,
|
||||
stats
|
||||
})
|
||||
return {
|
||||
worktree: worktree.id,
|
||||
relativePath: null,
|
||||
absolutePath: artifactPath,
|
||||
exists: true,
|
||||
isDirectory,
|
||||
openTarget: grant
|
||||
? {
|
||||
kind: 'absolute-file',
|
||||
provider: grant.provider,
|
||||
absolutePath: artifactPath,
|
||||
grantId: grant.id
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
return await this.resolveAbsoluteFileGrant({
|
||||
worktreeId: worktree.id,
|
||||
artifactPath,
|
||||
rejectedAbsolutePath: absolutePath,
|
||||
connectionId,
|
||||
clientId
|
||||
})
|
||||
} catch (error) {
|
||||
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
|
||||
if (
|
||||
@@ -909,6 +914,72 @@ export class RuntimeFileCommands {
|
||||
return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath)
|
||||
}
|
||||
|
||||
private async resolveNativeChatArtifactPath(
|
||||
absolutePath: string,
|
||||
connectionId?: string
|
||||
): Promise<string> {
|
||||
if (!connectionId) {
|
||||
return canonicalPathForArtifactComparison(absolutePath)
|
||||
}
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider.realpath(absolutePath)
|
||||
}
|
||||
|
||||
private async resolveAbsoluteFileGrant(args: {
|
||||
worktreeId: string
|
||||
artifactPath: string
|
||||
rejectedAbsolutePath?: string
|
||||
connectionId?: string
|
||||
clientId?: string
|
||||
readOnly?: boolean
|
||||
provenance?: TerminalFileGrant['provenance']
|
||||
}): Promise<RuntimeTerminalPathResolution> {
|
||||
const stats = args.connectionId
|
||||
? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId)
|
||||
: await this.statLocalTerminalPath(args.artifactPath)
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!isDirectory && isTerminalArtifactHardLinked(stats)) {
|
||||
return {
|
||||
worktree: args.worktreeId,
|
||||
relativePath: null,
|
||||
absolutePath: args.rejectedAbsolutePath ?? args.artifactPath,
|
||||
exists: false,
|
||||
isDirectory: false
|
||||
}
|
||||
}
|
||||
const grant = isDirectory
|
||||
? null
|
||||
: this.createTerminalFileGrant({
|
||||
worktreeId: args.worktreeId,
|
||||
absolutePath: args.artifactPath,
|
||||
provider: args.connectionId ? 'ssh' : 'local',
|
||||
connectionId: args.connectionId,
|
||||
clientId: args.clientId,
|
||||
readOnly: args.readOnly === true,
|
||||
provenance: args.provenance ?? 'terminal-output',
|
||||
stats
|
||||
})
|
||||
return {
|
||||
worktree: args.worktreeId,
|
||||
relativePath: null,
|
||||
absolutePath: args.artifactPath,
|
||||
exists: true,
|
||||
isDirectory,
|
||||
openTarget: grant
|
||||
? {
|
||||
kind: 'absolute-file',
|
||||
provider: grant.provider,
|
||||
absolutePath: args.artifactPath,
|
||||
grantId: grant.id,
|
||||
...(grant.readOnly ? { readOnly: true } : {})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAllowedRemoteTerminalArtifactPath(
|
||||
absolutePath: string,
|
||||
connectionId: string
|
||||
@@ -953,6 +1024,8 @@ export class RuntimeFileCommands {
|
||||
provider: 'local' | 'ssh'
|
||||
connectionId?: string
|
||||
clientId?: string
|
||||
readOnly?: boolean
|
||||
provenance: TerminalFileGrant['provenance']
|
||||
stats: RuntimeFileStatLike
|
||||
}): TerminalFileGrant {
|
||||
assertTerminalArtifactNotHardLinked(args.stats)
|
||||
@@ -964,7 +1037,9 @@ export class RuntimeFileCommands {
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
||||
...(args.clientId ? { clientId: args.clientId } : {}),
|
||||
expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS,
|
||||
statIdentity: terminalFileStatIdentity(args.stats)
|
||||
statIdentity: terminalFileStatIdentity(args.stats),
|
||||
readOnly: args.readOnly === true,
|
||||
provenance: args.provenance
|
||||
}
|
||||
this.terminalFileGrants.set(grant.id, grant)
|
||||
this.scheduleTerminalFileGrantExpiry(grant)
|
||||
@@ -1129,6 +1204,9 @@ export class RuntimeFileCommands {
|
||||
absolutePath,
|
||||
clientId
|
||||
)
|
||||
if (grant.readOnly) {
|
||||
throw new Error('terminal_file_grant_read_only')
|
||||
}
|
||||
if (isMobileBinaryPath(grant.absolutePath)) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
@@ -1280,12 +1358,15 @@ export class RuntimeFileCommands {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const allowedPath = await this.resolveAllowedRemoteTerminalArtifactPath(
|
||||
grant.absolutePath,
|
||||
grant.connectionId
|
||||
)
|
||||
// Why: relay I/O follows symlinks, so re-canonicalize a remote temp-artifact grant after the process can mutate it.
|
||||
if (allowedPath !== grant.absolutePath) {
|
||||
const canonicalPath =
|
||||
grant.provenance === 'native-chat'
|
||||
? await provider.realpath(grant.absolutePath)
|
||||
: await this.resolveAllowedRemoteTerminalArtifactPath(
|
||||
grant.absolutePath,
|
||||
grant.connectionId
|
||||
)
|
||||
// Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path.
|
||||
if (canonicalPath !== grant.absolutePath) {
|
||||
throw new Error('terminal_file_grant_stale')
|
||||
}
|
||||
return provider
|
||||
|
||||
@@ -69,35 +69,69 @@ function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(includeSource = true) {
|
||||
function createHarness(
|
||||
includeSource = true,
|
||||
options: {
|
||||
connectionId?: string | null
|
||||
deferReveal?: boolean
|
||||
deferSpawn?: boolean
|
||||
includePairedSnapshot?: boolean
|
||||
rendererMounted?: boolean
|
||||
sourceIncarnationId?: string
|
||||
stopAndWaitResult?: boolean
|
||||
} = {}
|
||||
) {
|
||||
let session = persistedSession(includeSource)
|
||||
const connectionId = options.connectionId ?? null
|
||||
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
|
||||
const requestedSessionHostIds: (string | undefined)[] = []
|
||||
const repo = {
|
||||
id: REPO_ID,
|
||||
path: '/workspace',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
addedAt: 1,
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
|
||||
getWorkspaceSession: () => session,
|
||||
getWorkspaceSession: (hostId?: string) => {
|
||||
requestedSessionHostIds.push(hostId)
|
||||
return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession()
|
||||
},
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
persistPtyBinding: () => true
|
||||
}
|
||||
const spawn = vi.fn(async () => ({ id: SPLIT_PTY_ID }))
|
||||
let resolveSpawn: ((result: { id: string }) => void) | undefined
|
||||
const spawn = options.deferSpawn
|
||||
? vi.fn(
|
||||
() =>
|
||||
new Promise<{ id: string }>((resolve) => {
|
||||
resolveSpawn = resolve
|
||||
})
|
||||
)
|
||||
: vi.fn(async () => ({ id: SPLIT_PTY_ID }))
|
||||
const kill = vi.fn(() => true)
|
||||
const revealTerminalSession = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
|
||||
const retireRejectedPty = vi.fn()
|
||||
const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true)
|
||||
let resolveReveal: ((result: { tabId: string }) => void) | undefined
|
||||
const revealTerminalSession = options.deferReveal
|
||||
? vi.fn(
|
||||
() =>
|
||||
new Promise<{ tabId: string }>((resolve) => {
|
||||
resolveReveal = resolve
|
||||
})
|
||||
)
|
||||
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
Object.assign(runtime, {
|
||||
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
|
||||
id: WORKTREE_ID,
|
||||
path: '/workspace',
|
||||
connectionId: null,
|
||||
connectionId,
|
||||
repo,
|
||||
folderWorkspace: null
|
||||
}))
|
||||
@@ -106,35 +140,77 @@ function createHarness(includeSource = true) {
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill,
|
||||
retireRejectedPty,
|
||||
...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({ revealTerminalSession } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: includeSource ? [remoteSnapshot()] : []
|
||||
tabs:
|
||||
includeSource && options.rendererMounted
|
||||
? [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Restored terminal',
|
||||
activeLeafId: SOURCE_LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: SOURCE_LEAF_ID } as const
|
||||
}
|
||||
]
|
||||
: [],
|
||||
leaves:
|
||||
includeSource && options.rendererMounted
|
||||
? [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: SOURCE_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: SOURCE_PTY_ID
|
||||
}
|
||||
]
|
||||
: [],
|
||||
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
|
||||
})
|
||||
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, null, {
|
||||
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
|
||||
tabId: TAB_ID,
|
||||
leafId: SOURCE_LEAF_ID
|
||||
leafId: SOURCE_LEAF_ID,
|
||||
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
|
||||
})
|
||||
const internals = runtime as unknown as {
|
||||
getTerminalHandleForPaneKey: (paneKey: string) => string | null
|
||||
issuePtyHandle: (pty: unknown) => string
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
ptysById: Map<string, unknown>
|
||||
}
|
||||
const handle =
|
||||
internals.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, SOURCE_LEAF_ID)) ??
|
||||
internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
|
||||
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
|
||||
return {
|
||||
runtime,
|
||||
handle,
|
||||
spawn,
|
||||
kill,
|
||||
retireRejectedPty,
|
||||
stopAndWait,
|
||||
revealTerminalSession,
|
||||
getSession: () => session,
|
||||
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID)
|
||||
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
|
||||
requestedSessionHostIds,
|
||||
replaceSourceIncarnation: (incarnationId: string) =>
|
||||
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
|
||||
tabId: TAB_ID,
|
||||
leafId: SOURCE_LEAF_ID,
|
||||
incarnationId
|
||||
}),
|
||||
replacePersistedSourceIncarnation: (incarnationId: string) => {
|
||||
session = {
|
||||
...session,
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
...session.terminalPtyIncarnationsByPaneKey,
|
||||
[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]: incarnationId
|
||||
}
|
||||
}
|
||||
},
|
||||
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
|
||||
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,4 +261,118 @@ describe('remote runtime terminal split authority', () => {
|
||||
expect.soft(harness.kill).not.toHaveBeenCalled()
|
||||
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'local', connectionId: null, expectedHostId: 'local' },
|
||||
{ label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' }
|
||||
])(
|
||||
'rejects a same-ID $label source replacement when restored persistence lacks an incarnation',
|
||||
async ({ connectionId, expectedHostId }) => {
|
||||
const harness = createHarness(true, {
|
||||
connectionId,
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: false,
|
||||
rendererMounted: true,
|
||||
sourceIncarnationId: 'source-before'
|
||||
})
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
|
||||
expect(
|
||||
harness.getSession().terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]
|
||||
).toBeUndefined()
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedSourceBinding: expect.objectContaining({ ptyId: SOURCE_PTY_ID })
|
||||
})
|
||||
)
|
||||
// Why: persistence never recorded this incarnation, so sending it would make the store
|
||||
// reject every split from a restored pane; the live id is fenced in-runtime instead.
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('source-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.requestedSessionHostIds).toContain(expectedHostId)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a same-ID paired-runtime source replacement recovered without an incarnation map', async () => {
|
||||
const harness = createHarness(true, {
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: true,
|
||||
rendererMounted: false,
|
||||
sourceIncarnationId: 'remote-before'
|
||||
})
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('remote-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a persisted-only source incarnation change during spawn', async () => {
|
||||
const harness = createHarness(true, {
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: false,
|
||||
stopAndWaitResult: true
|
||||
})
|
||||
harness.replacePersistedSourceIncarnation('persisted-before')
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
harness.replacePersistedSourceIncarnation('persisted-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(
|
||||
SPLIT_PTY_ID,
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
})
|
||||
|
||||
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
|
||||
const harness = createHarness(false, {
|
||||
deferReveal: true,
|
||||
includePairedSnapshot: true,
|
||||
sourceIncarnationId: 'projected-before',
|
||||
stopAndWaitResult: false
|
||||
})
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('projected-after')
|
||||
harness.resolveReveal()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(
|
||||
SPLIT_PTY_ID,
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14898,6 +14898,8 @@ describe('OrcaRuntimeService', () => {
|
||||
const splitSpawn = spawn.mock.calls[0]?.[0] as
|
||||
| { expectedSourceBinding?: { incarnationId?: string } }
|
||||
| undefined
|
||||
// Why: persistence never recorded an incarnation for this pane, so sending the live-only id
|
||||
// would make the store's fence reject every split from a restored session.
|
||||
expect(splitSpawn?.expectedSourceBinding).not.toHaveProperty('incarnationId')
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
|
||||
@@ -711,6 +711,7 @@ import {
|
||||
scanLocalRepoWorktreesForResolution,
|
||||
type RuntimeWorktreeScanResult
|
||||
} from './repo-worktree-resolution-scan'
|
||||
import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint'
|
||||
import {
|
||||
getLocalWorktreePathAccess,
|
||||
removeLocalWorktreePath,
|
||||
@@ -1012,6 +1013,7 @@ import {
|
||||
recentTerminalOutputIncludesPath,
|
||||
recentTerminalPathCandidatesIncludePath
|
||||
} from './terminal-output-path-candidates'
|
||||
import { nativeChatTranscriptIncludesPath } from '../native-chat/native-chat-file-provenance'
|
||||
import {
|
||||
getSelectedReviewBranch,
|
||||
getSelectedReviewLookupHints,
|
||||
@@ -1724,6 +1726,7 @@ type RuntimePtyController = {
|
||||
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
|
||||
attach?(ptyId: string): Promise<boolean>
|
||||
kill(ptyId: string): boolean
|
||||
retireRejectedPty?(ptyId: string): void
|
||||
stopAndWait?(
|
||||
ptyId: string,
|
||||
opts?: { keepHistory?: boolean; deadlineMs?: number }
|
||||
@@ -1827,6 +1830,9 @@ const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
|
||||
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
|
||||
// Why: the split already failed; the caller waits on this teardown only to learn whether the
|
||||
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
|
||||
const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
|
||||
const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
|
||||
const SSH_PANE_RECOVERY_GRACE_MS = 30_000
|
||||
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
|
||||
@@ -2531,12 +2537,25 @@ type RuntimeWorktreeScanCache = {
|
||||
runtimeKey: string
|
||||
result: RuntimeWorktreeScanResult
|
||||
expiresAt: number
|
||||
/**
|
||||
* Git-admin state read as of the scan's start, kept unresolved so no caller ever waits on the
|
||||
* probe to receive a scan result. Resolves to `null` when the state could not be read.
|
||||
*/
|
||||
adminFingerprint: Promise<string | null>
|
||||
/** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */
|
||||
scannedAt: number
|
||||
}
|
||||
|
||||
type RuntimeWorktreeScanInFlight = {
|
||||
generation: number
|
||||
runtimeKey: string
|
||||
promise: Promise<RuntimeWorktreeScanResult>
|
||||
promise: Promise<RuntimeWorktreeScanRefresh>
|
||||
}
|
||||
|
||||
type RuntimeWorktreeScanRefresh = {
|
||||
result: RuntimeWorktreeScanResult
|
||||
adminFingerprint: Promise<string | null>
|
||||
scannedAt: number
|
||||
}
|
||||
|
||||
type WorktreeLineageCandidate = {
|
||||
@@ -8764,6 +8783,13 @@ export class OrcaRuntimeService {
|
||||
this.resolveTerminalFileUriHostname(terminalHandle),
|
||||
hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) =>
|
||||
this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath),
|
||||
hasRecentNativeChatOutputPath: (worktreeId, context, pathText, absolutePath) =>
|
||||
nativeChatTranscriptIncludesPath({
|
||||
tabs: this.getMobileSessionTabsForWorktree(worktreeId).tabs,
|
||||
context,
|
||||
pathText,
|
||||
absolutePath
|
||||
}),
|
||||
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
|
||||
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => {
|
||||
if (!this.notifier?.openFile) {
|
||||
@@ -27362,6 +27388,8 @@ export class OrcaRuntimeService {
|
||||
if (!sourceAuthority) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
const sourceIncarnationId =
|
||||
sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId
|
||||
const leafId = randomUUID()
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const paneKey = makePaneKey(parentTabId, leafId)
|
||||
@@ -27388,6 +27416,9 @@ export class OrcaRuntimeService {
|
||||
tabId: parentTabId,
|
||||
leafId: parsedPaneKey.leafId,
|
||||
ptyId: pty.ptyId,
|
||||
// Why: the store can only match its own persisted map, so a live-only id it never
|
||||
// recorded would reject every split from a session restored without incarnations.
|
||||
// The live id is fenced by revalidateSourceAuthority below instead.
|
||||
...(sourceAuthority.persistedIncarnationId
|
||||
? { incarnationId: sourceAuthority.persistedIncarnationId }
|
||||
: {})
|
||||
@@ -27426,17 +27457,29 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
try {
|
||||
const revalidatedAuthority = this.resolveTerminalSplitSourceAuthority(
|
||||
workspace.id,
|
||||
parentTabId,
|
||||
parsedPaneKey.leafId,
|
||||
pty.ptyId
|
||||
)
|
||||
if (!revalidatedAuthority || (sourceAuthority.persisted && !revalidatedAuthority.persisted)) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
const revalidateSourceAuthority = (): void => {
|
||||
const current = this.resolveTerminalSplitSourceAuthority(
|
||||
workspace.id,
|
||||
parentTabId,
|
||||
parsedPaneKey.leafId,
|
||||
pty.ptyId
|
||||
)
|
||||
if (
|
||||
!current ||
|
||||
(sourceAuthority.persisted && !current.persisted) ||
|
||||
(sourceIncarnationId !== null &&
|
||||
(current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId)
|
||||
) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
}
|
||||
revalidateSourceAuthority()
|
||||
if (!sourceAuthority.persisted) {
|
||||
await revealSplit()
|
||||
// Why: rejecting here unmounts the pane the reveal just added only because the retire
|
||||
// below always emits its exit and the tab still holds the source sibling — the renderer's
|
||||
// exit handler closes non-final panes. Never close it by tabId: that drops the whole tab.
|
||||
revalidateSourceAuthority()
|
||||
}
|
||||
if (createdPty) {
|
||||
const persisted = this.persistHeadlessTerminalSplit({
|
||||
@@ -27460,7 +27503,19 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
} catch (error) {
|
||||
this.setPairedRendererSessionOwnership(result.id, false)
|
||||
this.ptyController.kill?.(result.id)
|
||||
let stopped = false
|
||||
try {
|
||||
stopped =
|
||||
(await this.ptyController.stopAndWait?.(result.id, {
|
||||
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS
|
||||
})) ?? false
|
||||
} catch {
|
||||
// Best-effort fallback below preserves the original split authority error.
|
||||
}
|
||||
if (!stopped) {
|
||||
this.ptyController.kill(result.id)
|
||||
}
|
||||
this.ptyController.retireRejectedPty?.(result.id)
|
||||
throw error
|
||||
}
|
||||
const committedSourceAuthority = sourceAuthority.persisted
|
||||
@@ -27489,6 +27544,7 @@ export class OrcaRuntimeService {
|
||||
rendererMounted: boolean
|
||||
persistedWorktreeId: string | null
|
||||
persistedIncarnationId: string | null
|
||||
liveIncarnationId: string | null
|
||||
} | null {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null
|
||||
@@ -27527,7 +27583,8 @@ export class OrcaRuntimeService {
|
||||
persisted: true,
|
||||
rendererMounted,
|
||||
persistedWorktreeId: sessionWorktreeId,
|
||||
persistedIncarnationId
|
||||
persistedIncarnationId,
|
||||
liveIncarnationId
|
||||
}
|
||||
}
|
||||
// Why: renderer adoption can precede graph sync; this path still requires reveal success before commit.
|
||||
@@ -27549,7 +27606,8 @@ export class OrcaRuntimeService {
|
||||
persisted: false,
|
||||
rendererMounted,
|
||||
persistedWorktreeId: null,
|
||||
persistedIncarnationId: null
|
||||
persistedIncarnationId: null,
|
||||
liveIncarnationId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29255,26 +29313,30 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const inFlight = this.worktreeScanInFlight.get(repo.id)
|
||||
if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) {
|
||||
return inFlight.promise
|
||||
return (await inFlight.promise).result
|
||||
}
|
||||
const promise = this.listRepoWorktreesForResolutionUncached(repo, projectRuntime)
|
||||
const reusableCached =
|
||||
cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null
|
||||
const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached)
|
||||
this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise })
|
||||
try {
|
||||
const result = await promise
|
||||
const refresh = await promise
|
||||
// Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll.
|
||||
if (
|
||||
(result.ok || !repo.connectionId) &&
|
||||
(refresh.result.ok || !repo.connectionId) &&
|
||||
generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) &&
|
||||
this.worktreeScanInFlight.get(repo.id)?.promise === promise
|
||||
) {
|
||||
this.worktreeScanCache.set(repo.id, {
|
||||
generation,
|
||||
runtimeKey,
|
||||
result,
|
||||
expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo)
|
||||
result: refresh.result,
|
||||
expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo),
|
||||
adminFingerprint: refresh.adminFingerprint,
|
||||
scannedAt: refresh.scannedAt
|
||||
})
|
||||
}
|
||||
return result
|
||||
return refresh.result
|
||||
} finally {
|
||||
if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) {
|
||||
this.worktreeScanInFlight.delete(repo.id)
|
||||
@@ -29282,6 +29344,50 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap
|
||||
* Git-admin fingerprint proves nothing changed since the cached scan.
|
||||
*/
|
||||
private async refreshRepoWorktreeScan(
|
||||
repo: Repo,
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined,
|
||||
cached: RuntimeWorktreeScanCache | null
|
||||
): Promise<RuntimeWorktreeScanRefresh> {
|
||||
const scannedAt = Date.now()
|
||||
// SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them.
|
||||
const fingerprintCapable =
|
||||
!repo.connectionId &&
|
||||
// Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a
|
||||
// fingerprint, so reading one would be pure work. Agent-scratch roots are that case today.
|
||||
resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS &&
|
||||
!getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime).wslDistro
|
||||
// Why issue it before the scan: a change landing while the scan runs must not be stamped as
|
||||
// already-observed, or the next probe would mask it until the reconciliation deadline.
|
||||
const adminFingerprint = fingerprintCapable
|
||||
? readRepoWorktreeAdminFingerprint(repo.path)
|
||||
: UNFINGERPRINTED_WORKTREE_SCAN
|
||||
const reusable =
|
||||
fingerprintCapable &&
|
||||
cached?.result.ok === true &&
|
||||
scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
|
||||
? cached
|
||||
: null
|
||||
if (reusable) {
|
||||
// Why await only here: this is the one branch whose decision needs the probe. A scan-bound
|
||||
// caller must never wait on it, or every cold read pays filesystem latency it cannot use.
|
||||
const [current, previous] = await Promise.all([adminFingerprint, reusable.adminFingerprint])
|
||||
if (current !== null && current === previous) {
|
||||
return {
|
||||
result: reusable.result,
|
||||
adminFingerprint: reusable.adminFingerprint,
|
||||
scannedAt: reusable.scannedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime)
|
||||
return { result, adminFingerprint, scannedAt }
|
||||
}
|
||||
|
||||
private async listRepoWorktreesForResolutionUncached(
|
||||
repo: Repo,
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined
|
||||
@@ -35606,6 +35712,12 @@ const WORKTREE_SCAN_CACHE_TTL_MS = 30_000
|
||||
// fan-out was measured at ~128 git execs/min on real installs, mostly against
|
||||
// these (crash-cluster diagnostics, 2026-07).
|
||||
const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000
|
||||
// Why: the Git-admin fingerprint reads HEAD and its ref tip exactly, but sparse-checkout pattern
|
||||
// edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size
|
||||
// stamp, so a real scan still runs on this interval even while the probe reports "unchanged".
|
||||
export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000
|
||||
/** Stand-in for a repo the local admin probe cannot or need not describe; never matches a real read. */
|
||||
const UNFINGERPRINTED_WORKTREE_SCAN: Promise<string | null> = Promise.resolve(null)
|
||||
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
|
||||
|
||||
export function resolveWorktreeScanCacheTtlMs(repo: Pick<Repo, 'path' | 'connectionId'>): number {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Real-binary coverage: the fingerprint's whole job is to predict what `git worktree list` would
|
||||
// report, so a mocked filesystem would only prove the assumptions, not the Git layout they model.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let scratchDir = ''
|
||||
let repoPath = ''
|
||||
let worktreePath = ''
|
||||
|
||||
async function git(args: string[], cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd })
|
||||
return stdout
|
||||
}
|
||||
|
||||
async function fingerprint(path = repoPath): Promise<string | null> {
|
||||
return await readRepoWorktreeAdminFingerprint(path)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath: macOS hands out /var/... temp paths while Git records /private/var/... in `gitdir`.
|
||||
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-fingerprint-')))
|
||||
repoPath = join(scratchDir, 'repo')
|
||||
worktreePath = join(scratchDir, 'trees', 'feature')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
await mkdir(join(scratchDir, 'trees'), { recursive: true })
|
||||
await git(['init', '-q'], repoPath)
|
||||
await git(['config', 'user.email', 'fingerprint@example.invalid'], repoPath)
|
||||
await git(['config', 'user.name', 'Fingerprint'], repoPath)
|
||||
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
|
||||
await git(['add', '-A'], repoPath)
|
||||
await git(['commit', '-qm', 'seed'], repoPath)
|
||||
await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(scratchDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('readRepoWorktreeAdminFingerprint', () => {
|
||||
it('is stable while nothing changes', async () => {
|
||||
const first = await fingerprint()
|
||||
expect(first).not.toBeNull()
|
||||
expect(await fingerprint()).toBe(first)
|
||||
})
|
||||
|
||||
it('changes when a worktree is added', async () => {
|
||||
const before = await fingerprint()
|
||||
await git(
|
||||
['worktree', 'add', '-q', join(scratchDir, 'trees', 'second'), '-b', 'second'],
|
||||
repoPath
|
||||
)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when a worktree is removed', async () => {
|
||||
const before = await fingerprint()
|
||||
await git(['worktree', 'remove', worktreePath], repoPath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when a worktree directory is deleted outside Git', async () => {
|
||||
// The admin dir is untouched by `rm -rf`, but the row's `prunable` flag flips.
|
||||
const before = await fingerprint()
|
||||
await rm(worktreePath, { recursive: true, force: true })
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when a worktree is moved', async () => {
|
||||
const before = await fingerprint()
|
||||
await git(['worktree', 'move', worktreePath, join(scratchDir, 'trees', 'moved')], repoPath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when a worktree is locked', async () => {
|
||||
const before = await fingerprint()
|
||||
await git(['worktree', 'lock', worktreePath], repoPath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when a linked worktree switches branch', async () => {
|
||||
const before = await fingerprint()
|
||||
// A longer ref name keeps the difference visible even on a coarse mtime clock.
|
||||
await git(['checkout', '-q', '-b', 'feature-with-a-much-longer-name'], worktreePath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when a linked worktree records a commit', async () => {
|
||||
// Committing rewrites refs/heads/feature and leaves HEAD alone, but moves the reported HEAD oid.
|
||||
const before = await fingerprint()
|
||||
await writeFile(join(worktreePath, 'work.txt'), 'work\n')
|
||||
await git(['add', '-A'], worktreePath)
|
||||
await git(['commit', '-qm', 'work'], worktreePath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when the main worktree records a commit', async () => {
|
||||
const before = await fingerprint()
|
||||
await writeFile(join(repoPath, 'more.txt'), 'more\n')
|
||||
await git(['add', '-A'], repoPath)
|
||||
await git(['commit', '-qm', 'more'], repoPath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('still tracks a tip whose loose ref has been packed away', async () => {
|
||||
await git(['pack-refs', '--all'], repoPath)
|
||||
const before = await fingerprint()
|
||||
await writeFile(join(worktreePath, 'packed.txt'), 'packed\n')
|
||||
await git(['add', '-A'], worktreePath)
|
||||
await git(['commit', '-qm', 'packed'], worktreePath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('changes when the main worktree switches branch', async () => {
|
||||
const before = await fingerprint()
|
||||
await git(['checkout', '-q', '-b', 'main-with-a-much-longer-name'], repoPath)
|
||||
expect(await fingerprint()).not.toBe(before)
|
||||
})
|
||||
|
||||
it('resolves the same admin state from a linked worktree path', async () => {
|
||||
// A linked worktree can itself be the registered repo path; both must describe one common dir.
|
||||
expect(await fingerprint(worktreePath)).toBe(await fingerprint(repoPath))
|
||||
})
|
||||
|
||||
it('reads a bare repo through its own gitdir', async () => {
|
||||
const barePath = join(scratchDir, 'bare.git')
|
||||
await git(['clone', '-q', '--bare', repoPath, barePath], scratchDir)
|
||||
const before = await fingerprint(barePath)
|
||||
expect(before).not.toBeNull()
|
||||
await git(
|
||||
['worktree', 'add', '-q', join(scratchDir, 'trees', 'from-bare'), '-b', 'bare-tree'],
|
||||
barePath
|
||||
)
|
||||
expect(await fingerprint(barePath)).not.toBe(before)
|
||||
})
|
||||
|
||||
it('returns null for a directory that is not a git repo', async () => {
|
||||
const plainPath = join(scratchDir, 'plain')
|
||||
await mkdir(plainPath, { recursive: true })
|
||||
expect(await fingerprint(plainPath)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a missing path', async () => {
|
||||
expect(await fingerprint(join(scratchDir, 'absent'))).toBeNull()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user