mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag
This commit is contained in:
@@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean {
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarnessOptions = {
|
||||
readonly composing?: boolean
|
||||
readonly heldText?: string
|
||||
readonly sentText?: string
|
||||
readonly pendingHandle?: string | null
|
||||
@@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = {
|
||||
}
|
||||
|
||||
function createAccessoryInputCommitHarness({
|
||||
composing,
|
||||
heldText = '',
|
||||
sentText = '',
|
||||
pendingHandle = null,
|
||||
@@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({
|
||||
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
|
||||
const activeHandle = 'terminal-a'
|
||||
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
|
||||
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
|
||||
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
|
||||
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
@@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
|
||||
const applyLiveInputMirror = vi.fn(
|
||||
(_handle: string, _fieldText: string, _composing?: boolean) => {}
|
||||
)
|
||||
const clearPendingLiveInputCommit = vi.fn(() => {})
|
||||
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
||||
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
|
||||
@@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
@@ -192,10 +198,11 @@ describe('terminal live accessory input commit hook', () => {
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
|
||||
it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
composing: true,
|
||||
heldText: 'ni hao',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
@@ -204,14 +211,31 @@ describe('terminal live accessory input commit hook', () => {
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true)
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한글',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined)
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
composing: false,
|
||||
heldText: '',
|
||||
sentText: 'ab',
|
||||
pendingHandle: 'terminal-a'
|
||||
@@ -221,7 +245,7 @@ describe('terminal live accessory input commit hook', () => {
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false)
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,10 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly liveInputComposingRef: RefObject<boolean | undefined>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
@@ -40,6 +41,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
@@ -81,7 +83,8 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
// field is edited here and the mirror diff syncs the PTY echo.
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
applyLiveInputMirror(activeHandle, editedText)
|
||||
// Preserve undefined so Android's heuristic hold still settles on its timer.
|
||||
applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
case 'commit-held-then-send': {
|
||||
@@ -102,6 +105,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
|
||||
@@ -161,6 +161,35 @@ describe('terminal live input commit hook', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
changeLiveInput(handlers, 'ni hao', true)
|
||||
|
||||
// When
|
||||
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2)
|
||||
changeLiveInput(handlers, '你好', false)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
})
|
||||
|
||||
it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
changeLiveInput(handlers, '한글')
|
||||
|
||||
// When
|
||||
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
@@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
|
||||
@@ -34,6 +34,7 @@ type TerminalLivePendingInputFlush = {
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly liveInputComposingRef: RefObject<boolean | undefined>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
@@ -50,6 +51,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
|
||||
const heldLiveInputTextRef = useRef('')
|
||||
const liveInputComposingRef = useRef<boolean | undefined>(undefined)
|
||||
const sentLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
|
||||
@@ -65,6 +67,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
clearHeldCommitTimer()
|
||||
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
heldLiveInputTextRef.current = ''
|
||||
liveInputComposingRef.current = undefined
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
}, [clearHeldCommitTimer])
|
||||
@@ -106,6 +109,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
})
|
||||
sentLiveInputTextRef.current = step.nextSentText
|
||||
heldLiveInputTextRef.current = step.heldText
|
||||
liveInputComposingRef.current = composing
|
||||
pendingLiveInputHandleRef.current =
|
||||
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
|
||||
|
||||
@@ -187,6 +191,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
heldCommitTimerRef.current = null
|
||||
}
|
||||
heldLiveInputTextRef.current = ''
|
||||
liveInputComposingRef.current = undefined
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
@@ -198,6 +203,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputComposingRef,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
|
||||
@@ -206,6 +206,16 @@ describe('orchestration gate commands carry caller identity', () => {
|
||||
'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.',
|
||||
{
|
||||
orchestrationRequestId: 'mutation_1',
|
||||
originalCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'gate-create',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--question',
|
||||
'ship?',
|
||||
'--json'
|
||||
],
|
||||
failedStage: 'dispatch_input',
|
||||
residualResources: [
|
||||
{ kind: 'worktree', id: 'repo::child' },
|
||||
|
||||
+5
-5
@@ -268,9 +268,9 @@ Common Commands:
|
||||
|
||||
Selectors:
|
||||
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
|
||||
--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
|
||||
--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--no-parent Force no parent lineage for unrelated worktree creation/update
|
||||
|
||||
Terminal Send Options:
|
||||
@@ -509,7 +509,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
return '--parent-current Use the current linked issue as parent'
|
||||
}
|
||||
if (command === 'worktree create' && flag === 'parent-worktree') {
|
||||
return '--parent-worktree <selector> Parent selector such as active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
|
||||
return '--parent-worktree <selector> Parent selector such as identity:<identity>, active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
|
||||
}
|
||||
if (command === 'orchestration task-create' && flag === 'task-title') {
|
||||
return '--task-title <text> Concise title for the orchestration task'
|
||||
@@ -577,7 +577,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
|
||||
pages: '--pages <n> Number of scroll pages',
|
||||
'parent-worktree':
|
||||
'--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
'--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
path: '--path <path> Path argument for the command',
|
||||
prompt: '--prompt <text> Prompt text for agent-backed commands',
|
||||
query: '--query <text> Search text for matching refs',
|
||||
@@ -601,7 +601,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
'to-x': '--to-x <x> Destination window-local x coordinate',
|
||||
'to-y': '--to-y <y> Destination window-local y coordinate',
|
||||
worktree:
|
||||
'--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
'--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
workspace: '--workspace <selector> Existing worktree selector for automation runs',
|
||||
'workspace-status':
|
||||
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock('child_process', async () => {
|
||||
})
|
||||
|
||||
import { COMMAND_SPECS, main } from './index'
|
||||
import { formatFlagHelp } from './help'
|
||||
import { GLOBAL_FLAGS, specPaths } from './args'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
|
||||
@@ -480,6 +481,7 @@ describe('orca root help', () => {
|
||||
const rootHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(rootHelp).not.toContain('--parent-workspace')
|
||||
expect(rootHelp).toContain('[--parent-worktree <selector>] [--no-parent]')
|
||||
expect(rootHelp).toContain('identity:<identity>')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
||||
@@ -504,6 +506,7 @@ describe('orca root help', () => {
|
||||
const setHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(setHelp).not.toContain('--parent-workspace')
|
||||
expect(setHelp).not.toContain('folder:<id>')
|
||||
expect(formatFlagHelp('parent-worktree')).toContain('identity:<identity>')
|
||||
expect(setHelp).not.toContain('worktree:<id>')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+4
-1
@@ -18,6 +18,7 @@ import { reportCliError } from './format'
|
||||
import { printHelp } from './help'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command'
|
||||
|
||||
export { COMMAND_SPECS } from './specs'
|
||||
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
|
||||
@@ -149,7 +150,9 @@ export async function main(
|
||||
undefined,
|
||||
undefined,
|
||||
remotePairingCode,
|
||||
remoteEnvironment
|
||||
remoteEnvironment,
|
||||
resolveOrchestrationCliExecutable(),
|
||||
argv
|
||||
)
|
||||
return client
|
||||
},
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../shared/child-process/run-process'
|
||||
import {
|
||||
orchestrationMutationRecoveryError,
|
||||
renderCommand
|
||||
} from './orchestration-mutation-recovery'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
describe('orchestration mutation recovery', () => {
|
||||
it('queries a known dispatch before issuing the keyed retry', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
recovery: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
'request_1'
|
||||
],
|
||||
workerDeathInferred: false
|
||||
}
|
||||
})
|
||||
expect(result.message.indexOf('orca orchestration worker-show')).toBeLessThan(
|
||||
result.message.indexOf('orca orchestration worker-start')
|
||||
)
|
||||
expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([
|
||||
'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.',
|
||||
'Run orca orchestration worker-start --task task_1 --retry-request request_1.'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not invent a dispatch for an old-client-shaped error', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_2',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_2']
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
recovery: {
|
||||
orchestrationRequestId: 'request_2',
|
||||
retryCommand: expect.arrayContaining(['--retry-request', 'request_2']),
|
||||
workerDeathInferred: false
|
||||
}
|
||||
})
|
||||
expect((result.data as Record<string, unknown>).recovery).not.toHaveProperty('dispatchId')
|
||||
expect(result.message).not.toContain('worker death')
|
||||
})
|
||||
|
||||
it('renders the exact executable and safely quotes original arguments', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_3',
|
||||
dispatchId: 'dispatch_3',
|
||||
originalCommand: [
|
||||
'orca-dev',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task 3',
|
||||
'--comment',
|
||||
'literal $(do-not-run)'
|
||||
]
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([
|
||||
'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.',
|
||||
"Run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3."
|
||||
])
|
||||
expect(result.message).toContain("'literal $(do-not-run)'")
|
||||
})
|
||||
|
||||
it('parses legacy command text without losing quoted arguments', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_4',
|
||||
originalCommand:
|
||||
'orca-ide orchestration worker-stop --dispatch dispatch_4 --comment "quoted value"'
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(
|
||||
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
|
||||
).toEqual([
|
||||
'orca-ide',
|
||||
'orchestration',
|
||||
'worker-stop',
|
||||
'--dispatch',
|
||||
'dispatch_4',
|
||||
'--comment',
|
||||
'quoted value',
|
||||
'--retry-request',
|
||||
'request_4'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'gate-create',
|
||||
['orca', 'orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?']
|
||||
],
|
||||
[
|
||||
'worker-retain',
|
||||
['orca', 'orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json']
|
||||
]
|
||||
])('replays exact %s argv with the keyed retry', (_name, originalCommand) => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_exact',
|
||||
originalCommand
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(
|
||||
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
|
||||
).toEqual([...originalCommand, '--retry-request', 'request_exact'])
|
||||
})
|
||||
|
||||
it('reuses the request identity without duplicating an existing retry flag', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_reused',
|
||||
originalCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-retain',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--retry-request=request_reused'
|
||||
]
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(
|
||||
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
|
||||
).toEqual([
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-retain',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--retry-request',
|
||||
'request_reused'
|
||||
])
|
||||
})
|
||||
|
||||
it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => {
|
||||
expect(
|
||||
renderCommand(
|
||||
['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'],
|
||||
'win32',
|
||||
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
|
||||
)
|
||||
).toBe(
|
||||
'"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell and POSIX recovery guidance literal', () => {
|
||||
expect(
|
||||
renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', {
|
||||
ComSpec: 'powershell.exe'
|
||||
})
|
||||
).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'")
|
||||
expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe(
|
||||
"orca 'literal $(do-not-run)'"
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'round trips POSIX recovery argv through /bin/sh',
|
||||
async () => {
|
||||
const values = ['with spaces', "apostrophe's", 'literal $(do-not-run)', 'line one\nline two']
|
||||
const command = renderCommand(
|
||||
[
|
||||
process.execPath,
|
||||
'-e',
|
||||
'process.stdout.write(JSON.stringify(process.argv.slice(1)))',
|
||||
...values
|
||||
],
|
||||
'darwin'
|
||||
)
|
||||
|
||||
const result = await runProcess({ program: '/bin/sh', args: ['-c', command] })
|
||||
|
||||
expect(result).toMatchObject({ code: 0, stderr: '', timedOut: false })
|
||||
expect(JSON.parse(result.stdout)).toEqual(values)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[
|
||||
'split',
|
||||
['orca', 'orchestration', 'send', '--pairing-code', 'split-secret', '--subject', 'status'],
|
||||
'split-secret'
|
||||
],
|
||||
[
|
||||
'equals',
|
||||
['orca', 'orchestration', 'send', '--pairing-code=equals-secret', '--subject', 'status'],
|
||||
'equals-secret'
|
||||
],
|
||||
[
|
||||
'dispatch split',
|
||||
[
|
||||
'orca',
|
||||
'orchestration',
|
||||
'send',
|
||||
'--dispatch-capability',
|
||||
'split-dispatch-secret',
|
||||
'--subject',
|
||||
'status'
|
||||
],
|
||||
'split-dispatch-secret'
|
||||
],
|
||||
[
|
||||
'dispatch equals',
|
||||
[
|
||||
'orca',
|
||||
'orchestration',
|
||||
'send',
|
||||
'--dispatch-capability=equals-dispatch-secret',
|
||||
'--subject',
|
||||
'status'
|
||||
],
|
||||
'equals-dispatch-secret'
|
||||
]
|
||||
])('blocks recovery and removes %s credentials', (_name, originalCommand, secret) => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_secret',
|
||||
originalCommand
|
||||
})
|
||||
) as RuntimeClientError
|
||||
const output = JSON.stringify({ message: result.message, data: result.data })
|
||||
|
||||
expect(output).not.toContain(secret)
|
||||
expect(result.message).toContain('Recovery is blocked')
|
||||
expect(result.data).toMatchObject({
|
||||
recovery: {
|
||||
orchestrationRequestId: 'request_secret',
|
||||
recoveryBlocked: true
|
||||
}
|
||||
})
|
||||
expect(result.data).not.toHaveProperty('originalCommand')
|
||||
expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,12 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import {
|
||||
recoverableOrchestrationArgs,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './runtime/orchestration-recovery-command'
|
||||
import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line'
|
||||
import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument'
|
||||
import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell'
|
||||
import type { AgentStartupShell } from '../shared/tui-agent-startup-shell'
|
||||
|
||||
export function orchestrationMutationRecoveryError(error: unknown): unknown {
|
||||
if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) {
|
||||
@@ -9,16 +17,64 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown {
|
||||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||
return error
|
||||
}
|
||||
const dispatchId = typeof data?.dispatchId === 'string' ? data.dispatchId : undefined
|
||||
const parsedOriginalCommand = commandParts(data?.originalCommand)
|
||||
const originalCommand = parsedOriginalCommand
|
||||
? recoverableOrchestrationArgs(parsedOriginalCommand)
|
||||
: undefined
|
||||
const safeData = { ...data }
|
||||
delete safeData.originalCommand
|
||||
const retryCommand = originalCommand
|
||||
? [...originalCommand, '--retry-request', requestId]
|
||||
: undefined
|
||||
const executable = originalCommand?.[0] ?? resolveOrchestrationCliExecutable()
|
||||
const queryCommand = dispatchId
|
||||
? [executable, 'orchestration', 'worker-show', '--dispatch', dispatchId, '--json']
|
||||
: undefined
|
||||
const recovery = {
|
||||
orchestrationRequestId: requestId,
|
||||
...(dispatchId ? { dispatchId } : {}),
|
||||
...(queryCommand ? { queryCommand } : {}),
|
||||
...(retryCommand ? { retryCommand } : {}),
|
||||
recoveryBlocked: !retryCommand,
|
||||
disposition: 'outcome_unknown',
|
||||
workerDeathInferred: false
|
||||
}
|
||||
const retryStep = retryCommand
|
||||
? `Run ${renderCommand(retryCommand)}.`
|
||||
: 'Recovery is blocked until the exact original command is available; no retry command was emitted.'
|
||||
const nextSteps = queryCommand
|
||||
? [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep]
|
||||
: [retryStep]
|
||||
const message = [
|
||||
stripUnsafeRetryAdvice(error.message, requestId),
|
||||
'The orchestration mutation may already have taken effect; do not assume it failed.',
|
||||
`Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`,
|
||||
...nextSteps,
|
||||
typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined,
|
||||
Array.isArray(data?.residualResources)
|
||||
? `Residual resources: ${JSON.stringify(data.residualResources)}.`
|
||||
: undefined
|
||||
].filter((line): line is string => line !== undefined)
|
||||
return new RuntimeClientError(error.code, message.join('\n'), error.data)
|
||||
const recoveredData = {
|
||||
...safeData,
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand ? { originalCommand } : {}),
|
||||
recovery,
|
||||
nextSteps
|
||||
}
|
||||
// Preserve the RPC failure envelope so --json callers retain the request id and
|
||||
// runtime metadata while receiving the structured recovery guidance.
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: {
|
||||
...error.response.error,
|
||||
message: message.join('\n'),
|
||||
data: recoveredData
|
||||
}
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(error.code, message.join('\n'), recoveredData)
|
||||
}
|
||||
|
||||
function isUnknownMutationOutcomeCode(code: string): boolean {
|
||||
@@ -36,6 +92,110 @@ function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
function commandParts(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value) && value.every((part) => typeof part === 'string')) {
|
||||
return [...value]
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return parseCommandLine(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseCommandLine(value: string): string[] | undefined {
|
||||
const parts: string[] = []
|
||||
let part = ''
|
||||
let quote: "'" | '"' | undefined
|
||||
let tokenStarted = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index]
|
||||
if (quote === "'") {
|
||||
if (character === "'") {
|
||||
quote = undefined
|
||||
} else {
|
||||
part += character
|
||||
}
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (character === '"') {
|
||||
quote = undefined
|
||||
} else if (character === '\\' && ['"', '\\'].includes(value[index + 1] ?? '')) {
|
||||
part += value[++index]
|
||||
} else {
|
||||
part += character
|
||||
}
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character
|
||||
tokenStarted = true
|
||||
} else if (/\s/.test(character)) {
|
||||
if (tokenStarted) {
|
||||
parts.push(part)
|
||||
part = ''
|
||||
tokenStarted = false
|
||||
}
|
||||
} else if (character === '\\' && [' ', '\\', "'", '"'].includes(value[index + 1] ?? '')) {
|
||||
part += value[++index]
|
||||
tokenStarted = true
|
||||
} else {
|
||||
part += character
|
||||
tokenStarted = true
|
||||
}
|
||||
}
|
||||
if (quote !== undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (tokenStarted) {
|
||||
parts.push(part)
|
||||
}
|
||||
return parts.length > 0 ? parts : undefined
|
||||
}
|
||||
|
||||
export function renderCommand(
|
||||
command: readonly string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
const shell = resolveRecoveryShell(platform, env)
|
||||
const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ')
|
||||
return shell === 'powershell' && rendered ? `& ${rendered}` : rendered
|
||||
}
|
||||
|
||||
function resolveRecoveryShell(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv
|
||||
): AgentStartupShell {
|
||||
if (platform !== 'win32') {
|
||||
return 'posix'
|
||||
}
|
||||
return resolveWindowsShellStartupFamily(
|
||||
env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC
|
||||
)
|
||||
}
|
||||
|
||||
function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string {
|
||||
if (shell === 'cmd') {
|
||||
// cmd has no single-quote syntax and expands %VAR% inside double quotes;
|
||||
// use the argv encoder that keeps quote parity and escapes percent pairs.
|
||||
return quoteWindowsCmdArgument(value)
|
||||
}
|
||||
if (shell === 'powershell') {
|
||||
return quotePowerShellNativeArgument(value)
|
||||
}
|
||||
return shellQuote(value)
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
function stripUnsafeRetryAdvice(message: string, requestId: string): string {
|
||||
return message
|
||||
.replace(' Restart Orca and try again.', '')
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './types'
|
||||
|
||||
export function attachMutationRecovery(
|
||||
error: unknown,
|
||||
requestId: string | undefined,
|
||||
originalCommand?: string[]
|
||||
): unknown {
|
||||
if (!requestId || !(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
const data = {
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand ? { originalCommand } : {})
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: {
|
||||
...error.response.error,
|
||||
message: `${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
data
|
||||
}
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(
|
||||
error.code,
|
||||
`${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
data
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createServer, type Server } from 'node:net'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client'
|
||||
|
||||
const servers = new Set<Server>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
)
|
||||
)
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
describe('RuntimeClient orchestration recovery identity', () => {
|
||||
it('rejects a worker-start timeout whose client grace would overflow timers', () => {
|
||||
const client = new RuntimeClient(undefined, 60_000, null, null, 'orca')
|
||||
const resolve = (
|
||||
client as unknown as {
|
||||
resolveMethodTimeoutMs: (method: string, params?: unknown) => number
|
||||
}
|
||||
).resolveMethodTimeoutMs.bind(client)
|
||||
const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS
|
||||
expect(resolve('orchestration.workerStart', { timeoutMs: maxValid })).toBe(MAX_TIMER_DELAY_MS)
|
||||
expect(() => resolve('orchestration.workerStart', { timeoutMs: maxValid + 1 })).toThrow(
|
||||
'derived timeout must be'
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches the request and exact retry identity to a real RPC failure response', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string }
|
||||
const response =
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
: {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_timeout',
|
||||
message: 'request timed out',
|
||||
data: { requestId: 'request_1', dispatchId: 'dispatch_1' }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
socket.end(`${JSON.stringify(response)}\n`)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
try {
|
||||
await client.call('orchestration.workerStart', { task: 'task_1' })
|
||||
throw new Error('expected worker-start failure')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
const recovered = orchestrationMutationRecoveryError(error) as {
|
||||
data?: Record<string, unknown>
|
||||
response?: { id?: string; _meta?: { runtimeId?: string } }
|
||||
}
|
||||
expect(recovered).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect(recovered.response).toMatchObject({
|
||||
id: expect.any(String),
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
expect(recovered.data).toMatchObject({
|
||||
orchestrationRequestId: expect.any(String),
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'],
|
||||
recovery: {
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
expect.any(String)
|
||||
],
|
||||
recoveryBlocked: false
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
+45
-19
@@ -11,6 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus, projectRemoteAppStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { attachMutationRecovery } from './client-error-recovery'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
@@ -19,6 +20,16 @@ import {
|
||||
import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
import {
|
||||
isWorkerStartTimeoutWithinTimerLimit,
|
||||
resolveWorkerStartClientTimeoutMs,
|
||||
resolveWorkerStartReadinessTimeoutMs
|
||||
} from '../../shared/orchestration-timing-budgets'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import {
|
||||
buildOrchestrationRecoveryCommand,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './orchestration-recovery-command'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -40,6 +51,8 @@ export class RuntimeClient {
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly remotePairing: PairingOffer | null
|
||||
private readonly environmentSelector: string | null
|
||||
private readonly cliExecutable: string
|
||||
private readonly originalArgs: readonly string[] | undefined
|
||||
private readonly remoteCompat: RemoteRuntimeCompatGate
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
|
||||
@@ -53,11 +66,15 @@ export class RuntimeClient {
|
||||
userDataPath = getDefaultUserDataPath(),
|
||||
requestTimeoutMs = 60_000,
|
||||
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
|
||||
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
|
||||
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null,
|
||||
cliExecutable = resolveOrchestrationCliExecutable(),
|
||||
originalArgs?: readonly string[]
|
||||
) {
|
||||
this.userDataPath = userDataPath
|
||||
this.requestTimeoutMs = requestTimeoutMs
|
||||
this.environmentSelector = environmentSelector
|
||||
this.cliExecutable = cliExecutable
|
||||
this.originalArgs = originalArgs ? [...originalArgs] : undefined
|
||||
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
|
||||
this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector)
|
||||
}
|
||||
@@ -79,6 +96,9 @@ export class RuntimeClient {
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const originalCommand = orchestrationMutation
|
||||
? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs)
|
||||
: undefined
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
@@ -107,10 +127,14 @@ export class RuntimeClient {
|
||||
envelope
|
||||
})
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
}
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
@@ -124,10 +148,14 @@ export class RuntimeClient {
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -138,6 +166,18 @@ export class RuntimeClient {
|
||||
// to resolve. Without this, a 5 min wait would still die at the 60 s default.
|
||||
// See design doc §3.1.
|
||||
private resolveMethodTimeoutMs(method: string, params?: unknown): number {
|
||||
if (method === 'orchestration.workerStart') {
|
||||
const requestedValue = getTimeoutMsParam(params)
|
||||
const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue)
|
||||
if (!isWorkerStartTimeoutWithinTimerLimit(requested)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.`
|
||||
)
|
||||
}
|
||||
const readiness = resolveWorkerStartReadinessTimeoutMs(requested)
|
||||
return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs)
|
||||
}
|
||||
if (
|
||||
(method === 'orchestration.check' && isWaitingCheck(params)) ||
|
||||
method === 'terminal.wait'
|
||||
@@ -241,20 +281,6 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown {
|
||||
if (!requestId || !(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
return new RuntimeClientError(
|
||||
error.code,
|
||||
`${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
{
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function throwDesktopActivationBlocked(): never {
|
||||
throw new RuntimeClientError(
|
||||
'desktop_activation_blocked',
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildOrchestrationRecoveryCommand,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './orchestration-recovery-command'
|
||||
|
||||
describe('orchestration recovery command identity', () => {
|
||||
it.each([
|
||||
['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'],
|
||||
['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'],
|
||||
['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'],
|
||||
['packaged Linux', {}, 'linux', 'orca-ide'],
|
||||
['local macOS', {}, 'darwin', 'orca'],
|
||||
['local Windows', {}, 'win32', 'orca']
|
||||
] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => {
|
||||
expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected)
|
||||
})
|
||||
|
||||
it('reconstructs the keyed worker-start command from its RPC params', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand(
|
||||
'orchestration.workerStart',
|
||||
{
|
||||
task: 'task_1',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
timeoutMs: 90_000,
|
||||
devMode: false
|
||||
},
|
||||
'orca'
|
||||
)
|
||||
).toEqual([
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--on',
|
||||
'windows',
|
||||
'--worktree',
|
||||
'new-top-level',
|
||||
'--timeout-ms',
|
||||
'90000'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the selected executable and raw command arguments', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand(
|
||||
'orchestration.workerStart',
|
||||
{ task: 'task_1' },
|
||||
'orca-dev',
|
||||
[
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--comment',
|
||||
'literal $(do-not-run) "quoted"',
|
||||
'--json'
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
'orca-dev',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--comment',
|
||||
'literal $(do-not-run) "quoted"',
|
||||
'--json'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'gate-create',
|
||||
'orchestration.gateCreate',
|
||||
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?']
|
||||
],
|
||||
[
|
||||
'worker-retain',
|
||||
'orchestration.workerRetain',
|
||||
['orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json']
|
||||
]
|
||||
] as const)('preserves exact raw argv for %s recovery', (_name, method, args) => {
|
||||
expect(buildOrchestrationRecoveryCommand(method, {}, 'orca', args)).toEqual(['orca', ...args])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']],
|
||||
['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']],
|
||||
[
|
||||
'dispatch split',
|
||||
['orchestration', 'send', '--dispatch-capability', 'secret', '--subject', 'status']
|
||||
],
|
||||
[
|
||||
'dispatch equals',
|
||||
['orchestration', 'send', '--dispatch-capability=secret', '--subject', 'status']
|
||||
]
|
||||
])('blocks %s credential argv instead of exposing it', (_name, args) => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orchestration.send', {}, 'orca', args)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('supports the explicit executable-first form', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', {
|
||||
dispatch: 'dispatch_1'
|
||||
})
|
||||
).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1'])
|
||||
})
|
||||
|
||||
it('reconstructs worker-retain when raw argv is unavailable', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orchestration.workerRetain', {
|
||||
dispatch: 'dispatch_1'
|
||||
})
|
||||
).toEqual([
|
||||
resolveOrchestrationCliExecutable(),
|
||||
'orchestration',
|
||||
'worker-retain',
|
||||
'--dispatch',
|
||||
'dispatch_1'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fabricate identity for unsupported mutations or malformed params', () => {
|
||||
expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined()
|
||||
expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
export function resolveOrchestrationCliExecutable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string {
|
||||
const configured = env.ORCA_CLI_COMMAND?.trim()
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
if (env.ORCA_DEV_REPO_ROOT) {
|
||||
return 'orca-dev'
|
||||
}
|
||||
return platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
method: string,
|
||||
params: unknown,
|
||||
executable?: string,
|
||||
originalArgs?: readonly string[]
|
||||
): string[] | undefined
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
executable: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
originalArgs?: readonly string[]
|
||||
): string[] | undefined
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
methodOrExecutable: string,
|
||||
paramsOrMethod: unknown,
|
||||
executableOrParams?: unknown,
|
||||
originalArgs?: readonly string[]
|
||||
): string[] | undefined {
|
||||
const legacyOrder = typeof paramsOrMethod === 'string' && executableOrParams !== undefined
|
||||
const method = (legacyOrder ? paramsOrMethod : methodOrExecutable) as string
|
||||
const params = legacyOrder ? executableOrParams : paramsOrMethod
|
||||
const executable = legacyOrder
|
||||
? methodOrExecutable
|
||||
: typeof executableOrParams === 'string'
|
||||
? executableOrParams
|
||||
: resolveOrchestrationCliExecutable()
|
||||
if (originalArgs && originalArgs.length > 0) {
|
||||
const recoverableArgs = recoverableOrchestrationArgs(originalArgs)
|
||||
return recoverableArgs ? [executable, ...recoverableArgs] : undefined
|
||||
}
|
||||
const command =
|
||||
method === 'orchestration.workerStart'
|
||||
? 'worker-start'
|
||||
: method === 'orchestration.workerStop'
|
||||
? 'worker-stop'
|
||||
: method === 'orchestration.workerAbandon'
|
||||
? 'worker-abandon'
|
||||
: method === 'orchestration.workerRelease'
|
||||
? 'worker-release'
|
||||
: method === 'orchestration.workerRetain'
|
||||
? 'worker-retain'
|
||||
: undefined
|
||||
if (!command || params === null || typeof params !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const record = params as Record<string, unknown>
|
||||
const requiredKey = command === 'worker-start' ? 'task' : 'dispatch'
|
||||
if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const args = [executable, 'orchestration', command]
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (value === undefined || value === null || value === false) {
|
||||
continue
|
||||
}
|
||||
const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`
|
||||
if (value === true) {
|
||||
args.push(flag)
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
args.push(flag, String(value))
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
export function recoverableOrchestrationArgs(args: readonly string[]): string[] | undefined {
|
||||
const containsCredential = args.some((arg) =>
|
||||
['--pairing-code', '--dispatch-capability'].some(
|
||||
(flag) => arg === flag || arg.startsWith(`${flag}=`)
|
||||
)
|
||||
)
|
||||
if (containsCredential) {
|
||||
return undefined
|
||||
}
|
||||
const result: string[] = []
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--retry-request') {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--retry-request=')) {
|
||||
continue
|
||||
}
|
||||
result.push(arg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
|
||||
const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted(
|
||||
() => ({
|
||||
sendRemoteRuntimeRequest: vi.fn(),
|
||||
sendRemoteRuntimeRequestWithStatusPreflight: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
RemoteRuntimeClientError,
|
||||
sendRemoteRuntimeRequest,
|
||||
sendRemoteRuntimeRequestWithStatusPreflight
|
||||
}))
|
||||
|
||||
import {
|
||||
sendWebSocketRequest,
|
||||
sendWebSocketRequestWithStatusPreflight
|
||||
} from './websocket-transport'
|
||||
|
||||
const pairing = {
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:1',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key'
|
||||
} as never
|
||||
|
||||
describe('CLI WebSocket transport error identity', () => {
|
||||
it('preserves recovery data from one-shot transport errors', async () => {
|
||||
sendRemoteRuntimeRequest.mockRejectedValueOnce(
|
||||
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
|
||||
data: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100)
|
||||
throw new Error('expected transport failure')
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({
|
||||
code: 'runtime_timeout',
|
||||
data: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
}
|
||||
})
|
||||
expect(orchestrationMutationRecoveryError(error)).toMatchObject({
|
||||
data: {
|
||||
recovery: {
|
||||
dispatchId: 'dispatch_1',
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
'request_1'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves recovery data through status-preflight transport errors', async () => {
|
||||
sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce(
|
||||
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
|
||||
data: { orchestrationRequestId: 'request_2' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
sendWebSocketRequestWithStatusPreflight(
|
||||
pairing,
|
||||
'orchestration.workerStart',
|
||||
{},
|
||||
100,
|
||||
() => {}
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'runtime_timeout',
|
||||
data: { orchestrationRequestId: 'request_2' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ export async function sendWebSocketRequest<TResult>(
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs, envelope)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient)
|
||||
// server, so cwd-derived worktree selectors are only valid locally.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
|
||||
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.',
|
||||
'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.',
|
||||
'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.',
|
||||
'When present, draft is UI-only composer text excluded from tail; never treat it as terminal output or a submitted instruction.',
|
||||
'--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.',
|
||||
'Use --cursor with the nextCursor value from a previous read to get only new output since that read.',
|
||||
'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalRead } from './terminal-format'
|
||||
|
||||
describe('formatTerminalRead draft separation', () => {
|
||||
it('labels composer drafts outside terminal output', () => {
|
||||
const output = formatTerminalRead({
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
status: 'running',
|
||||
tail: ['Build passed', '❯'],
|
||||
truncated: false,
|
||||
nextCursor: null,
|
||||
source: 'screen',
|
||||
draft: 'proceed with the release'
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('draft: "proceed with the release"')
|
||||
expect(output).toContain('\n\nBuild passed\n❯')
|
||||
})
|
||||
})
|
||||
@@ -141,6 +141,7 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s
|
||||
`handle: ${terminal.handle}`,
|
||||
`status: ${terminal.status}`,
|
||||
...(terminal.source ? [`source: ${terminal.source}`] : []),
|
||||
...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []),
|
||||
...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []),
|
||||
...oldestCursor,
|
||||
...latestCursor,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
|
||||
import type { RemoteSessionSource } from './remote-session-scanner-types'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath,
|
||||
parseClineSessionContent
|
||||
} from './session-scanner-cline-parser'
|
||||
|
||||
export function remoteClineSource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent: 'cline',
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
directoryPredicate: (_name, depth) => depth === 0,
|
||||
parse: async (file, content, context) => {
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path))
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
messagesContent = read.isBinary ? null : read.content
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (!isMissingRemoteSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
const files = await mapRemoteScanBatches(
|
||||
paths,
|
||||
REMOTE_DISCOVERY_CONCURRENCY,
|
||||
(path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{
|
||||
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
|
||||
signal: args.context.signal
|
||||
}
|
||||
),
|
||||
(path) => statRemoteCandidateFile(path, args.source, args.context, args.issues),
|
||||
args.context.signal
|
||||
)
|
||||
return files
|
||||
@@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
}))
|
||||
}
|
||||
|
||||
async function statRemoteCandidateFile(
|
||||
path: string,
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
const file = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
path,
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{
|
||||
missingIsExpected: Boolean(source.fixedChildFileSegments),
|
||||
signal: context.signal
|
||||
}
|
||||
)
|
||||
if (!file || !source.contentDependencyPath) {
|
||||
return file
|
||||
}
|
||||
const dependency = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
source.contentDependencyPath(path),
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{ missingIsExpected: true, signal: context.signal }
|
||||
)
|
||||
if (!dependency) {
|
||||
return file
|
||||
}
|
||||
const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs)
|
||||
return {
|
||||
...file,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteFixedChildFiles(
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import { remoteClineSource } from './remote-session-scanner-cline-source'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
@@ -77,6 +78,7 @@ export function remoteSessionSources(
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
remoteClineSource(remoteHome, hostPlatform),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
|
||||
@@ -33,6 +33,7 @@ export type RemoteSessionSource = {
|
||||
codexHome?: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
contentDependencyPath?: (path: string) => string
|
||||
// Depth 0 denotes a direct child of rootDir.
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// A canonical file directly beneath every top-level session directory.
|
||||
|
||||
@@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f
|
||||
import { primeAgentFixture } from './session-scanner-prime-agent-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/home/ada/repo'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix remote Cline history' }]
|
||||
}
|
||||
]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
title: 'Fix remote Cline history',
|
||||
cwd: '/home/ada/repo',
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
filePath: `${sessionDir}/${sessionId}.json`
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes and resumes Cline sessions from a Windows SSH host', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
cwd: 'C:/repo/app'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
executionHostPlatform: 'win32',
|
||||
filePath: `${sessionDir}/${sessionId}.json`,
|
||||
resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""`
|
||||
})
|
||||
})
|
||||
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
|
||||
@@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions')
|
||||
const GROK_ROOT = join(HOME, '.grok', 'sessions')
|
||||
|
||||
describe('validateAiVaultSessionDeleteTarget', () => {
|
||||
it('allows a canonical Cline manifest and removes its whole session directory', () => {
|
||||
const root = join('/tmp', 'cline-sessions')
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = join(root, sessionId)
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'cline',
|
||||
filePath: join(sessionDir, `${sessionId}.json`),
|
||||
executionHostId: 'local',
|
||||
rootOptions: { clineSessionsDir: root }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
agent: 'cline',
|
||||
removals: [{ path: sessionDir, kind: 'directory' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a Cline messages companion as an undiscoverable delete target', () => {
|
||||
const root = join('/tmp', 'cline-sessions')
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'cline',
|
||||
filePath: join(root, sessionId, `${sessionId}.messages.json`),
|
||||
executionHostId: 'local',
|
||||
rootOptions: { clineSessionsDir: root }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' })
|
||||
})
|
||||
|
||||
it('allows a supported agent whose file resolves inside its known root', () => {
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'gemini',
|
||||
|
||||
@@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types'
|
||||
// Agents whose session IS the directory holding the scanned file: everything
|
||||
// beside it belongs to the same session (rovo's session_context.json, grok's
|
||||
// chat_history.jsonl), so the directory is the only complete delete unit.
|
||||
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>(['rovo', 'grok'])
|
||||
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>([
|
||||
'rovo',
|
||||
'grok',
|
||||
'cline'
|
||||
])
|
||||
|
||||
export type ValidateAiVaultSessionDeleteTargetArgs = {
|
||||
agent: AiVaultAgent
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionFile } from './session-scanner-devin-parser'
|
||||
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseClineSessionFile } from './session-scanner-cline-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
@@ -72,6 +73,8 @@ export async function parseAgentSessionFile(
|
||||
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform)
|
||||
case 'droid':
|
||||
return parseDroidSessionFile(candidate.file, platform)
|
||||
case 'cline':
|
||||
return parseClineSessionFile(candidate.file, platform)
|
||||
case 'devin':
|
||||
return parseDevinSessionFile(candidate.file, platform)
|
||||
case 'kimi':
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion'
|
||||
import { resolveGrokSessionsDir } from '../../shared/grok-session-paths'
|
||||
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath
|
||||
} from './session-scanner-cline-parser'
|
||||
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
|
||||
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
|
||||
import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots'
|
||||
@@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join(
|
||||
)
|
||||
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
|
||||
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
|
||||
const CLINE_SESSIONS_DIR =
|
||||
process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions')
|
||||
|
||||
/**
|
||||
* Where one agent's session files live and which of them count as sessions.
|
||||
@@ -55,6 +61,8 @@ export type AiVaultAgentSource = {
|
||||
rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[]
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (filePath: string) => boolean
|
||||
// A sibling whose stat participates in candidate freshness and recency.
|
||||
contentDependencyPath?: (filePath: string) => string
|
||||
// Return false to skip a directory; depth 0 is a child of the root.
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// Roots that are alternates for one install rather than distinct locations,
|
||||
@@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
|
||||
],
|
||||
extensions: ['.jsonl']
|
||||
},
|
||||
cline: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [
|
||||
'.cline',
|
||||
'data',
|
||||
'sessions'
|
||||
]),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
// Cline stores one manifest directly beneath each session directory.
|
||||
directoryPredicate: (_name, depth) => depth === 0
|
||||
},
|
||||
kimi: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
describe('Cline AI Vault sessions', () => {
|
||||
it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = join(roots.clineSessionsDir, sessionId)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: 'cli',
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
status: 'idle',
|
||||
interactive: true,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/repo/cline',
|
||||
workspace_root: '/repo/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
agent: 'lead',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix the Cline vault scanner' }]
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will inspect the scanner.' }],
|
||||
ts: 1_786_466_280_000,
|
||||
modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
|
||||
expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({
|
||||
sessionId,
|
||||
title: 'Fix the Cline vault scanner',
|
||||
cwd: '/repo/cline',
|
||||
model: 'deepseek-v4-flash',
|
||||
messageCount: 2,
|
||||
resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`,
|
||||
filePath: join(sessionDir, `${sessionId}.json`)
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:39:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({
|
||||
messageCount: 3,
|
||||
updatedAt: '2026-08-11T16:39:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('only indexes manifests directly beneath a session directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = 'direct-session'
|
||||
const nestedId = 'nested-session'
|
||||
await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true })
|
||||
await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true })
|
||||
const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' })
|
||||
await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata)
|
||||
await writeFile(
|
||||
join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`),
|
||||
JSON.stringify({ session_id: nestedId, cwd: '/unexpected' })
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
|
||||
expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractString,
|
||||
normalizeTitleText
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export function isClineSessionMetadataPath(filePath: string): boolean {
|
||||
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const fileName = segments.pop()
|
||||
const sessionId = segments.pop()
|
||||
return Boolean(fileName && sessionId && fileName === `${sessionId}.json`)
|
||||
}
|
||||
|
||||
export function clineMessagesPathForMetadata(filePath: string): string {
|
||||
return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : ''
|
||||
}
|
||||
|
||||
export async function parseClineSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan')
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
messagesContent = await wslGatedReadFile(
|
||||
clineMessagesPathForMetadata(file.path),
|
||||
'utf-8',
|
||||
'scan'
|
||||
)
|
||||
} catch (error) {
|
||||
// The manifest is written before the first turn; a missing messages file is
|
||||
// a valid metadata-only session, while a WSL gate refusal must stay visible.
|
||||
if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, metadataContent, messagesContent, platform)
|
||||
}
|
||||
|
||||
function isMissingSessionPathError(error: unknown): boolean {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: null
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
export function parseClineSessionContent(
|
||||
file: FileWithMtime,
|
||||
metadataContent: string,
|
||||
messagesContent: string | null,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const metadata = parseJsonRecord(metadataContent)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? ''
|
||||
const accumulator = createAccumulator({ agent: 'cline', file, sessionId })
|
||||
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
|
||||
accumulator.model = extractString(metadata.model)
|
||||
updateTimeline(accumulator, metadata.started_at)
|
||||
|
||||
const messages = messagesContent ? parseJsonRecord(messagesContent) : null
|
||||
if (messages) {
|
||||
updateTimeline(accumulator, messages.updated_at)
|
||||
for (const value of arrayValue(messages.messages)) {
|
||||
const message = asRecord(value)
|
||||
const role = message?.role
|
||||
if (!message || (role !== 'user' && role !== 'assistant')) {
|
||||
continue
|
||||
}
|
||||
accumulator.messageCount++
|
||||
updateTimeline(accumulator, message.ts)
|
||||
const content = message.content
|
||||
if (role === 'user' && !accumulator.fallbackTitle) {
|
||||
accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '')
|
||||
}
|
||||
if (role === 'assistant' && !accumulator.model) {
|
||||
accumulator.model = extractString(asRecord(message.modelInfo)?.id)
|
||||
}
|
||||
addPreviewContent(accumulator, role, content, message.ts)
|
||||
}
|
||||
}
|
||||
accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '')
|
||||
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
function parseJsonRecord(content: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return asRecord(JSON.parse(content) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export async function discoverFiles(args: {
|
||||
issues: AiVaultScanIssue[]
|
||||
extensions: string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
contentDependencyPath?: (path: string) => string
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
let paths: string[]
|
||||
@@ -41,11 +42,13 @@ export async function discoverFiles(args: {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const fileStat = await wslGatedStat(path, 'scan')
|
||||
const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path))
|
||||
const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0)
|
||||
files.push({
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
sizeBytes: fileStat.size,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: fileStat.size + (dependencyStat?.size ?? 0),
|
||||
dev: fileStat.dev,
|
||||
ino: fileStat.ino,
|
||||
nlink: fileStat.nlink
|
||||
@@ -65,6 +68,23 @@ export async function discoverFiles(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalContentDependencyStat(
|
||||
filePath: string | undefined
|
||||
): Promise<{ mtimeMs: number; size: number } | null> {
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const fileStat = await wslGatedStat(filePath, 'scan')
|
||||
return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }
|
||||
} catch (error) {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function walkSessionFiles(
|
||||
dirPath: string,
|
||||
agent: AiVaultAgent,
|
||||
|
||||
@@ -76,6 +76,7 @@ function resumableStateFactoryFor(
|
||||
case 'devin':
|
||||
case 'grok':
|
||||
case 'hermes':
|
||||
case 'cline':
|
||||
case 'kimi':
|
||||
case 'opencode':
|
||||
case 'rovo':
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
@@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
|
||||
expect(env).toEqual({
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [
|
||||
// dropping one hides every session of a user who relocated that agent's home.
|
||||
const AGENT_ROOT_ENV_ALLOWLIST = [
|
||||
'CODEX_HOME',
|
||||
'CLINE_SESSION_DATA_DIR',
|
||||
'COPILOT_HOME',
|
||||
'DEVIN_HOME',
|
||||
'GROK_HOME',
|
||||
|
||||
@@ -55,6 +55,7 @@ function agentDiscoveries(
|
||||
issues,
|
||||
extensions: [...source.extensions],
|
||||
filePredicate: source.filePredicate,
|
||||
contentDependencyPath: source.contentDependencyPath,
|
||||
directoryPredicate: source.directoryPredicate
|
||||
})
|
||||
return source.mergeRootDiscoveries
|
||||
|
||||
@@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) {
|
||||
primeAgentSessionsDir: join(root, 'prime-agent-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
clineSessionsDir: join(root, 'cline-sessions'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type AiVaultScanOptions = {
|
||||
primeAgentSessionsDir?: string
|
||||
droidSessionsDir?: string
|
||||
droidProjectsDir?: string
|
||||
clineSessionsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
|
||||
@@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const clineSessionId = 'cline-session'
|
||||
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
|
||||
await mkdir(clineSessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.json`),
|
||||
JSON.stringify({
|
||||
session_id: clineSessionId,
|
||||
started_at: '2026-05-01T10:10:30.000Z',
|
||||
model: 'cline-model',
|
||||
cwd: '/tmp/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
updated_at: '2026-05-01T10:10:31.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
|
||||
// with the work dir resolved from the top-level session_index.jsonl.
|
||||
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
|
||||
@@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(commandByAgent.get('prime-agent')).toBe(
|
||||
`cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'`
|
||||
)
|
||||
expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'")
|
||||
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
|
||||
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
|
||||
expect(commandByAgent.get('kimi')).toBe(
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { installDeviceAttributesResponder } from './startup-device-attributes-responder'
|
||||
import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
|
||||
import { readTerminalCursorLineContext } from './terminal-cursor-line-context'
|
||||
|
||||
export type HeadlessEmulatorOptions = {
|
||||
cols: number
|
||||
@@ -302,6 +304,20 @@ export class HeadlessEmulator {
|
||||
return lines
|
||||
}
|
||||
|
||||
getVisibleBufferRange(): { start: number; endExclusive: number; totalLength: number } {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const start = buffer.viewportY
|
||||
return {
|
||||
start,
|
||||
endExclusive: Math.min(buffer.length, start + this.terminal.rows),
|
||||
totalLength: buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
getCursorLineContext(rowsAbove = this.terminal.rows): TerminalCursorContext | null {
|
||||
return readTerminalCursorLineContext(this.terminal, rowsAbove)
|
||||
}
|
||||
|
||||
getBufferTailLines(limit: number): string[] {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit)))
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { readTerminalCursorLineContext } from './terminal-cursor-line-context'
|
||||
|
||||
function writeSync(terminal: Terminal, data: string): void {
|
||||
const core = (terminal as unknown as { _core: { writeSync(data: string): void } })._core
|
||||
core.writeSync(data)
|
||||
}
|
||||
|
||||
describe('readTerminalCursorLineContext', () => {
|
||||
it.each([
|
||||
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
|
||||
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
|
||||
])(
|
||||
'preserves a space at a dimmed soft-wrap boundary with $cols columns',
|
||||
({ cols, cursorRowTail, continuation }) => {
|
||||
const terminal = new Terminal({ cols, rows: 6, allowProposedApi: true })
|
||||
writeSync(
|
||||
terminal,
|
||||
`${'─'.repeat(cols)}\r\n❯ \x1b7\x1b[2mproceed with the release\x1b[22m\x1b8`
|
||||
)
|
||||
|
||||
const context = readTerminalCursorLineContext(terminal, 16)
|
||||
|
||||
expect(context?.rawAfterCursor).toBe(cursorRowTail)
|
||||
expect(context?.rowsBelow).toEqual([continuation, '', '', ''])
|
||||
expect(context?.rowsBelowWrapped).toEqual([true, false, false, false])
|
||||
expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release')
|
||||
terminal.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves a typed space before the cursor moves onto a wrapped row', () => {
|
||||
const terminal = new Terminal({ cols: 19, rows: 6, allowProposedApi: true })
|
||||
writeSync(terminal, '───────────────────\r\n❯ proceed with the release')
|
||||
|
||||
const context = readTerminalCursorLineContext(terminal, 16)
|
||||
|
||||
expect(context?.typedRows).toEqual(['───────────────────', '❯ proceed with the ', 'release'])
|
||||
expect(context?.rowsWrapped).toEqual([false, false, true])
|
||||
expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('recognizes a colored context-only Codex status footer', () => {
|
||||
const terminal = new Terminal({ cols: 80, rows: 8, allowProposedApi: true })
|
||||
writeSync(
|
||||
terminal,
|
||||
'\x1b[1m›\x1b[22m \x1b7review the change\r\n \r\n\x1b[38;2;242;181;144mContext 0% used\x1b[0m\x1b8'
|
||||
)
|
||||
|
||||
const context = readTerminalCursorLineContext(terminal, 16)
|
||||
|
||||
expect(detectTerminalComposerDraft(context)?.text).toBe('review the change')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('finds a composer prompt more than 16 wrapped rows above the cursor', async () => {
|
||||
const draft = `proceed ${'with '.repeat(65)}release`
|
||||
const emulator = new HeadlessEmulator({ cols: 19, rows: 30 })
|
||||
await emulator.write(`${'─'.repeat(19)}\r\n❯ ${draft}`)
|
||||
|
||||
const context = emulator.getCursorLineContext()
|
||||
|
||||
expect(context?.rows.length).toBeGreaterThan(17)
|
||||
expect(detectTerminalComposerDraft(context)?.text).toBe(draft)
|
||||
emulator.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { IBufferLine, Terminal } from '@xterm/headless'
|
||||
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
|
||||
|
||||
function undimmedText(line: IBufferLine, fromX = 0, trimRight = true): string {
|
||||
let text = ''
|
||||
for (let x = fromX; x < line.length; x += 1) {
|
||||
const cell = line.getCell(x)
|
||||
if (!cell || cell.isDim() || cell.getWidth() === 0) {
|
||||
continue
|
||||
}
|
||||
text += cell.getChars() || ' '
|
||||
}
|
||||
return trimRight ? text.trimEnd() : text
|
||||
}
|
||||
|
||||
function firstVisibleCellIsBold(line: IBufferLine): boolean {
|
||||
for (let x = 0; x < line.length; x += 1) {
|
||||
const cell = line.getCell(x)
|
||||
if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) {
|
||||
continue
|
||||
}
|
||||
return Boolean(cell.isBold())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function firstVisibleCellHasCustomForeground(line: IBufferLine): boolean {
|
||||
for (let x = 0; x < line.length; x += 1) {
|
||||
const cell = line.getCell(x)
|
||||
if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) {
|
||||
continue
|
||||
}
|
||||
return !cell.isFgDefault()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function readTerminalCursorLineContext(
|
||||
terminal: Terminal,
|
||||
rowsAbove: number
|
||||
): TerminalCursorContext | null {
|
||||
const buffer = terminal.buffer.active
|
||||
const cursorRow = buffer.baseY + buffer.cursorY
|
||||
const cursorLine = buffer.getLine(cursorRow)
|
||||
if (!cursorLine) {
|
||||
return null
|
||||
}
|
||||
const rows: string[] = []
|
||||
const typedRows: string[] = []
|
||||
const promptGlyphBoldRows: boolean[] = []
|
||||
const rowsWrapped: boolean[] = []
|
||||
const start = Math.max(buffer.viewportY, cursorRow - Math.max(0, Math.floor(rowsAbove)))
|
||||
for (let row = start; row <= cursorRow; row += 1) {
|
||||
const line = buffer.getLine(row)
|
||||
const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false
|
||||
rows.push(line?.translateToString(!nextLineIsWrapped) ?? '')
|
||||
typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '')
|
||||
promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false)
|
||||
rowsWrapped.push(line?.isWrapped ?? false)
|
||||
}
|
||||
const rowsBelow: string[] = []
|
||||
const typedRowsBelow: string[] = []
|
||||
const rowsBelowWrapped: boolean[] = []
|
||||
const rowsBelowCustomForeground: boolean[] = []
|
||||
const end = Math.min(
|
||||
buffer.viewportY + terminal.rows - 1,
|
||||
cursorRow + Math.max(0, Math.floor(rowsAbove))
|
||||
)
|
||||
for (let row = cursorRow + 1; row <= end; row += 1) {
|
||||
const line = buffer.getLine(row)
|
||||
const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false
|
||||
rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '')
|
||||
typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '')
|
||||
rowsBelowWrapped.push(line?.isWrapped ?? false)
|
||||
rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false)
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
typedRows,
|
||||
promptGlyphBoldRows,
|
||||
rowsWrapped,
|
||||
rowsBelow,
|
||||
typedRowsBelow,
|
||||
rowsBelowWrapped,
|
||||
rowsBelowCustomForeground,
|
||||
beforeCursor: cursorLine.translateToString(true, 0, buffer.cursorX),
|
||||
afterCursor: undimmedText(
|
||||
cursorLine,
|
||||
buffer.cursorX,
|
||||
!(buffer.getLine(cursorRow + 1)?.isWrapped ?? false)
|
||||
),
|
||||
rawAfterCursor: cursorLine.translateToString(
|
||||
!(buffer.getLine(cursorRow + 1)?.isWrapped ?? false),
|
||||
buffer.cursorX
|
||||
),
|
||||
cursorHidden: !terminal.modes.showCursor,
|
||||
cursorViewportRow: cursorRow - buffer.viewportY
|
||||
}
|
||||
}
|
||||
@@ -88,31 +88,51 @@ export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebu
|
||||
rendererDispatcherReadyForcedCount: 0
|
||||
}
|
||||
|
||||
export let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({
|
||||
...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT
|
||||
})
|
||||
export let resetPtyRendererDeliveryDebugSnapshot = (): void => {}
|
||||
// Why null-init + wrapper fns (not `export let fn = noop`): rolldown const-folds the noop into
|
||||
// call sites and drops the setter's reassignment, so bridged closures never run in the built app (STA-5661).
|
||||
let readPtyRendererDeliveryDebugSnapshotImpl: (() => PtyRendererDeliveryDebugSnapshot) | null = null
|
||||
let resetPtyRendererDeliveryDebugSnapshotImpl: (() => void) | null = null
|
||||
// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash.
|
||||
export let resetRendererDeliveryAccountingForLifecycleReset = (): void => {}
|
||||
let resetRendererDeliveryAccountingForLifecycleResetImpl: (() => void) | null = null
|
||||
// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own.
|
||||
export let clearRendererDispatcherReadyWatchdog = (): void => {}
|
||||
let clearRendererDispatcherReadyWatchdogImpl: (() => void) | null = null
|
||||
|
||||
export function readPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
|
||||
return (
|
||||
readPtyRendererDeliveryDebugSnapshotImpl?.() ?? {
|
||||
...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function resetPtyRendererDeliveryDebugSnapshot(): void {
|
||||
resetPtyRendererDeliveryDebugSnapshotImpl?.()
|
||||
}
|
||||
|
||||
export function resetRendererDeliveryAccountingForLifecycleReset(): void {
|
||||
resetRendererDeliveryAccountingForLifecycleResetImpl?.()
|
||||
}
|
||||
|
||||
export function clearRendererDispatcherReadyWatchdog(): void {
|
||||
clearRendererDispatcherReadyWatchdogImpl?.()
|
||||
}
|
||||
|
||||
export function setReadPtyRendererDeliveryDebugSnapshot(
|
||||
fn: () => PtyRendererDeliveryDebugSnapshot
|
||||
): void {
|
||||
readPtyRendererDeliveryDebugSnapshot = fn
|
||||
readPtyRendererDeliveryDebugSnapshotImpl = fn
|
||||
}
|
||||
|
||||
export function setResetPtyRendererDeliveryDebugSnapshot(fn: () => void): void {
|
||||
resetPtyRendererDeliveryDebugSnapshot = fn
|
||||
resetPtyRendererDeliveryDebugSnapshotImpl = fn
|
||||
}
|
||||
|
||||
export function setResetRendererDeliveryAccountingForLifecycleReset(fn: () => void): void {
|
||||
resetRendererDeliveryAccountingForLifecycleReset = fn
|
||||
resetRendererDeliveryAccountingForLifecycleResetImpl = fn
|
||||
}
|
||||
|
||||
export function setClearRendererDispatcherReadyWatchdog(fn: () => void): void {
|
||||
clearRendererDispatcherReadyWatchdog = fn
|
||||
clearRendererDispatcherReadyWatchdogImpl = fn
|
||||
}
|
||||
|
||||
export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
|
||||
|
||||
@@ -9,8 +9,17 @@ export const interactiveOutputCharsByPty = new Map<string, number>()
|
||||
export const activeRendererPtys = new Set<string>()
|
||||
export const visibleRendererPtys = new Set<string>()
|
||||
export const rendererVisibilityKnownPtys = new Set<string>()
|
||||
export let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {}
|
||||
export let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {}
|
||||
// Why null-init + wrapper fns: see debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661).
|
||||
let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null
|
||||
let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null
|
||||
|
||||
export function invalidatePendingPtyDrainPriority(id?: string, schedule?: boolean): void {
|
||||
invalidatePendingPtyDrainPriorityImpl?.(id, schedule)
|
||||
}
|
||||
|
||||
export function invalidatePendingPtyDrainPolicy(id?: string, schedule?: boolean): void {
|
||||
invalidatePendingPtyDrainPolicyImpl?.(id, schedule)
|
||||
}
|
||||
export const pendingHiddenRendererResizeOutputPtys = new Set<string>()
|
||||
export const deliveredHiddenRendererResizeOutputPtys = new Set<string>()
|
||||
export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000
|
||||
@@ -21,11 +30,11 @@ export const providerSnapshotRequiredPtys = new Set<string>()
|
||||
export function setInvalidatePendingPtyDrainPriority(
|
||||
fn: (id?: string, schedule?: boolean) => void
|
||||
): void {
|
||||
invalidatePendingPtyDrainPriority = fn
|
||||
invalidatePendingPtyDrainPriorityImpl = fn
|
||||
}
|
||||
|
||||
export function setInvalidatePendingPtyDrainPolicy(
|
||||
fn: (id?: string, schedule?: boolean) => void
|
||||
): void {
|
||||
invalidatePendingPtyDrainPolicy = fn
|
||||
invalidatePendingPtyDrainPolicyImpl = fn
|
||||
}
|
||||
|
||||
@@ -17,7 +17,12 @@ export let rendererGateResetLoadHandler: (() => void) | null = null
|
||||
export let rendererGateResetGoneHandler: (() => void) | null = null
|
||||
export let rendererGateResetWebContents: WebContents | null = null
|
||||
// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState.
|
||||
export let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {}
|
||||
// Why null-init + wrapper fn: see delivery/debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661).
|
||||
let clearBackgroundedDeliverySyncForPtyImpl: ((id: string) => void) | null = null
|
||||
|
||||
export function clearBackgroundedDeliverySyncForPty(id: string): void {
|
||||
clearBackgroundedDeliverySyncForPtyImpl?.(id)
|
||||
}
|
||||
|
||||
export type RendererNavigationDetails = {
|
||||
isMainFrame: boolean
|
||||
@@ -42,7 +47,7 @@ export function setRebindProviderListeners(fn: (() => void) | null): void {
|
||||
}
|
||||
|
||||
export function setClearBackgroundedDeliverySyncForPty(fn: (id: string) => void): void {
|
||||
clearBackgroundedDeliverySyncForPty = fn
|
||||
clearBackgroundedDeliverySyncForPtyImpl = fn
|
||||
}
|
||||
|
||||
export function setSshOutputIntakeCleanup(fn: (() => void) | null): void {
|
||||
|
||||
@@ -12,10 +12,8 @@ import type {
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult
|
||||
} from '../../../shared/project-types'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectIdForProviderIdentity
|
||||
} from '../../../shared/project-host-setup-projection'
|
||||
import { getProjectIdForProviderIdentity } from '../../../shared/project-host-setup-projection'
|
||||
import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
|
||||
import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache'
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
...makeStore(),
|
||||
getWorktreeMeta: (worktreeId: string) =>
|
||||
worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId]
|
||||
} as Store
|
||||
} as unknown as Store
|
||||
|
||||
const result = await scanWorkspaceCleanup(store, {
|
||||
includeAllWorkspaces: true
|
||||
@@ -561,6 +561,36 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a canonical-only SSH cleanup row blocked while local metadata collides', async () => {
|
||||
const worktreeId = 'repo-1::/shared/workspace'
|
||||
const localRepo = { ...REPO, path: '/local/repo' }
|
||||
const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' }
|
||||
const localMeta = makeWorktreeMeta({
|
||||
displayName: 'Local workspace',
|
||||
hostId: 'local'
|
||||
})
|
||||
const remoteMeta = makeWorktreeMeta({
|
||||
displayName: 'Canonical remote workspace',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
const store = {
|
||||
...makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }),
|
||||
getAllWorktreeMetaForHost: (hostId: string) =>
|
||||
hostId === 'ssh:ssh-1' ? { [worktreeId]: remoteMeta } : { [worktreeId]: localMeta }
|
||||
} as unknown as Store
|
||||
|
||||
const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true })
|
||||
|
||||
expect(result.candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
worktreeId,
|
||||
displayName: 'Canonical remote workspace',
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
blockers: ['ssh-disconnected']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not synthesize a disconnected SSH row from same-id local metadata', async () => {
|
||||
const sharedPath = '/shared/workspace'
|
||||
const worktreeId = `${REPO.id}::${sharedPath}`
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
isWorkspaceInactiveForCleanup
|
||||
} from './workspace-cleanup-candidate'
|
||||
import { getRepoOwnedWorktreeMeta, isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership'
|
||||
import {
|
||||
readAllWorktreeMetaForHost,
|
||||
readWorktreeMetaForHost
|
||||
} from '../persistence/host-qualified-worktree-meta'
|
||||
|
||||
export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
store: Store,
|
||||
@@ -25,6 +29,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
includeAllWorkspaces = false
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const repoWorktreePrefix = `${repo.id}::`
|
||||
const executionHostId = getRepoExecutionHostId(repo)
|
||||
if (targetWorktreeIds) {
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
// Why: targeted refreshes name their workspaces already; walking all
|
||||
@@ -33,7 +38,11 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
if (!worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
continue
|
||||
}
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const meta =
|
||||
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
|
||||
(typeof store.getWorktreeMetaForHost === 'function'
|
||||
? undefined
|
||||
: store.getWorktreeMeta(worktreeId))
|
||||
if (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) {
|
||||
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
|
||||
}
|
||||
@@ -42,7 +51,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
}
|
||||
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const allMeta = readAllWorktreeMetaForHost(store, executionHostId)
|
||||
for (const worktreeId in allMeta) {
|
||||
if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Store } from '../persistence'
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
@@ -187,8 +189,11 @@ async function scanRepoWorkspaces(
|
||||
? listWorkspaceCleanupFolderWorkspaces(store, repo, repoOwnerCount)
|
||||
: gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
// Host-qualified first: the same repoId::path is a different checkout on each host.
|
||||
const hostMeta = readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo))
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const ownedMeta = isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined
|
||||
const ownedMeta =
|
||||
hostMeta ?? (isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount) ? meta : undefined)
|
||||
return mergeWorktree(repo.id, gitWorktree, ownedMeta, repo.displayName)
|
||||
})
|
||||
// Why: with includeAllWorkspaces the browser shows every workspace and lets
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalWorktreeIdentity } from '../../shared/worktree/identity'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import { mergeWorktree } from './worktree-metadata-merge'
|
||||
|
||||
const git: GitWorktreeInfo = {
|
||||
path: '/workspace/feature',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
|
||||
describe('mergeWorktree identity projection', () => {
|
||||
it('publishes canonical identity when host and instance metadata are known', () => {
|
||||
const worktree = mergeWorktree('repo-1', git, {
|
||||
instanceId: '11111111-1111-4111-8111-111111111111',
|
||||
hostId: 'ssh:build-box',
|
||||
displayName: 'Feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
})
|
||||
|
||||
expect(worktree.identity).toEqual({
|
||||
key: canonicalWorktreeIdentity({
|
||||
worktreeId: worktree.id,
|
||||
executionHostId: 'ssh:build-box',
|
||||
instanceId: '11111111-1111-4111-8111-111111111111'
|
||||
}),
|
||||
executionHostId: 'ssh:build-box',
|
||||
instanceId: '11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits canonical identity for legacy metadata without a proven host', () => {
|
||||
const worktree = mergeWorktree('repo-1', git, undefined)
|
||||
|
||||
expect(worktree.identity).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata'
|
||||
import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance'
|
||||
import { createWorktreeIdentity } from '../../shared/worktree/identity'
|
||||
|
||||
/**
|
||||
* Merge raw git worktree info with persisted user metadata into a full Worktree.
|
||||
@@ -16,8 +17,18 @@ export function mergeWorktree(
|
||||
): Worktree {
|
||||
const branchShort = git.branch.replace(/^refs\/heads\//, '')
|
||||
const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance)
|
||||
const worktreeId = `${repoId}::${git.path}`
|
||||
return {
|
||||
id: `${repoId}::${git.path}`,
|
||||
id: worktreeId,
|
||||
...(meta?.instanceId && meta.hostId
|
||||
? {
|
||||
identity: createWorktreeIdentity({
|
||||
worktreeId,
|
||||
executionHostId: meta.hostId,
|
||||
instanceId: meta.instanceId
|
||||
})
|
||||
}
|
||||
: {}),
|
||||
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId,
|
||||
...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
||||
|
||||
@@ -48,7 +48,7 @@ import type {
|
||||
RemoteFetchResult,
|
||||
RemoteTrackingBase
|
||||
} from '../runtime/orca-runtime'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup'
|
||||
import { getEffectiveHooks, loadHooks, parseOrcaYaml } from '../hooks'
|
||||
import { buildPosixRunnerScript, buildWindowsRunnerScript } from '../setup-runner-script-text'
|
||||
import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script'
|
||||
|
||||
@@ -205,6 +205,36 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
expect(result).toMatchObject({ comment: 'keep me', isPinned: true })
|
||||
})
|
||||
it('routes metadata updates through the explicitly selected host', () => {
|
||||
store.setWorktreeMetaForHost.mockImplementation((_worktreeId, _executionHostId, meta) => meta)
|
||||
|
||||
const result = handlers['worktrees:updateMeta'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
executionHostId: 'ssh:build-box',
|
||||
updates: { comment: 'remote note' }
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMetaForHost).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/feature-wt',
|
||||
'ssh:build-box',
|
||||
{ comment: 'remote note' }
|
||||
)
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ comment: 'remote note' })
|
||||
})
|
||||
|
||||
it('rejects malformed execution-host identities at the IPC boundary', () => {
|
||||
expect(() =>
|
||||
handlers['worktrees:updateMeta'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
executionHostId: 'container:untrusted',
|
||||
updates: { comment: 'must not write' }
|
||||
})
|
||||
).toThrow('Invalid execution host identity.')
|
||||
|
||||
expect(store.setWorktreeMetaForHost).not.toHaveBeenCalled()
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pushes a remote-client invalidation for renames but not read-state updates', () => {
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { makeWorktreeMeta } from './worktrees-test-fixtures'
|
||||
import { buildDetectedGitWorktrees } from './worktrees/listing/ssh-worktree-fallback'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
@@ -224,6 +226,41 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists a canonical-only SSH row without leaking the same-id local row', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const worktreeId = 'repo-ssh::/shared/feature-wt'
|
||||
const canonicalRemote = makeWorktreeMeta({
|
||||
displayName: 'Canonical remote',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
[worktreeId]: makeWorktreeMeta({ displayName: 'Legacy local', hostId: 'local' })
|
||||
})
|
||||
;(
|
||||
store as typeof store & {
|
||||
getAllWorktreeMetaForHost: ReturnType<typeof vi.fn>
|
||||
}
|
||||
).getAllWorktreeMetaForHost = vi.fn(() => ({ [worktreeId]: canonicalRemote }))
|
||||
|
||||
const listed = await handlers['worktrees:list'](null, { repoId: repo.id })
|
||||
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: worktreeId,
|
||||
displayName: 'Canonical remote',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to reconstructed SSH rows when provider listing throws', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
@@ -424,4 +461,31 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' })
|
||||
])
|
||||
})
|
||||
|
||||
it('loads repo ownership once while building multiple detected rows', () => {
|
||||
const owner = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
store.getRepos.mockReturnValue([owner])
|
||||
|
||||
const detected = buildDetectedGitWorktrees(
|
||||
store as unknown as Store,
|
||||
owner,
|
||||
['/workspace/one', '/workspace/two', '/workspace/three'].map((path) => ({
|
||||
path,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
})),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(detected).toHaveLength(3)
|
||||
expect(store.getRepos).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,6 +73,8 @@ export {
|
||||
|
||||
/** Registers worktree IPC handlers against freshly reset shared mocks and returns the runtime stub. */
|
||||
export function setupWorktreeHandlers(): WorktreeRuntimeStub {
|
||||
delete (store as typeof store & { getAllWorktreeMetaForHost?: (...args: unknown[]) => unknown })
|
||||
.getAllWorktreeMetaForHost
|
||||
setPlatform(ORIGINAL_PLATFORM)
|
||||
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
|
||||
__resetSshWorktreeCreateFetchCacheForTests()
|
||||
@@ -125,8 +127,10 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
|
||||
store.getSparsePresets,
|
||||
store.getSettings,
|
||||
store.getWorktreeMeta,
|
||||
store.getWorktreeMetaForHost,
|
||||
store.getAllWorktreeMeta,
|
||||
store.setWorktreeMeta,
|
||||
store.setWorktreeMetaForHost,
|
||||
store.getProjectHostSetups,
|
||||
store.removeWorktreeMeta,
|
||||
store.removeWorkspaceSessionStateForWorktree,
|
||||
@@ -190,10 +194,18 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
|
||||
workspaceDir: '/workspace'
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
// Host-qualified accessors delegate by default so payload assertions stay on one spy;
|
||||
// host routing itself is covered by worktree-identity-persistence.test.ts.
|
||||
store.getWorktreeMetaForHost.mockImplementation((...args: unknown[]) =>
|
||||
store.getWorktreeMeta(args[0] as string)
|
||||
)
|
||||
store.getAllWorktreeMeta.mockReturnValue({})
|
||||
store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] })
|
||||
resetRetirementCollisionKeyCacheForTests()
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
store.setWorktreeMetaForHost.mockImplementation((...args: unknown[]) =>
|
||||
store.setWorktreeMeta(args[0] as string, args[2] as object)
|
||||
)
|
||||
store.getProjectHostSetups.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
|
||||
@@ -22,8 +22,10 @@ export type TestStore = {
|
||||
getSparsePresets: StoreMock
|
||||
getSettings: StoreMock
|
||||
getWorktreeMeta: KeyedStoreMock
|
||||
getWorktreeMetaForHost: StoreMock
|
||||
getAllWorktreeMeta: StoreMock
|
||||
setWorktreeMeta: KeyedStoreWriteMock
|
||||
setWorktreeMetaForHost: StoreMock
|
||||
getProjectHostSetups: StoreMock
|
||||
removeWorktreeMeta: KeyedStoreMock
|
||||
removeWorkspaceSessionStateForWorktree: KeyedStoreMock
|
||||
@@ -54,8 +56,10 @@ export const store: TestStore = {
|
||||
getSparsePresets: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getWorktreeMeta: vi.fn(),
|
||||
getWorktreeMetaForHost: vi.fn(),
|
||||
getAllWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
setWorktreeMetaForHost: vi.fn(),
|
||||
getProjectHostSetups: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
removeWorkspaceSessionStateForWorktree: vi.fn(),
|
||||
|
||||
@@ -165,6 +165,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
getProjectHostSetups: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
addRetiredWorktreeName: vi.fn(),
|
||||
@@ -209,6 +210,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
store.getProjectHostSetups.mockReset()
|
||||
store.getSettings.mockReset()
|
||||
store.getWorktreeMeta.mockReset()
|
||||
store.getAllWorktreeMeta.mockReset()
|
||||
store.setWorktreeMeta.mockReset()
|
||||
store.removeWorktreeMeta.mockReset()
|
||||
store.addRetiredWorktreeName.mockReset()
|
||||
@@ -251,6 +253,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
})
|
||||
resolveSetupRunnerShellMock.mockReturnValue(undefined)
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.getAllWorktreeMeta.mockReturnValue({})
|
||||
store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] })
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||||
@@ -501,6 +504,14 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-1::C:/workspaces/improve-dashboard': {
|
||||
lastActivityAt: 123,
|
||||
displayName: 'Improve Dashboard',
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456
|
||||
}
|
||||
})
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
|
||||
import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
rememberLocalWorktreeRoots
|
||||
} from './detected-worktree-scan-cache'
|
||||
import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export async function listDetectedWorktreesForCapturedRepo(
|
||||
store: Store,
|
||||
@@ -36,8 +38,11 @@ export async function listDetectedWorktreesForCapturedRepo(
|
||||
providerAbort?.signal.aborted
|
||||
? ({ providerAbortStatus: providerAbort.status() } as const)
|
||||
: undefined
|
||||
const allMeta = isFolderRepo(repo)
|
||||
? undefined
|
||||
: readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
const sshWorktreeMetaIndex = repo.connectionId
|
||||
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
|
||||
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
|
||||
: new Map()
|
||||
|
||||
try {
|
||||
@@ -118,7 +123,7 @@ export async function listDetectedWorktreesForCapturedRepo(
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees)
|
||||
worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta)
|
||||
}
|
||||
} catch (err) {
|
||||
const aborted = abortedResult()
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
listDisconnectedSshWorktrees
|
||||
} from './ssh-worktree-fallback'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
|
||||
export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
|
||||
const { store } = context
|
||||
@@ -76,7 +77,10 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
|
||||
)
|
||||
)
|
||||
}
|
||||
const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id)
|
||||
const metaIndex = createSshWorktreeMetaIndexForRepo(
|
||||
readAllWorktreeMetaForHost(store, requestedExecutionHostId),
|
||||
repo.id
|
||||
)
|
||||
return complete(
|
||||
buildDisconnectedDetectedWorktrees(
|
||||
store,
|
||||
@@ -111,7 +115,7 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
|
||||
if (isFolderRepo(repo)) {
|
||||
return nothingForgotten
|
||||
}
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const allMeta = readAllWorktreeMetaForHost(store, requestedExecutionHostId)
|
||||
const forgottenWorktreeIds: string[] = []
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getSshGitProvider } from '../../../providers/ssh-git-dispatch'
|
||||
import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning'
|
||||
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry'
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
warnOnce
|
||||
} from './worktree-listing-diagnostics'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
|
||||
const WORKTREE_LIST_ALL_CONCURRENCY = 8
|
||||
|
||||
@@ -49,9 +52,36 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
|
||||
ipcMain.handle('worktrees:listAll', async () => {
|
||||
const repos = store.getRepos()
|
||||
const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId)
|
||||
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
|
||||
: new Map()
|
||||
const legacyMetadata =
|
||||
typeof store.getAllWorktreeMetaForHost === 'function' ? undefined : store.getAllWorktreeMeta()
|
||||
const metadataByHost = new Map<ExecutionHostId, Record<string, WorktreeMeta>>()
|
||||
const metadataForRepo = (repo: (typeof repos)[number]): Record<string, WorktreeMeta> => {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const cached = metadataByHost.get(hostId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const metadata =
|
||||
typeof store.getAllWorktreeMetaForHost === 'function'
|
||||
? store.getAllWorktreeMetaForHost(hostId)
|
||||
: readAllWorktreeMetaForHost({ getAllWorktreeMeta: () => legacyMetadata ?? {} }, hostId)
|
||||
metadataByHost.set(hostId, metadata)
|
||||
return metadata
|
||||
}
|
||||
const sshMetaIndexByHost = new Map<
|
||||
ExecutionHostId,
|
||||
ReturnType<typeof createSshWorktreeMetaIndex>
|
||||
>()
|
||||
const sshMetaIndexForRepo = (repo: (typeof repos)[number]) => {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const cached = sshMetaIndexByHost.get(hostId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const index = createSshWorktreeMetaIndex(Object.entries(metadataForRepo(repo)))
|
||||
sshMetaIndexByHost.set(hostId, index)
|
||||
return index
|
||||
}
|
||||
|
||||
// Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses.
|
||||
const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => {
|
||||
@@ -68,7 +98,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
`${repo.connectionId}:${repo.id}`,
|
||||
`[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}`
|
||||
)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo))
|
||||
}
|
||||
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
|
||||
try {
|
||||
@@ -80,7 +110,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
|
||||
err
|
||||
)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex)
|
||||
return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo))
|
||||
}
|
||||
} else {
|
||||
const scan = await listDetectedGitWorktrees(store, repo)
|
||||
@@ -92,9 +122,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
}
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
|
||||
const metadata = metadataForRepo(repo)
|
||||
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree))
|
||||
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
|
||||
} catch (err) {
|
||||
warnOnce(
|
||||
loggedWorktreeListFailures,
|
||||
@@ -123,8 +154,11 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
if (!repo) {
|
||||
return []
|
||||
}
|
||||
const allMeta = repo.connectionId
|
||||
? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
: undefined
|
||||
const sshWorktreeMetaIndex = repo.connectionId
|
||||
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
|
||||
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
|
||||
: new Map()
|
||||
|
||||
try {
|
||||
@@ -164,9 +198,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
}
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
|
||||
const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree))
|
||||
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
|
||||
} catch (err) {
|
||||
warnOnce(
|
||||
loggedWorktreeListFailures,
|
||||
|
||||
@@ -8,6 +8,11 @@ import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types'
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
readWorktreeMetaForHost,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
|
||||
import {
|
||||
buildKnownOrcaWorkspaceLayouts,
|
||||
isLegacyRepoForExternalWorktreeVisibility,
|
||||
@@ -110,7 +115,7 @@ export function listDisconnectedSshWorktrees(
|
||||
? { ...candidate.meta, ...ownershipUpdates }
|
||||
: candidate.meta
|
||||
if (Object.keys(ownershipUpdates).length > 0) {
|
||||
store.setWorktreeMeta(candidate.id, ownershipUpdates)
|
||||
writeWorktreeMetaForHost(store, candidate.id, expectedHostId, ownershipUpdates)
|
||||
}
|
||||
// Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename()
|
||||
// applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must
|
||||
@@ -131,7 +136,8 @@ export function listDisconnectedSshWorktrees(
|
||||
export function buildDetectedGitWorktrees(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
gitWorktrees: GitWorktreeInfo[]
|
||||
gitWorktrees: GitWorktreeInfo[],
|
||||
allMetaOverride?: Record<string, WorktreeMeta>
|
||||
): DetectedWorktree[] {
|
||||
const settings = store.getSettings()
|
||||
const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
@@ -145,9 +151,15 @@ export function buildDetectedGitWorktrees(
|
||||
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults),
|
||||
resolveConfiguredWorktreeBasePaths(repo)
|
||||
)
|
||||
const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.()
|
||||
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
const detected = liveWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
let meta = store.getWorktreeMeta(worktreeId)
|
||||
const legacyMeta = store.getWorktreeMeta?.(worktreeId)
|
||||
const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {})
|
||||
let meta =
|
||||
readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ??
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
@@ -162,7 +174,13 @@ export function buildDetectedGitWorktrees(
|
||||
return detected
|
||||
}
|
||||
|
||||
meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId)
|
||||
meta = resolveWorktreeMetaWithDiscoveryBackfill(
|
||||
store,
|
||||
repo,
|
||||
worktreeId,
|
||||
allMeta,
|
||||
repoOwnerCount
|
||||
)
|
||||
return toDetectedWorktree({
|
||||
repo,
|
||||
worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
@@ -179,8 +197,9 @@ export function buildDetectedGitWorktrees(
|
||||
export function stampAndMergeVisibleDetectedWorktree(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
detected: DetectedWorktree
|
||||
detected: DetectedWorktree,
|
||||
allMetaOverride?: Record<string, WorktreeMeta>
|
||||
) {
|
||||
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id, allMetaOverride)
|
||||
return mergeWorktree(repo.id, detected, meta, repo.displayName)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
readWorktreeMetaForHost,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export function getProjectHostSetupMetaUpdates(
|
||||
@@ -29,9 +35,21 @@ export function getProjectHostSetupMetaUpdates(
|
||||
export function resolveWorktreeMetaWithDiscoveryBackfill(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
allMetaOverride?: Record<string, WorktreeMeta>,
|
||||
repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
|
||||
): WorktreeMeta {
|
||||
const existing = store.getWorktreeMeta(worktreeId)
|
||||
const executionHostId = getRepoExecutionHostId(repo)
|
||||
const legacyMeta = store.getWorktreeMeta?.(worktreeId)
|
||||
const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.()
|
||||
const existing =
|
||||
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
|
||||
getRepoOwnedWorktreeMeta(
|
||||
repo,
|
||||
worktreeId,
|
||||
allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}),
|
||||
repoOwnerCount
|
||||
)
|
||||
const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing)
|
||||
if (existing) {
|
||||
const updates = {
|
||||
@@ -40,11 +58,11 @@ export function resolveWorktreeMetaWithDiscoveryBackfill(
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
// Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing.
|
||||
return store.setWorktreeMeta(worktreeId, updates)
|
||||
return writeWorktreeMetaForHost(store, worktreeId, executionHostId, updates)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
return store.setWorktreeMeta(worktreeId, {
|
||||
return writeWorktreeMetaForHost(store, worktreeId, executionHostId, {
|
||||
lastActivityAt: Date.now(),
|
||||
...ownershipUpdates
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
|
||||
import type {
|
||||
@@ -16,10 +17,23 @@ import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
|
||||
export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void {
|
||||
const { mainWindow, store, runtime } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:updateMeta',
|
||||
(_event, args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => {
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
worktreeId: string
|
||||
executionHostId?: string
|
||||
updates: Partial<WorktreeMeta>
|
||||
}
|
||||
) => {
|
||||
const executionHostId =
|
||||
args.executionHostId === undefined
|
||||
? undefined
|
||||
: parseExecutionHostId(args.executionHostId)?.id
|
||||
if (args.executionHostId !== undefined && !executionHostId) {
|
||||
throw new Error('Invalid execution host identity.')
|
||||
}
|
||||
const validatedUpdates = normalizeLinkedWorkItemFields(args.updates)
|
||||
const updates =
|
||||
validatedUpdates.displayName !== undefined
|
||||
@@ -29,7 +43,10 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v
|
||||
firstAgentMessageRenameError: null
|
||||
}
|
||||
: validatedUpdates
|
||||
const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates))
|
||||
const sanitizedUpdates = stripOrcaProvenanceMetaUpdates(updates)
|
||||
const meta = executionHostId
|
||||
? store.setWorktreeMetaForHost(args.worktreeId, executionHostId, sanitizedUpdates)
|
||||
: store.setWorktreeMeta(args.worktreeId, sanitizedUpdates)
|
||||
// Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209).
|
||||
if (args.updates.displayName !== undefined) {
|
||||
// Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free.
|
||||
|
||||
@@ -395,6 +395,12 @@ describe('Store', () => {
|
||||
// Local worktree meta survives; the SSH host's meta is pruned.
|
||||
expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined()
|
||||
expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined()
|
||||
store.flush()
|
||||
const persisted = readDataFile() as PersistedState
|
||||
const identityMetadata = Object.values(persisted.worktreeMetaByIdentity ?? {})
|
||||
expect(identityMetadata).toEqual([
|
||||
expect.objectContaining({ hostId: 'local', displayName: 'local-wt' })
|
||||
])
|
||||
})
|
||||
|
||||
it('removeProjectForHost keeps the surviving host session for a shared repo id + path', async () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
|
||||
/**
|
||||
* The listing paths take partial store shapes (`Pick<Store, ...>`), so the
|
||||
* host-qualified accessors are optional here and fall back to the locator-keyed
|
||||
* ones. A real Store always has them.
|
||||
*/
|
||||
export type HostQualifiedWorktreeMetaStore = {
|
||||
getAllWorktreeMeta: () => Record<string, WorktreeMeta>
|
||||
getWorktreeMetaForHost?: (
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
) => WorktreeMeta | undefined
|
||||
getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record<string, WorktreeMeta>
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => WorktreeMeta
|
||||
setWorktreeMetaForHost?: (
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId,
|
||||
meta: Partial<WorktreeMeta>
|
||||
) => WorktreeMeta
|
||||
}
|
||||
|
||||
/** Canonical host snapshot, with a filtered legacy fallback for partial test/compatibility stores. */
|
||||
export function readAllWorktreeMetaForHost(
|
||||
store: Pick<HostQualifiedWorktreeMetaStore, 'getAllWorktreeMeta' | 'getAllWorktreeMetaForHost'>,
|
||||
executionHostId: ExecutionHostId
|
||||
): Record<string, WorktreeMeta> {
|
||||
const qualified = store.getAllWorktreeMetaForHost?.(executionHostId)
|
||||
if (qualified) {
|
||||
return qualified
|
||||
}
|
||||
const projected: Record<string, WorktreeMeta> = {}
|
||||
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
|
||||
if (!meta.hostId || meta.hostId === executionHostId) {
|
||||
projected[worktreeId] = meta
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
/**
|
||||
* The row this host owns at `worktreeId`, or undefined. Deliberately does NOT fall back to the
|
||||
* locator-keyed read: that would hand one host's metadata to another's row, which is the collision
|
||||
* every caller here is trying to avoid. Callers keep their own same-id ownership guard as fallback.
|
||||
*/
|
||||
export function readWorktreeMetaForHost(
|
||||
store: Pick<HostQualifiedWorktreeMetaStore, 'getWorktreeMetaForHost'>,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): WorktreeMeta | undefined {
|
||||
return store.getWorktreeMetaForHost?.(worktreeId, executionHostId)
|
||||
}
|
||||
|
||||
export function writeWorktreeMetaForHost(
|
||||
store: Pick<HostQualifiedWorktreeMetaStore, 'setWorktreeMeta' | 'setWorktreeMetaForHost'>,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId,
|
||||
meta: Partial<WorktreeMeta>
|
||||
): WorktreeMeta {
|
||||
return (
|
||||
store.setWorktreeMetaForHost?.(worktreeId, executionHostId, meta) ??
|
||||
store.setWorktreeMeta(worktreeId, { ...meta, hostId: executionHostId })
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity
|
||||
} from '../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
type IdentityMove = {
|
||||
sourceKey: string
|
||||
nextKey: string
|
||||
instanceId: string
|
||||
sourceMeta: WorktreeMeta
|
||||
nextMeta: WorktreeMeta
|
||||
}
|
||||
|
||||
type AliasMove = {
|
||||
oldAlias: string
|
||||
nextAlias: string
|
||||
identities: IdentityMove[]
|
||||
}
|
||||
|
||||
/** Move canonical aliases only after every destination occupant is proven compatible. */
|
||||
export function reassignCanonicalWorktreeMetadataHost(
|
||||
state: PersistedState,
|
||||
oldHostId: ExecutionHostId,
|
||||
newHostId: ExecutionHostId
|
||||
): { changed: boolean; preservedWorktreeIds: ReadonlySet<string> } {
|
||||
const aliases = state.worktreeIdentityAliases ?? {}
|
||||
const identityMeta = state.worktreeMetaByIdentity ?? {}
|
||||
const repairedInstanceIds = new Map<string, string>()
|
||||
const plans: AliasMove[] = []
|
||||
|
||||
for (const [oldAlias, rawKeys] of Object.entries(aliases)) {
|
||||
if (getExecutionHostIdFromWorktreeHostIdentity(oldAlias) !== oldHostId) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = getWorktreeIdFromHostIdentity(oldAlias)
|
||||
if (!worktreeId || rawKeys.length === 0) {
|
||||
continue
|
||||
}
|
||||
const identities: IdentityMove[] = []
|
||||
let safe = true
|
||||
for (const sourceKey of new Set(rawKeys)) {
|
||||
const sourceMeta = identityMeta[sourceKey]
|
||||
if (!sourceMeta) {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
let instanceId = sourceMeta.instanceId ?? repairedInstanceIds.get(sourceKey)
|
||||
if (instanceId === undefined) {
|
||||
instanceId = randomUUID()
|
||||
repairedInstanceIds.set(sourceKey, instanceId)
|
||||
}
|
||||
const nextKey = canonicalWorktreeIdentity({
|
||||
worktreeId,
|
||||
executionHostId: newHostId,
|
||||
instanceId
|
||||
})
|
||||
const nextMeta = { ...sourceMeta, instanceId, hostId: newHostId }
|
||||
const duplicate = identities.find((identity) => identity.nextKey === nextKey)
|
||||
if (duplicate && !isDeepStrictEqual(duplicate.nextMeta, nextMeta)) {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
if (!duplicate) {
|
||||
identities.push({ sourceKey, nextKey, instanceId, sourceMeta, nextMeta })
|
||||
}
|
||||
}
|
||||
if (!safe) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextAlias = composeWorktreeHostIdentity(newHostId, worktreeId)
|
||||
const candidateKeys = new Set(identities.map((identity) => identity.nextKey))
|
||||
if ((aliases[nextAlias] ?? []).some((key) => !candidateKeys.has(key))) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
identities.some((identity) => {
|
||||
const existing = identityMeta[identity.nextKey]
|
||||
return existing !== undefined && !isDeepStrictEqual(existing, identity.nextMeta)
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
plans.push({ oldAlias, nextAlias, identities })
|
||||
}
|
||||
|
||||
if (plans.length > 0) {
|
||||
state.worktreeIdentityAliases ??= {}
|
||||
state.worktreeMetaByIdentity ??= {}
|
||||
for (const plan of plans) {
|
||||
for (const identity of plan.identities) {
|
||||
identity.sourceMeta.instanceId ??= identity.instanceId
|
||||
state.worktreeMetaByIdentity[identity.nextKey] ??= identity.nextMeta
|
||||
}
|
||||
state.worktreeIdentityAliases[plan.nextAlias] = [
|
||||
...new Set([
|
||||
...(state.worktreeIdentityAliases[plan.nextAlias] ?? []),
|
||||
...plan.identities.map((identity) => identity.nextKey)
|
||||
])
|
||||
]
|
||||
delete state.worktreeIdentityAliases[plan.oldAlias]
|
||||
}
|
||||
|
||||
const referenced = new Set(Object.values(state.worktreeIdentityAliases).flat())
|
||||
for (const sourceKey of new Set(
|
||||
plans.flatMap((plan) => plan.identities.map((identity) => identity.sourceKey))
|
||||
)) {
|
||||
if (!referenced.has(sourceKey)) {
|
||||
delete state.worktreeMetaByIdentity[sourceKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preservedWorktreeIds = new Set<string>()
|
||||
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
|
||||
if (getExecutionHostIdFromWorktreeHostIdentity(alias) === oldHostId) {
|
||||
preservedWorktreeIds.add(getWorktreeIdFromHostIdentity(alias))
|
||||
}
|
||||
}
|
||||
return { changed: plans.length > 0, preservedWorktreeIds }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
migrateAutomationsForSshReadoption
|
||||
} from '../../automations/automation-ssh-readoption-migration'
|
||||
import { automationIdsPinnedToSshTarget } from '../scheduling-automations/automation-owner-projection'
|
||||
import { reassignCanonicalWorktreeMetadataHost } from './canonical-worktree-host-reassignment'
|
||||
|
||||
export type SshTargetReassignmentOperations = {
|
||||
state: PersistedState
|
||||
@@ -80,10 +81,16 @@ export function reassignSshTargetId(
|
||||
}
|
||||
repoIds.add(repo.id)
|
||||
}
|
||||
// Re-point worktree metas whose hostId pointed at the old SSH host.
|
||||
// Legacy locator rows cannot recover canonical-only metadata after target readoption.
|
||||
const identityResult = reassignCanonicalWorktreeMetadataHost(
|
||||
operations.state,
|
||||
oldHostId,
|
||||
newHostId
|
||||
)
|
||||
// Re-point legacy rows unless a conflicting destination kept their canonical source in place.
|
||||
let metaChanged = false
|
||||
for (const meta of Object.values(operations.state.worktreeMeta)) {
|
||||
if (meta.hostId === oldHostId) {
|
||||
for (const [worktreeId, meta] of Object.entries(operations.state.worktreeMeta)) {
|
||||
if (meta.hostId === oldHostId && !identityResult.preservedWorktreeIds.has(worktreeId)) {
|
||||
meta.hostId = newHostId
|
||||
metaChanged = true
|
||||
}
|
||||
@@ -175,7 +182,13 @@ export function reassignSshTargetId(
|
||||
if (repoIds.size > 0 || setupsChanged) {
|
||||
operations.syncProjectHostSetupCompatibilityState()
|
||||
}
|
||||
if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) {
|
||||
if (
|
||||
repoIds.size > 0 ||
|
||||
metaChanged ||
|
||||
carrierChanged ||
|
||||
setupsChanged ||
|
||||
identityResult.changed
|
||||
) {
|
||||
// The rewrites above patch rows in place; the list projection caches on array
|
||||
// identity, so a same-identity array would keep serving pre-readoption owners.
|
||||
operations.state.repos = [...operations.state.repos]
|
||||
|
||||
@@ -1,45 +1,14 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
|
||||
import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
workspaceSessionOwnerPartitionForHost,
|
||||
workspaceSessionPartitionIdsForHost
|
||||
} from '../restoring-sessions/session-owner-removal'
|
||||
import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration'
|
||||
|
||||
function getDefaultWorktreeMeta(): WorktreeMeta {
|
||||
return {
|
||||
instanceId: randomUUID(),
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedBitbucketPR: null,
|
||||
linkedAzureDevOpsPR: null,
|
||||
linkedGiteaPR: null,
|
||||
linkedWorkItem: null,
|
||||
linkedTaskSourceContext: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: Date.now(),
|
||||
lastActivityAt: 0,
|
||||
workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID
|
||||
}
|
||||
}
|
||||
|
||||
import type { StoreRuntimeState } from './store-runtime-state'
|
||||
import type { WriteSchedulingOperations } from './write-scheduling'
|
||||
import type { SessionHostPartitionOperations } from './session-host-partitions'
|
||||
@@ -50,6 +19,15 @@ import {
|
||||
partitionHasOtherRepoWorktreeTabs,
|
||||
removeWorkspaceSessionOwnerInPartition
|
||||
} from './session-host-partitions'
|
||||
import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration'
|
||||
import {
|
||||
getAllWorktreeMetaForHost as getAllWorktreeMetaForHostOperation,
|
||||
getWorktreeMetaForHost as getWorktreeMetaForHostOperation,
|
||||
migrateWorktreeMetadataLocator,
|
||||
removeWorktreeMetadataForHost,
|
||||
setWorktreeMetaForHost as setWorktreeMetaForHostOperation
|
||||
} from './worktree-identity-metadata'
|
||||
import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization'
|
||||
|
||||
type MetadataLineageOperationsRuntime = Pick<StoreRuntimeState, 'state'>
|
||||
|
||||
@@ -70,34 +48,62 @@ export class MetadataLineageOperations {
|
||||
) {
|
||||
this[metadataLineageOperationsContext] = { runtime, scheduling, sessions }
|
||||
}
|
||||
|
||||
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
|
||||
return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]
|
||||
}
|
||||
|
||||
getWorktreeMetaForHost(
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): WorktreeMeta | undefined {
|
||||
return getWorktreeMetaForHostOperation(
|
||||
this[metadataLineageOperationsContext].runtime,
|
||||
this[metadataLineageOperationsContext].scheduling,
|
||||
worktreeId,
|
||||
executionHostId
|
||||
)
|
||||
}
|
||||
|
||||
setWorktreeMetaForHost(
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId,
|
||||
meta: Partial<WorktreeMeta>
|
||||
): WorktreeMeta {
|
||||
return setWorktreeMetaForHostOperation(
|
||||
this[metadataLineageOperationsContext].runtime,
|
||||
this[metadataLineageOperationsContext].scheduling,
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
meta
|
||||
)
|
||||
}
|
||||
|
||||
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
|
||||
return this[metadataLineageOperationsContext].runtime.state.worktreeMeta
|
||||
}
|
||||
|
||||
getAllWorktreeMetaForHost(executionHostId: ExecutionHostId): Record<string, WorktreeMeta> {
|
||||
return getAllWorktreeMetaForHostOperation(
|
||||
this[metadataLineageOperationsContext].runtime,
|
||||
executionHostId
|
||||
)
|
||||
}
|
||||
|
||||
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
|
||||
const existing =
|
||||
this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] ||
|
||||
getDefaultWorktreeMeta()
|
||||
const updated = { ...existing, ...meta }
|
||||
updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem)
|
||||
const linkedTaskSourceContext = normalizeStoredTaskSourceContext(
|
||||
updated.linkedTaskSourceContext
|
||||
)
|
||||
updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
|
||||
updated.linkedWorkItem,
|
||||
linkedTaskSourceContext
|
||||
)
|
||||
? linkedTaskSourceContext
|
||||
: null
|
||||
if (!updated.instanceId) {
|
||||
updated.instanceId = randomUUID()
|
||||
const state = this[metadataLineageOperationsContext].runtime.state
|
||||
const stored = state.worktreeMeta[worktreeId]
|
||||
const executionHostId = meta.hostId ?? stored?.hostId
|
||||
if (executionHostId) {
|
||||
return setWorktreeMetaForHostOperation(
|
||||
this[metadataLineageOperationsContext].runtime,
|
||||
this[metadataLineageOperationsContext].scheduling,
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
meta
|
||||
)
|
||||
}
|
||||
this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated
|
||||
const updated = mergeWorktreeMetaForWrite(stored, meta)
|
||||
state.worktreeMeta[worktreeId] = updated
|
||||
scheduleSave(this[metadataLineageOperationsContext].scheduling)
|
||||
return updated
|
||||
}
|
||||
@@ -120,6 +126,13 @@ export class MetadataLineageOperations {
|
||||
ownerPartition
|
||||
))
|
||||
)
|
||||
// Mirror the legacy delete's scope below: a full removal takes every host's identity rows,
|
||||
// otherwise the surviving owner keeps its own.
|
||||
removeWorktreeMetadataForHost(
|
||||
this[metadataLineageOperationsContext].runtime.state,
|
||||
worktreeId,
|
||||
hostId === undefined ? undefined : (hostId ?? undefined)
|
||||
)
|
||||
// Skip partitions main never wrote: materializing one fences every sibling worktree of the repo.
|
||||
const partitions = new Set<ExecutionHostId>(
|
||||
workspaceSessionPartitionIdsForHost(owner).filter(
|
||||
@@ -190,14 +203,27 @@ export class MetadataLineageOperations {
|
||||
scheduleSave(this[metadataLineageOperationsContext].scheduling)
|
||||
}
|
||||
|
||||
migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void {
|
||||
if (
|
||||
migrateWorktreeIdentityOperation(
|
||||
this[metadataLineageOperationsContext].runtime.state,
|
||||
oldWorktreeId,
|
||||
newWorktreeId
|
||||
)
|
||||
) {
|
||||
migrateWorktreeIdentity(
|
||||
oldWorktreeId: string,
|
||||
newWorktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): void {
|
||||
const state = this[metadataLineageOperationsContext].runtime.state
|
||||
const persistedOwner = state.worktreeMeta[oldWorktreeId]?.hostId
|
||||
const mover = executionHostId ?? persistedOwner ?? LOCAL_EXECUTION_HOST_ID
|
||||
const legacyChanged =
|
||||
executionHostId !== undefined &&
|
||||
persistedOwner !== undefined &&
|
||||
persistedOwner !== executionHostId
|
||||
? false
|
||||
: migrateWorktreeIdentityOperation(state, oldWorktreeId, newWorktreeId)
|
||||
const canonicalChanged = migrateWorktreeMetadataLocator(
|
||||
state,
|
||||
oldWorktreeId,
|
||||
newWorktreeId,
|
||||
mover
|
||||
)
|
||||
if (legacyChanged || canonicalChanged) {
|
||||
scheduleSave(this[metadataLineageOperationsContext].scheduling)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity
|
||||
} from '../../../shared/worktree/host-qualified-identity'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { StoreRuntimeState } from './store-runtime-state'
|
||||
import type { WriteSchedulingOperations } from './write-scheduling'
|
||||
import { scheduleSave } from './write-scheduling'
|
||||
import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization'
|
||||
|
||||
type MetadataRuntime = Pick<StoreRuntimeState, 'state'>
|
||||
|
||||
/** Select one readable row without discarding competing alias candidates. */
|
||||
function resolveAliasIdentityKey(state: PersistedState, alias: string): string | undefined {
|
||||
const identityKeys = state.worktreeIdentityAliases?.[alias] ?? []
|
||||
if (identityKeys.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const resolvable = identityKeys.filter((key) => state.worktreeMetaByIdentity?.[key])
|
||||
const candidates = resolvable.length > 0 ? resolvable : identityKeys
|
||||
// Newest activity wins, then the greater key, so every host agrees on the survivor.
|
||||
const winner = candidates.reduce((best, key) => {
|
||||
const bestTouch = state.worktreeMetaByIdentity?.[best]?.lastActivityAt ?? 0
|
||||
const keyTouch = state.worktreeMetaByIdentity?.[key]?.lastActivityAt ?? 0
|
||||
if (keyTouch !== bestTouch) {
|
||||
return keyTouch > bestTouch ? key : best
|
||||
}
|
||||
return key > best ? key : best
|
||||
})
|
||||
return winner
|
||||
}
|
||||
|
||||
/** Drop identity metadata no alias points at any more. */
|
||||
export function pruneUnreferencedWorktreeIdentityMeta(state: PersistedState): boolean {
|
||||
const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat())
|
||||
let changed = false
|
||||
for (const identityKey of Object.keys(state.worktreeMetaByIdentity ?? {})) {
|
||||
if (!referenced.has(identityKey)) {
|
||||
delete state.worktreeMetaByIdentity?.[identityKey]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function migrateLegacyWorktreeMetadata(
|
||||
state: PersistedState,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): boolean {
|
||||
const meta = state.worktreeMeta[worktreeId]
|
||||
if (!meta || (meta.hostId !== undefined && meta.hostId !== executionHostId)) {
|
||||
return false
|
||||
}
|
||||
state.worktreeMetaByIdentity ??= {}
|
||||
state.worktreeIdentityAliases ??= {}
|
||||
let changed = false
|
||||
const instanceId = meta.instanceId ?? randomUUID()
|
||||
if (!meta.instanceId) {
|
||||
meta.instanceId = instanceId
|
||||
changed = true
|
||||
}
|
||||
if (!meta.hostId) {
|
||||
meta.hostId = executionHostId
|
||||
changed = true
|
||||
}
|
||||
const identityKey = canonicalWorktreeIdentity({
|
||||
worktreeId,
|
||||
executionHostId,
|
||||
instanceId
|
||||
})
|
||||
if (!state.worktreeMetaByIdentity[identityKey]) {
|
||||
state.worktreeMetaByIdentity[identityKey] = {
|
||||
...meta,
|
||||
instanceId,
|
||||
hostId: executionHostId
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
|
||||
const aliases = state.worktreeIdentityAliases[alias] ?? []
|
||||
if (aliases.length === 0) {
|
||||
state.worktreeIdentityAliases[alias] = [identityKey]
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Drop the identity rows a locator owns, for one host or for every host. */
|
||||
export function removeWorktreeMetadataForHost(
|
||||
state: PersistedState,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId | undefined
|
||||
): boolean {
|
||||
let changed = false
|
||||
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
|
||||
if (getWorktreeIdFromHostIdentity(alias) !== worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
executionHostId !== undefined &&
|
||||
getExecutionHostIdFromWorktreeHostIdentity(alias) !== executionHostId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
delete state.worktreeIdentityAliases?.[alias]
|
||||
changed = true
|
||||
}
|
||||
return pruneUnreferencedWorktreeIdentityMeta(state) || changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-point one host's alias at a renamed locator. Host-scoped on purpose: a local
|
||||
* folder move must not drag a remote host's alias to a path that host does not have.
|
||||
*/
|
||||
export function migrateWorktreeMetadataLocator(
|
||||
state: PersistedState,
|
||||
oldWorktreeId: string,
|
||||
newWorktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): boolean {
|
||||
if (oldWorktreeId === newWorktreeId) {
|
||||
return false
|
||||
}
|
||||
const oldAlias = composeWorktreeHostIdentity(executionHostId, oldWorktreeId)
|
||||
const identityKeys = state.worktreeIdentityAliases?.[oldAlias]
|
||||
if (!identityKeys || identityKeys.length === 0) {
|
||||
return false
|
||||
}
|
||||
const newAlias = composeWorktreeHostIdentity(executionHostId, newWorktreeId)
|
||||
state.worktreeIdentityAliases ??= {}
|
||||
// A taken destination keeps its own occupant; stranding the mover at the old locator loses less
|
||||
// than merging (which makes both unreadable) or dropping its row outright.
|
||||
if ((state.worktreeIdentityAliases[newAlias] ?? []).length > 0) {
|
||||
return false
|
||||
}
|
||||
state.worktreeIdentityAliases[newAlias] = [...identityKeys]
|
||||
delete state.worktreeIdentityAliases[oldAlias]
|
||||
return true
|
||||
}
|
||||
export function getWorktreeMetaForHost(
|
||||
runtime: MetadataRuntime,
|
||||
scheduling: WriteSchedulingOperations,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId
|
||||
): WorktreeMeta | undefined {
|
||||
const state = runtime.state
|
||||
const changed = migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId)
|
||||
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
|
||||
const identityKey = resolveAliasIdentityKey(state, alias)
|
||||
if (changed) {
|
||||
scheduleSave(scheduling)
|
||||
}
|
||||
if (identityKey) {
|
||||
return state.worktreeMetaByIdentity?.[identityKey]
|
||||
}
|
||||
const legacy = state.worktreeMeta[worktreeId]
|
||||
return !legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined
|
||||
}
|
||||
|
||||
/** Read-only host projection used by listings that must include canonical-only rows. */
|
||||
export function getAllWorktreeMetaForHost(
|
||||
runtime: MetadataRuntime,
|
||||
executionHostId: ExecutionHostId
|
||||
): Record<string, WorktreeMeta> {
|
||||
const state = runtime.state
|
||||
const projected: Record<string, WorktreeMeta> = {}
|
||||
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
|
||||
if (!meta.hostId || meta.hostId === executionHostId) {
|
||||
projected[worktreeId] = meta
|
||||
}
|
||||
}
|
||||
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
|
||||
if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== executionHostId) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = getWorktreeIdFromHostIdentity(alias)
|
||||
const identityKey = resolveAliasIdentityKey(state, alias)
|
||||
const meta = identityKey ? state.worktreeMetaByIdentity?.[identityKey] : undefined
|
||||
if (!worktreeId || !meta || (meta.hostId && meta.hostId !== executionHostId)) {
|
||||
continue
|
||||
}
|
||||
projected[worktreeId] =
|
||||
meta.hostId === executionHostId ? meta : { ...meta, hostId: executionHostId }
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export function setWorktreeMetaForHost(
|
||||
runtime: MetadataRuntime,
|
||||
scheduling: WriteSchedulingOperations,
|
||||
worktreeId: string,
|
||||
executionHostId: ExecutionHostId,
|
||||
meta: Partial<WorktreeMeta>
|
||||
): WorktreeMeta {
|
||||
const state = runtime.state
|
||||
migrateLegacyWorktreeMetadata(state, worktreeId, executionHostId)
|
||||
const alias = composeWorktreeHostIdentity(executionHostId, worktreeId)
|
||||
const identityKeys = state.worktreeIdentityAliases?.[alias] ?? []
|
||||
if (identityKeys.length > 1 && meta.instanceId === undefined) {
|
||||
throw new Error('Worktree identity is ambiguous for this host and locator.')
|
||||
}
|
||||
const existingIdentityKey = resolveAliasIdentityKey(state, alias)
|
||||
const existingIdentityMeta = existingIdentityKey
|
||||
? state.worktreeMetaByIdentity?.[existingIdentityKey]
|
||||
: undefined
|
||||
const legacy = state.worktreeMeta[worktreeId]
|
||||
const existing =
|
||||
existingIdentityMeta ??
|
||||
(!legacy?.hostId || legacy.hostId === executionHostId ? legacy : undefined)
|
||||
// An explicit instanceId is a deliberate rotation (see worktree-lineage-pruning), so it wins.
|
||||
const instanceId = meta.instanceId ?? existing?.instanceId ?? randomUUID()
|
||||
const identityKey = canonicalWorktreeIdentity({ worktreeId, executionHostId, instanceId })
|
||||
const updated = mergeWorktreeMetaForWrite(existing, meta, {
|
||||
instanceId,
|
||||
hostId: executionHostId
|
||||
})
|
||||
state.worktreeMetaByIdentity ??= {}
|
||||
state.worktreeIdentityAliases ??= {}
|
||||
if (existingIdentityKey && existingIdentityKey !== identityKey) {
|
||||
delete state.worktreeMetaByIdentity[existingIdentityKey]
|
||||
}
|
||||
state.worktreeMetaByIdentity[identityKey] = updated
|
||||
state.worktreeIdentityAliases[alias] = [identityKey]
|
||||
// Keep the legacy projection only for the first known owner.
|
||||
if (!legacy || legacy.hostId === executionHostId) {
|
||||
state.worktreeMeta[worktreeId] = updated
|
||||
}
|
||||
scheduleSave(scheduling)
|
||||
return updated
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
|
||||
type WorktreeMetaIdentity = {
|
||||
instanceId: string
|
||||
hostId: ExecutionHostId
|
||||
}
|
||||
|
||||
function createDefaultWorktreeMeta(): WorktreeMeta {
|
||||
return {
|
||||
instanceId: randomUUID(),
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedBitbucketPR: null,
|
||||
linkedAzureDevOpsPR: null,
|
||||
linkedGiteaPR: null,
|
||||
linkedWorkItem: null,
|
||||
linkedTaskSourceContext: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: Date.now(),
|
||||
lastActivityAt: 0,
|
||||
workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge and normalize the metadata shape shared by legacy and identity-keyed writes. */
|
||||
export function mergeWorktreeMetaForWrite(
|
||||
existing: WorktreeMeta | undefined,
|
||||
updates: Partial<WorktreeMeta>,
|
||||
identity?: WorktreeMetaIdentity
|
||||
): WorktreeMeta {
|
||||
const updated = { ...(existing ?? createDefaultWorktreeMeta()), ...updates, ...identity }
|
||||
updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem)
|
||||
const sourceContext = normalizeStoredTaskSourceContext(updated.linkedTaskSourceContext)
|
||||
updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
|
||||
updated.linkedWorkItem,
|
||||
sourceContext
|
||||
)
|
||||
? sourceContext
|
||||
: null
|
||||
updated.instanceId ||= randomUUID()
|
||||
return updated
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
|
||||
import {
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity,
|
||||
isWorktreeHostIdentity
|
||||
} from '../../../shared/worktree/host-qualified-identity'
|
||||
import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata'
|
||||
|
||||
export function pruneWorktreeStateForRepo(
|
||||
state: PersistedState,
|
||||
@@ -67,6 +69,14 @@ export function pruneWorktreeStateForRepo(
|
||||
delete state.worktreeMeta[key]
|
||||
}
|
||||
}
|
||||
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
|
||||
const rawId = getWorktreeIdFromHostIdentity(alias)
|
||||
const aliasHost = getExecutionHostIdFromWorktreeHostIdentity(alias)
|
||||
if (rawId.startsWith(prefix) && (hostId === null || aliasHost === hostId)) {
|
||||
delete state.worktreeIdentityAliases?.[alias]
|
||||
}
|
||||
}
|
||||
pruneUnreferencedWorktreeIdentityMeta(state)
|
||||
// Why: a host-scoped prune must touch only that host's session (legacy blob = local, one partition
|
||||
// per remote host); pruning every partition would wipe a surviving host's tabs and sleeping agents
|
||||
// for a shared repo id/path. A full removal (hostId === null) still clears every host.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization'
|
||||
import {
|
||||
gcStaleWorktreeMeta,
|
||||
normalizeWorktreeLinkedItemMetadata,
|
||||
WORKTREE_META_GC_GRACE_MS
|
||||
} from './worktree-metadata-normalization'
|
||||
|
||||
// Only the presence of an entry matters here; the normalizer never reads its linked-item fields.
|
||||
function makeMeta(): WorktreeMeta {
|
||||
@@ -38,6 +42,99 @@ describe('normalizeWorktreeLinkedItemMetadata', () => {
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
|
||||
expect(state.workspaceLineageByChildKey).toEqual({})
|
||||
})
|
||||
it('repairs malformed canonical metadata and alias maps', () => {
|
||||
const state = makeState({
|
||||
worktreeMetaByIdentity: null as unknown as PersistedState['worktreeMetaByIdentity'],
|
||||
worktreeIdentityAliases: {
|
||||
'local|r1::/tmp/wt': ['wt2:local:one', 'wt2:local:one', '', 42]
|
||||
} as unknown as PersistedState['worktreeIdentityAliases']
|
||||
})
|
||||
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
|
||||
expect(state.worktreeMetaByIdentity).toEqual({})
|
||||
// A key with no backing row is what turns the next write into an ambiguous alias, so it goes.
|
||||
expect(state.worktreeIdentityAliases).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps alias keys that still resolve to a metadata row', () => {
|
||||
const state = makeState({
|
||||
worktreeMetaByIdentity: { 'wt2:local:one': makeMeta() },
|
||||
worktreeIdentityAliases: { 'local|r1::/tmp/wt': ['wt2:local:one'] }
|
||||
})
|
||||
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(false)
|
||||
expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/wt': ['wt2:local:one'] })
|
||||
})
|
||||
|
||||
it('drops malformed canonical metadata entries', () => {
|
||||
const state = makeState({
|
||||
worktreeMetaByIdentity: {
|
||||
valid: makeMeta(),
|
||||
malformed: null
|
||||
} as unknown as PersistedState['worktreeMetaByIdentity'],
|
||||
worktreeIdentityAliases: { 'local|r1::/tmp/wt': ['valid', 'malformed'] }
|
||||
})
|
||||
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
|
||||
expect(state.worktreeMetaByIdentity).toEqual({ valid: makeMeta() })
|
||||
expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/wt': ['valid'] })
|
||||
})
|
||||
|
||||
it('preserves every host canonical row when one legacy locator row is malformed', () => {
|
||||
const localKey = 'wt2:local:local-instance'
|
||||
const remoteKey = 'wt2:ssh%3Abuilder:remote-instance'
|
||||
const worktreeId = 'r1::/tmp/wt'
|
||||
const state = makeState({
|
||||
worktreeMeta: { [worktreeId]: null } as unknown as PersistedState['worktreeMeta'],
|
||||
worktreeMetaByIdentity: { [localKey]: makeMeta(), [remoteKey]: makeMeta() },
|
||||
worktreeIdentityAliases: {
|
||||
[`local|${worktreeId}`]: [localKey],
|
||||
[`ssh:builder|${worktreeId}`]: [remoteKey]
|
||||
}
|
||||
})
|
||||
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
|
||||
expect(state.worktreeMeta).toEqual({})
|
||||
expect(state.worktreeMetaByIdentity).toEqual({
|
||||
[localKey]: makeMeta(),
|
||||
[remoteKey]: makeMeta()
|
||||
})
|
||||
expect(state.worktreeIdentityAliases).toEqual({
|
||||
[`local|${worktreeId}`]: [localKey],
|
||||
[`ssh:builder|${worktreeId}`]: [remoteKey]
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves valid canonical state when the whole legacy projection is malformed', () => {
|
||||
const identityKey = 'wt2:ssh%3Abuilder:remote-instance'
|
||||
const alias = 'ssh:builder|r1::/tmp/wt'
|
||||
const state = makeState({
|
||||
worktreeMeta: null as unknown as PersistedState['worktreeMeta'],
|
||||
worktreeMetaByIdentity: { [identityKey]: makeMeta() },
|
||||
worktreeIdentityAliases: { [alias]: [identityKey] }
|
||||
})
|
||||
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
|
||||
expect(state.worktreeMeta).toEqual({})
|
||||
expect(state.worktreeMetaByIdentity).toEqual({ [identityKey]: makeMeta() })
|
||||
expect(state.worktreeIdentityAliases).toEqual({ [alias]: [identityKey] })
|
||||
})
|
||||
|
||||
it('prunes canonical metadata left unreachable by dangling-alias cleanup', () => {
|
||||
const liveKey = 'wt2:local:live'
|
||||
const orphanKey = 'wt2:local:orphan'
|
||||
const state = makeState({
|
||||
worktreeMetaByIdentity: { [liveKey]: makeMeta(), [orphanKey]: makeMeta() },
|
||||
worktreeIdentityAliases: {
|
||||
'local|r1::/tmp/live': [liveKey],
|
||||
'local|r1::/tmp/dangling': ['missing-key']
|
||||
}
|
||||
})
|
||||
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true)
|
||||
expect(state.worktreeMetaByIdentity).toEqual({ [liveKey]: makeMeta() })
|
||||
expect(state.worktreeIdentityAliases).toEqual({ 'local|r1::/tmp/live': [liveKey] })
|
||||
})
|
||||
|
||||
it('leaves already-normalized state clean', () => {
|
||||
const state = makeState({
|
||||
@@ -47,3 +144,36 @@ describe('normalizeWorktreeLinkedItemMetadata', () => {
|
||||
expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gcStaleWorktreeMeta', () => {
|
||||
it('reclaims the identity rows of a collected worktree', () => {
|
||||
const worktreeId = 'r1::/definitely/missing/orca/path'
|
||||
const identityKey = 'wt2:local:dead'
|
||||
const remoteIdentityKey = 'wt2:ssh:live'
|
||||
const state = makeState({
|
||||
repos: [],
|
||||
projects: [],
|
||||
worktreeMeta: {
|
||||
[worktreeId]: {
|
||||
hostId: 'local',
|
||||
instanceId: 'dead',
|
||||
displayName: 'dead',
|
||||
lastActivityAt: Date.now() - WORKTREE_META_GC_GRACE_MS - 1
|
||||
} as unknown as WorktreeMeta
|
||||
},
|
||||
worktreeMetaByIdentity: { [identityKey]: makeMeta(), [remoteIdentityKey]: makeMeta() },
|
||||
worktreeIdentityAliases: {
|
||||
[`local|${worktreeId}`]: [identityKey],
|
||||
[`ssh:conn-1|${worktreeId}`]: [remoteIdentityKey]
|
||||
}
|
||||
})
|
||||
|
||||
expect(gcStaleWorktreeMeta(state)).toBe(1)
|
||||
// A surviving alias would re-attach this dead metadata to a worktree later
|
||||
// created at the same repoId::path, and nothing else ever reclaims it.
|
||||
expect(state.worktreeMetaByIdentity).toEqual({ [remoteIdentityKey]: expect.anything() })
|
||||
expect(state.worktreeIdentityAliases).toEqual({
|
||||
[`ssh:conn-1|${worktreeId}`]: [remoteIdentityKey]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from '../../../shared/workspace-linked-item'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import {
|
||||
pruneUnreferencedWorktreeIdentityMeta,
|
||||
removeWorktreeMetadataForHost
|
||||
} from '../loading-store/worktree-identity-metadata'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
|
||||
// Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install).
|
||||
// GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace.
|
||||
@@ -75,11 +80,35 @@ export function gcStaleWorktreeMeta(state: PersistedState): number {
|
||||
delete state.worktreeMeta[key]
|
||||
delete state.worktreeLineageById[key]
|
||||
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
|
||||
// Identity rows are companions too: a surviving alias would re-attach this dead metadata to a
|
||||
// worktree later created at the same repoId::path, and nothing else ever reclaims them.
|
||||
removeWorktreeMetadataForHost(state, key, LOCAL_EXECUTION_HOST_ID)
|
||||
removed++
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
function normalizeLinkedMetadata(meta: WorktreeMeta): boolean {
|
||||
const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem)
|
||||
const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext)
|
||||
const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
|
||||
linkedWorkItem,
|
||||
sourceContext
|
||||
)
|
||||
? sourceContext
|
||||
: null
|
||||
let changed = false
|
||||
if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) {
|
||||
meta.linkedWorkItem = linkedWorkItem
|
||||
changed = true
|
||||
}
|
||||
if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) {
|
||||
meta.linkedTaskSourceContext = linkedTaskSourceContext
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean {
|
||||
// Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair
|
||||
// itself is dirty state — without it the map stays null on disk and is repaired again every load.
|
||||
@@ -98,8 +127,7 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool
|
||||
Object.keys(state.worktreeLineageById).length > 0 ||
|
||||
Object.keys(state.workspaceLineageByChildKey).length > 0
|
||||
state.worktreeMeta = {}
|
||||
// Companions go with the discarded map; a stranded lineage row would otherwise re-attach to a
|
||||
// worktree recreated at the same repoId::path.
|
||||
// Legacy lineage belongs to the discarded locator projection; canonical host state does not.
|
||||
state.worktreeLineageById = {}
|
||||
state.workspaceLineageByChildKey = {}
|
||||
changed ||= rawWorktreeMeta !== undefined || hadLineage
|
||||
@@ -109,29 +137,74 @@ export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): bool
|
||||
// keeps timestamp-less keys forever and every downstream consumer trusts the Record<string, WorktreeMeta> type.
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
|
||||
delete state.worktreeMeta[key]
|
||||
// Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would
|
||||
// otherwise re-attach to a worktree recreated at the same repoId::path.
|
||||
// Legacy lineage belongs to this corrupt locator row; canonical host state is independent.
|
||||
delete state.worktreeLineageById[key]
|
||||
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem)
|
||||
const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext)
|
||||
const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
|
||||
linkedWorkItem,
|
||||
sourceContext
|
||||
)
|
||||
? sourceContext
|
||||
: null
|
||||
if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) {
|
||||
meta.linkedWorkItem = linkedWorkItem
|
||||
changed = normalizeLinkedMetadata(meta) || changed
|
||||
}
|
||||
const rawIdentityMetadata = state.worktreeMetaByIdentity as unknown
|
||||
if (
|
||||
rawIdentityMetadata !== undefined &&
|
||||
(typeof rawIdentityMetadata !== 'object' ||
|
||||
rawIdentityMetadata === null ||
|
||||
Array.isArray(rawIdentityMetadata))
|
||||
) {
|
||||
state.worktreeMetaByIdentity = {}
|
||||
changed = true
|
||||
}
|
||||
for (const [identityKey, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
|
||||
delete state.worktreeMetaByIdentity?.[identityKey]
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) {
|
||||
meta.linkedTaskSourceContext = linkedTaskSourceContext
|
||||
changed = normalizeLinkedMetadata(meta) || changed
|
||||
}
|
||||
|
||||
const rawAliases = state.worktreeIdentityAliases as unknown
|
||||
const aliasesAreObject =
|
||||
typeof rawAliases === 'object' && rawAliases !== null && !Array.isArray(rawAliases)
|
||||
const aliasesAreMalformed = rawAliases !== undefined && !aliasesAreObject
|
||||
const aliasesCanProveReachability = aliasesAreObject && Object.keys(rawAliases).length > 0
|
||||
if (aliasesAreMalformed) {
|
||||
state.worktreeIdentityAliases = {}
|
||||
changed = true
|
||||
}
|
||||
for (const [alias, rawIdentityKeys] of Object.entries(state.worktreeIdentityAliases ?? {})) {
|
||||
if (!Array.isArray(rawIdentityKeys)) {
|
||||
delete state.worktreeIdentityAliases?.[alias]
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
// Drop keys with no backing row: a dangling key is what turns the next write into an
|
||||
// ambiguous alias, and nothing downstream can resolve it anyway.
|
||||
const identityKeys = [
|
||||
...new Set(
|
||||
rawIdentityKeys.filter(
|
||||
(key): key is string =>
|
||||
typeof key === 'string' &&
|
||||
key.length > 0 &&
|
||||
Boolean(state.worktreeMetaByIdentity?.[key])
|
||||
)
|
||||
)
|
||||
]
|
||||
if (identityKeys.length === 0) {
|
||||
delete state.worktreeIdentityAliases?.[alias]
|
||||
changed = true
|
||||
} else if (
|
||||
identityKeys.length !== rawIdentityKeys.length ||
|
||||
identityKeys.some((key, index) => key !== rawIdentityKeys[index])
|
||||
) {
|
||||
state.worktreeIdentityAliases ??= {}
|
||||
state.worktreeIdentityAliases[alias] = identityKeys
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (aliasesCanProveReachability) {
|
||||
changed = pruneUnreferencedWorktreeIdentityMeta(state) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-projection'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup'
|
||||
import { isTuiAgent } from '../shared/tui-agent-config'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS,
|
||||
type AgentPromptActivity,
|
||||
isAgentPromptStalledError,
|
||||
readAgentPromptWaitText,
|
||||
resolveAgentPromptEffectTimeoutMs,
|
||||
verifyAgentPromptSubmission
|
||||
} from './agent-prompt-submission-verification'
|
||||
@@ -23,6 +24,17 @@ function activity(overrides: Partial<AgentPromptActivity> = {}): AgentPromptActi
|
||||
describe('agent prompt submission verification', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('reuses wait text while the PTY output sequence is unchanged', () => {
|
||||
const cache: { outputSequence?: number; waitText?: string } = {}
|
||||
const readWaitText = vi.fn(() => 'retained terminal tail')
|
||||
|
||||
expect(readAgentPromptWaitText(cache, 7, readWaitText)).toBe('retained terminal tail')
|
||||
expect(readAgentPromptWaitText(cache, 7, readWaitText)).toBe('retained terminal tail')
|
||||
expect(readAgentPromptWaitText(cache, 8, readWaitText)).toBe('retained terminal tail')
|
||||
|
||||
expect(readWaitText).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('accepts an observed working transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
@@ -80,6 +92,21 @@ describe('agent prompt submission verification', () => {
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('accepts a working transition after the former five-second deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
current = activity({ workingSequence: 5, status: 'working' })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(verification).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks when permission appears after submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
@@ -207,7 +234,7 @@ describe('agent prompt submission verification', () => {
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('holds the longer hook window open past the default timeout', async () => {
|
||||
it('holds the extended hook window open past the former hook timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
@@ -216,7 +243,7 @@ describe('agent prompt submission verification', () => {
|
||||
timeoutMs: AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS + 1_000)
|
||||
await vi.advanceTimersByTimeAsync(15_000 + 1_000)
|
||||
current = activity({ explicitWorkingStartedAt: 9_000, status: 'working' })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets'
|
||||
import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
|
||||
export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000
|
||||
// Why: these panes prove a turn start only through the out-of-process hook — kimi has no synthetic
|
||||
// title profile and codex suppresses the hook-driven working frame (synthesizeWorkingTitle: false),
|
||||
// so the first proof lags Enter by agent startup, not by one TUI repaint. Capped so the worst case
|
||||
// (8s render gate + this wait + chunked paste) still fits RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS
|
||||
// (30s, src/relay/dispatcher.ts), the budget a paired client's submission runs under.
|
||||
export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = 15_000
|
||||
export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS
|
||||
const AGENT_PROMPT_EFFECT_POLL_MS = 50
|
||||
|
||||
const HOOK_OBSERVED_TURN_START_AGENTS = new Set<TuiAgent>(['codex', 'kimi'])
|
||||
@@ -26,6 +22,11 @@ export type AgentPromptActivity = Readonly<{
|
||||
status: 'working' | 'permission' | 'idle' | null
|
||||
}>
|
||||
|
||||
export type AgentPromptWaitTextCache = {
|
||||
outputSequence?: number
|
||||
waitText?: string
|
||||
}
|
||||
|
||||
type AgentPromptVerificationOptions = {
|
||||
baseline: AgentPromptActivity
|
||||
readActivity: () => AgentPromptActivity
|
||||
@@ -51,6 +52,20 @@ export function isAgentPromptStalledError(error: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function readAgentPromptWaitText(
|
||||
cache: AgentPromptWaitTextCache,
|
||||
outputSequence: number,
|
||||
readWaitText: () => string
|
||||
): string {
|
||||
if (cache.outputSequence === outputSequence && cache.waitText !== undefined) {
|
||||
return cache.waitText
|
||||
}
|
||||
const waitText = readWaitText()
|
||||
cache.outputSequence = outputSequence
|
||||
cache.waitText = waitText
|
||||
return waitText
|
||||
}
|
||||
|
||||
export async function verifyAgentPromptSubmission(
|
||||
options: AgentPromptVerificationOptions
|
||||
): Promise<void> {
|
||||
|
||||
@@ -19354,6 +19354,97 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('separates composer draft text from rendered terminal output', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
'\x1b[?1049hBuild passed\r\n────────\r\n❯ \x1b[2mproceed with the release\r\n and close the pull request\x1b[22m\x1b[1A\x1b[3G',
|
||||
100
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read).toMatchObject({
|
||||
source: 'screen',
|
||||
tail: ['Build passed', '────────', '❯'],
|
||||
draft: 'proceed with the release\nand close the pull request'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps renderer-fallback composer drafts separate from terminal output', async () => {
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: '\x1b[?1049hBuild passed\r\n────────\r\n❯ \x1b[2mproceed with the release\x1b[22m\x1b[3G',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
hasRendererSerializer: () => true,
|
||||
serializeBuffer
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', `${Array.from({ length: 3000 }, () => '').join('\n')}\n`, 100)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read).toMatchObject({
|
||||
source: 'screen',
|
||||
tail: ['Build passed', '────────', '❯'],
|
||||
draft: 'proceed with the release'
|
||||
})
|
||||
expect(serializeBuffer).toHaveBeenCalledWith('pty-1', {
|
||||
scrollbackRows: 0,
|
||||
altScreenForcesZeroRows: false
|
||||
})
|
||||
})
|
||||
|
||||
it('separates composer drafts from provider-owned terminal screens', async () => {
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: '\x1b[?1049hBuild passed\r\n────────\r\n❯ \x1b[2mproceed with the release\x1b[22m\x1b[3G',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 900,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-1',
|
||||
{ value: 900, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
|
||||
expect(read).toMatchObject({
|
||||
source: 'screen',
|
||||
tail: ['Build passed', '────────', '❯'],
|
||||
draft: 'proceed with the release'
|
||||
})
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not use renderer visible-screen fallback for cursor transcript reads', async () => {
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'Visible TUI\n',
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
|
||||
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
|
||||
import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft'
|
||||
import type {
|
||||
RemoteTerminalSourceRangeConsumerHooks,
|
||||
RemoteTerminalSourceRangeReplacementPublication,
|
||||
@@ -106,6 +107,8 @@ import {
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import {
|
||||
type AgentPromptActivity,
|
||||
type AgentPromptWaitTextCache,
|
||||
readAgentPromptWaitText,
|
||||
resolveAgentPromptEffectTimeoutMs,
|
||||
verifyAgentPromptSubmission
|
||||
} from './agent-prompt-submission-verification'
|
||||
@@ -538,11 +541,11 @@ import {
|
||||
splitWorktreeIdForFilesystem,
|
||||
worktreeIdComparisonKey
|
||||
} from '../../shared/worktree/id'
|
||||
import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getProjectIdForProviderIdentity,
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectHostSetupWorktreeMeta
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
} from '../../shared/project-host-setup-lookup'
|
||||
import { parsePtySessionId } from '../../shared/pty-session-id-format'
|
||||
import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
@@ -918,6 +921,7 @@ import {
|
||||
type RepoWorktreeRowDeps
|
||||
} from './repo-worktree-row-resolution'
|
||||
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
|
||||
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import {
|
||||
getLocalWorktreePathAccess,
|
||||
@@ -1354,6 +1358,7 @@ type RuntimeStore = {
|
||||
getAllWorktreeMeta: Store['getAllWorktreeMeta']
|
||||
getWorktreeMeta: Store['getWorktreeMeta']
|
||||
setWorktreeMeta: Store['setWorktreeMeta']
|
||||
setWorktreeMetaForHost?: Store['setWorktreeMetaForHost']
|
||||
removeWorktreeMeta: Store['removeWorktreeMeta']
|
||||
getWorktreeLineage?: Store['getWorktreeLineage']
|
||||
getAllWorktreeLineage?: Store['getAllWorktreeLineage']
|
||||
@@ -1892,11 +1897,17 @@ export type RuntimeTerminalDataMeta = Readonly<{
|
||||
|
||||
type RuntimeVisibleTerminalState = {
|
||||
lines: string[]
|
||||
draft?: string
|
||||
isAlternateScreen: boolean
|
||||
sequence: number
|
||||
generation: number
|
||||
}
|
||||
|
||||
type RuntimeTerminalProjection = {
|
||||
lines: string[]
|
||||
draft?: string
|
||||
}
|
||||
|
||||
type ProviderBufferAcquisition = {
|
||||
generation: number
|
||||
scrollbackRows: number
|
||||
@@ -6095,7 +6106,8 @@ export class OrcaRuntimeService {
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs?: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
internal?: { contractVerified?: boolean }
|
||||
): Promise<unknown> {
|
||||
if (!this.orchestrationEnvironmentTransport) {
|
||||
throw new OrchestrationError(
|
||||
@@ -6103,7 +6115,7 @@ export class OrcaRuntimeService {
|
||||
'Connected-server orchestration is unavailable in this runtime.'
|
||||
)
|
||||
}
|
||||
if (isOrchestrationMutation(method, params)) {
|
||||
if (isOrchestrationMutation(method, params) && !internal?.contractVerified) {
|
||||
const statusResponse = await this.orchestrationEnvironmentTransport.call(
|
||||
selector,
|
||||
'status.get',
|
||||
@@ -14048,13 +14060,18 @@ export class OrcaRuntimeService {
|
||||
!recoveredWorkerFallback &&
|
||||
this.isKnownUnattachedLocalDaemonPty(ptyId)
|
||||
if (recoveredWorkerFallback || neverAttachedProviderFallback) {
|
||||
const providerLines = await this.readProviderTerminalTailLines(
|
||||
const providerProjection = await this.readProviderTerminalTailLines(
|
||||
ptyId,
|
||||
opts.limit,
|
||||
providerSnapshot
|
||||
)
|
||||
if (providerLines.length > 0) {
|
||||
return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit)
|
||||
if (providerProjection.lines.length > 0) {
|
||||
return buildVisibleSnapshotReadFallback(
|
||||
read,
|
||||
providerProjection.lines,
|
||||
opts.limit,
|
||||
providerProjection.draft
|
||||
)
|
||||
}
|
||||
}
|
||||
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
|
||||
@@ -14078,21 +14095,21 @@ export class OrcaRuntimeService {
|
||||
) {
|
||||
return read
|
||||
}
|
||||
let lines = visibleState?.lines ?? []
|
||||
if (lines.length === 0) {
|
||||
lines = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] }
|
||||
if (projection.lines.length === 0) {
|
||||
projection = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
if (projection.lines.length === 0) {
|
||||
return read
|
||||
}
|
||||
return buildVisibleSnapshotReadFallback(read, lines, opts.limit)
|
||||
return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft)
|
||||
}
|
||||
|
||||
private async readProviderTerminalTailLines(
|
||||
ptyId: string,
|
||||
limit: number | undefined,
|
||||
snapshotOptions: ProviderSnapshotReadOptions = {}
|
||||
): Promise<string[]> {
|
||||
): Promise<RuntimeTerminalProjection> {
|
||||
const generation = this.getPtyLifecycleGeneration(ptyId)
|
||||
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
|
||||
const snapshot = await this.serializeProviderTerminalBuffer(
|
||||
@@ -14101,21 +14118,21 @@ export class OrcaRuntimeService {
|
||||
snapshotOptions
|
||||
)
|
||||
if (!snapshot) {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
// Why: a cached acquisition can carry scrollback this caller did not ask for,
|
||||
// so visible-only reads parse the grid itself rather than trusting the request.
|
||||
if (snapshotOptions.visibleScreenOnly) {
|
||||
const lines = await this.parseVisibleSnapshotLines(snapshot)
|
||||
const projection = await this.parseVisibleSnapshot(snapshot)
|
||||
// Live bytes ordered after the provider frame make that frame stale.
|
||||
return this.getPtyLifecycleGeneration(ptyId) === generation &&
|
||||
this.getPtyOutputSequence(ptyId) <= snapshot.seq
|
||||
? lines
|
||||
: []
|
||||
? projection
|
||||
: { lines: [] }
|
||||
}
|
||||
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
|
||||
if (data.length === 0) {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: snapshot.cols,
|
||||
@@ -14124,11 +14141,11 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
try {
|
||||
await emulator.write(data)
|
||||
const lines = visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit))
|
||||
const projection = projectTerminalTailLines(emulator, lineLimit)
|
||||
return this.getPtyLifecycleGeneration(ptyId) === generation &&
|
||||
this.getPtyOutputSequence(ptyId) <= snapshot.seq
|
||||
? lines
|
||||
: []
|
||||
? projection
|
||||
: { lines: [] }
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
@@ -14145,11 +14162,11 @@ export class OrcaRuntimeService {
|
||||
if (!knownAlternateScreen && !visibleState?.isAlternateScreen) {
|
||||
return preview
|
||||
}
|
||||
let lines = visibleState?.lines ?? []
|
||||
if (lines.length === 0) {
|
||||
lines = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] }
|
||||
if (projection.lines.length === 0) {
|
||||
projection = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
}
|
||||
return lines.length > 0 ? buildPreview(lines, '') : preview
|
||||
return projection.lines.length > 0 ? buildPreview(projection.lines, '') : preview
|
||||
}
|
||||
|
||||
private async readVisibleTerminalState(
|
||||
@@ -14203,7 +14220,7 @@ export class OrcaRuntimeService {
|
||||
return liveState
|
||||
}
|
||||
}
|
||||
const lines = await this.parseVisibleSnapshotLines(snapshot)
|
||||
const projection = await this.parseVisibleSnapshot(snapshot)
|
||||
if (
|
||||
this.getPtyLifecycleGeneration(ptyId) !== generation ||
|
||||
this.getPtyOutputSequence(ptyId) > snapshot.seq
|
||||
@@ -14211,7 +14228,8 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
const visibleState: RuntimeVisibleTerminalState = {
|
||||
lines,
|
||||
lines: projection.lines,
|
||||
...(projection.draft ? { draft: projection.draft } : {}),
|
||||
isAlternateScreen: snapshot.alternateScreen ?? false,
|
||||
sequence: snapshot.seq,
|
||||
generation
|
||||
@@ -14235,21 +14253,23 @@ export class OrcaRuntimeService {
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const projection = projectVisibleTerminalLines(state.emulator)
|
||||
return {
|
||||
lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()),
|
||||
lines: projection.lines,
|
||||
...(projection.draft ? { draft: projection.draft } : {}),
|
||||
isAlternateScreen: state.emulator.isAlternateScreen,
|
||||
sequence: state.outputSequence,
|
||||
generation
|
||||
}
|
||||
}
|
||||
|
||||
private async parseVisibleSnapshotLines(snapshot: {
|
||||
private async parseVisibleSnapshot(snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
}): Promise<string[]> {
|
||||
}): Promise<{ lines: string[]; draft?: string }> {
|
||||
if (snapshot.data.length === 0) {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: snapshot.cols,
|
||||
@@ -14258,19 +14278,21 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
try {
|
||||
await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`)
|
||||
return visibleNonBlankTerminalLines(emulator.getVisibleLines())
|
||||
return projectVisibleTerminalLines(emulator)
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async readRendererVisibleSnapshotLines(ptyId: string): Promise<string[]> {
|
||||
private async readRendererVisibleSnapshotLines(
|
||||
ptyId: string
|
||||
): Promise<RuntimeTerminalProjection> {
|
||||
const controller = this.ptyController
|
||||
if (!controller?.serializeBuffer) {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
try {
|
||||
// Why: raw PTY tails can be whitespace-only while a full-screen TUI is
|
||||
@@ -14285,11 +14307,11 @@ export class OrcaRuntimeService {
|
||||
null
|
||||
)
|
||||
if (!snapshot || snapshot.data.length === 0) {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
return this.parseVisibleSnapshotLines(snapshot)
|
||||
return this.parseVisibleSnapshot(snapshot)
|
||||
} catch {
|
||||
return []
|
||||
return { lines: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19270,12 +19292,11 @@ export class OrcaRuntimeService {
|
||||
opts: { limit?: number } = {}
|
||||
): Promise<RuntimeTerminalRead> {
|
||||
const visibleState = await this.readVisibleTerminalState(ptyId)
|
||||
const lines =
|
||||
visibleState?.lines ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit))
|
||||
if (lines.length === 0) {
|
||||
const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit))
|
||||
if (projection.lines.length === 0) {
|
||||
return { ...read, source: 'screen-unavailable' }
|
||||
}
|
||||
return buildVisibleSnapshotReadFallback(read, lines, opts.limit)
|
||||
return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft)
|
||||
}
|
||||
|
||||
// Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent
|
||||
@@ -19528,7 +19549,8 @@ export class OrcaRuntimeService {
|
||||
|
||||
private getTerminalAgentStatusSnapshot(
|
||||
handle: string,
|
||||
expectedPtyId: string
|
||||
expectedPtyId: string,
|
||||
waitTextOverride?: string
|
||||
): TerminalAgentStatusSnapshot {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
@@ -19548,11 +19570,9 @@ export class OrcaRuntimeService {
|
||||
{ title: pty.pty.title, updatedAt: pty.pty.titleUpdatedAt },
|
||||
{ title: pty.pty.lastOscTitle, updatedAt: pty.pty.lastOscTitleAt }
|
||||
)
|
||||
const waitText = buildTerminalWaitText(
|
||||
pty.pty.tailBuffer,
|
||||
pty.pty.tailPartialLine,
|
||||
pty.pty.preview
|
||||
)
|
||||
const waitText =
|
||||
waitTextOverride ??
|
||||
buildTerminalWaitText(pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview)
|
||||
return {
|
||||
waitText,
|
||||
waitBlockedAt: pty.pty.waitBlockedAt,
|
||||
@@ -19580,7 +19600,9 @@ export class OrcaRuntimeService {
|
||||
{ title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 }
|
||||
)
|
||||
return {
|
||||
waitText: buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview),
|
||||
waitText:
|
||||
waitTextOverride ??
|
||||
buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview),
|
||||
waitBlockedAt: leaf.waitBlockedAt,
|
||||
title: title?.title ?? null,
|
||||
titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus,
|
||||
@@ -20239,7 +20261,8 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
assertAgentPromptRequestActive(options.signal)
|
||||
this.assertAgentPromptGeneration(ptyId, generation)
|
||||
const baseline = this.getAgentPromptActivity(handle, ptyId)
|
||||
const waitTextCache: AgentPromptWaitTextCache = {}
|
||||
const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache)
|
||||
this.assertAgentPromptPermissionSafe(permissionBaseline, baseline)
|
||||
const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false
|
||||
if (!suffixWrote) {
|
||||
@@ -20247,7 +20270,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
await verifyAgentPromptSubmission({
|
||||
baseline,
|
||||
readActivity: () => this.getAgentPromptActivity(handle, ptyId),
|
||||
readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache),
|
||||
timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)),
|
||||
signal: options.signal
|
||||
})
|
||||
@@ -20276,8 +20299,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getAgentPromptActivity(handle: string, ptyId: string): AgentPromptActivity {
|
||||
private getAgentPromptActivity(
|
||||
handle: string,
|
||||
ptyId: string,
|
||||
waitTextCache?: AgentPromptWaitTextCache
|
||||
): AgentPromptActivity {
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, ptyId)
|
||||
const outputSequence = this.getPtyOutputSequence(ptyId)
|
||||
const explicitCandidate = this.getFreshExplicitAgentStatusForHandle(handle)
|
||||
const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId)
|
||||
const explicit =
|
||||
@@ -20295,7 +20323,14 @@ export class OrcaRuntimeService {
|
||||
(!explicit ||
|
||||
lifecycle.updatedAt > explicit.updatedAt ||
|
||||
(lifecycle.updatedAt === explicit.updatedAt && lifecycle.status === 'permission'))
|
||||
const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId)
|
||||
const waitText = waitTextCache
|
||||
? readAgentPromptWaitText(
|
||||
waitTextCache,
|
||||
outputSequence,
|
||||
() => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText
|
||||
)
|
||||
: undefined
|
||||
const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText)
|
||||
const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle)
|
||||
? 'permission'
|
||||
: lifecycleIsNewer
|
||||
@@ -20310,7 +20345,7 @@ export class OrcaRuntimeService {
|
||||
// stateStartedAt, not updatedAt — same-state tool/prompt pings refresh updatedAt and would
|
||||
// otherwise pass off an in-progress turn as a new one.
|
||||
explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null,
|
||||
outputSequence: this.getPtyOutputSequence(ptyId),
|
||||
outputSequence,
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -20835,7 +20870,7 @@ export class OrcaRuntimeService {
|
||||
workspaceKind: 'git',
|
||||
worktreeId: worktree.id,
|
||||
repoId: worktree.repoId,
|
||||
...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}),
|
||||
...((meta?.hostId ?? worktree.hostId) ? { hostId: meta?.hostId ?? worktree.hostId } : {}),
|
||||
terminalPlatform,
|
||||
repo: repo?.displayName ?? worktree.repoId,
|
||||
path: worktree.path,
|
||||
@@ -24261,7 +24296,10 @@ export class OrcaRuntimeService {
|
||||
const metaById = store.getAllWorktreeMeta()
|
||||
const detected = scan.worktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
// A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it.
|
||||
const meta =
|
||||
readWorktreeMetaForHost(store, worktreeId, expectedHostId) ??
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const worktree = {
|
||||
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId
|
||||
@@ -27129,12 +27167,20 @@ export class OrcaRuntimeService {
|
||||
createdAt
|
||||
})
|
||||
}
|
||||
this.store.setWorktreeMeta(worktree.id, stripOrcaProvenanceMetaUpdates(persistedMetaUpdates))
|
||||
const metadataUpdates = stripOrcaProvenanceMetaUpdates(persistedMetaUpdates)
|
||||
const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId
|
||||
if (executionHostId && this.store.setWorktreeMetaForHost) {
|
||||
this.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates)
|
||||
} else {
|
||||
this.store.setWorktreeMeta(worktree.id, metadataUpdates)
|
||||
}
|
||||
// Why: unlike renderer-initiated optimistic updates, CLI callers need an
|
||||
// explicit push so the editor refreshes metadata changed outside the UI.
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyWorktreesChanged(worktree.repoId)
|
||||
return await this.showManagedWorktree(`id:${worktree.id}`)
|
||||
return await this.showManagedWorktree(
|
||||
worktree.identity?.key ? `identity:${worktree.identity.key}` : `id:${worktree.id}`
|
||||
)
|
||||
}
|
||||
|
||||
persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } {
|
||||
@@ -32322,7 +32368,10 @@ export class OrcaRuntimeService {
|
||||
throw new Error('selector_not_found')
|
||||
}
|
||||
|
||||
if (selector.startsWith('id:')) {
|
||||
if (selector.startsWith('identity:')) {
|
||||
const identityKey = selector.slice('identity:'.length)
|
||||
candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey)
|
||||
} else if (selector.startsWith('id:')) {
|
||||
const worktreeId = explicitWorktreeId ?? selector.slice(3)
|
||||
candidates = worktrees.filter((worktree) => worktree.id === worktreeId)
|
||||
if (candidates.length === 0) {
|
||||
@@ -40940,6 +40989,46 @@ function visibleNonBlankTerminalLines(lines: string[]): string[] {
|
||||
return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0)
|
||||
}
|
||||
|
||||
function projectVisibleTerminalLines(emulator: HeadlessEmulator): {
|
||||
lines: string[]
|
||||
draft?: string
|
||||
} {
|
||||
const lines = emulator.getVisibleLines()
|
||||
const draft = detectTerminalComposerDraft(emulator.getCursorLineContext())
|
||||
if (draft) {
|
||||
lines[draft.promptRow] = draft.promptGlyph
|
||||
for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) {
|
||||
lines[row] = ''
|
||||
}
|
||||
}
|
||||
return {
|
||||
lines: visibleNonBlankTerminalLines(lines),
|
||||
...(draft ? { draft: draft.text } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function projectTerminalTailLines(
|
||||
emulator: HeadlessEmulator,
|
||||
limit: number
|
||||
): RuntimeTerminalProjection {
|
||||
const tail = emulator.getBufferTailLines(limit)
|
||||
const visible = emulator.getVisibleLines()
|
||||
const visibleRange = emulator.getVisibleBufferRange()
|
||||
const draft = detectTerminalComposerDraft(emulator.getCursorLineContext())
|
||||
if (draft && visibleRange.endExclusive === visibleRange.totalLength) {
|
||||
visible[draft.promptRow] = draft.promptGlyph
|
||||
for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) {
|
||||
visible[row] = ''
|
||||
}
|
||||
const scrollbackTail = tail.slice(0, Math.max(0, tail.length - visible.length))
|
||||
tail.splice(0, tail.length, ...scrollbackTail, ...visibleNonBlankTerminalLines(visible))
|
||||
}
|
||||
return {
|
||||
lines: visibleNonBlankTerminalLines(tail).slice(-limit),
|
||||
...(draft ? { draft: draft.text } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: every read carries its source, so a caller that asked for a screen and got a response
|
||||
// with no source at all knows it reached a host that predates screen reads — rather than
|
||||
// mistaking the stream for the screen. Rendered lines only ever enter a read through
|
||||
@@ -40952,7 +41041,8 @@ function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminal
|
||||
function buildVisibleSnapshotReadFallback(
|
||||
read: RuntimeTerminalRead,
|
||||
visibleLines: string[],
|
||||
limit: number | undefined
|
||||
limit: number | undefined,
|
||||
draft?: string
|
||||
): RuntimeTerminalRead {
|
||||
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
|
||||
const lineBoundedTail = visibleLines.slice(-lineLimit)
|
||||
@@ -40966,7 +41056,8 @@ function buildVisibleSnapshotReadFallback(
|
||||
limited:
|
||||
read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited,
|
||||
returnedLineCount: charBoundedTail.tail.length,
|
||||
source: 'screen'
|
||||
source: 'screen',
|
||||
...(draft ? { draft } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export type WorkerTranscriptSnapshotArchive = {
|
||||
|
||||
export type WorkerTerminalTailArchive = {
|
||||
lines: string[]
|
||||
draft?: string
|
||||
truncated: boolean
|
||||
terminalStatus: string
|
||||
warnings: string[]
|
||||
@@ -85,8 +86,13 @@ export async function captureWorkerOutputArchive(args: {
|
||||
`Output could not be preserved for Dispatch ${args.dispatchId}; the terminal was retained. ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
const redacted = redactWorkerTerminalLines(terminal.tail)
|
||||
const redacted = redactWorkerTerminalLines([
|
||||
...terminal.tail,
|
||||
...(terminal.draft ? [terminal.draft] : [])
|
||||
])
|
||||
const bounded = boundArchiveLines(redacted.lines)
|
||||
const draft = terminal.draft ? bounded.lines.at(-1) : undefined
|
||||
const lines = terminal.draft ? bounded.lines.slice(0, -1) : bounded.lines
|
||||
// Why: an exited PTY zeroes its tail immediately, so an empty capture is a distinct receipt,
|
||||
// not silent success — worker-read must be able to say why nothing is there.
|
||||
const empty = bounded.lines.every((line) => line.trim() === '')
|
||||
@@ -94,7 +100,8 @@ export async function captureWorkerOutputArchive(args: {
|
||||
kind: 'terminal_tail',
|
||||
status: empty ? 'empty' : 'captured',
|
||||
content: {
|
||||
lines: bounded.lines,
|
||||
lines,
|
||||
...(draft ? { draft } : {}),
|
||||
truncated: terminal.truncated || bounded.truncated,
|
||||
terminalStatus: terminal.status,
|
||||
warnings: empty
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
listStoredWorktreeRowsForRepo,
|
||||
resolveRepoWorktreeRows,
|
||||
resolveScopedWorktreeIdRow,
|
||||
type RepoWorktreeRowDeps
|
||||
@@ -111,6 +112,70 @@ describe('host-qualified scoped worktree resolution', () => {
|
||||
displayName: 'feature'
|
||||
})
|
||||
})
|
||||
it('uses the host-qualified metadata owner when a legacy row has another host', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
])
|
||||
const worktreeId = 'shared::/same/worktree'
|
||||
const remoteMeta = {
|
||||
displayName: 'remote workspace',
|
||||
hostId: 'ssh:builder',
|
||||
instanceId: 'remote-instance'
|
||||
} as unknown as WorktreeMeta
|
||||
deps.metaById[worktreeId] = {
|
||||
displayName: 'stale local workspace',
|
||||
hostId: 'local',
|
||||
instanceId: 'local-instance'
|
||||
} as unknown as WorktreeMeta
|
||||
;(
|
||||
deps.store as Store & {
|
||||
getWorktreeMetaForHost: () => WorktreeMeta
|
||||
}
|
||||
).getWorktreeMetaForHost = () => remoteMeta
|
||||
|
||||
const rows = await resolveRepoWorktreeRows(
|
||||
deps,
|
||||
deps.store.getRepos()[0]!,
|
||||
deps.metaById,
|
||||
new Map()
|
||||
)
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
displayName: 'remote workspace',
|
||||
hostId: 'ssh:builder',
|
||||
instanceId: 'remote-instance'
|
||||
})
|
||||
})
|
||||
|
||||
it('restores canonical-only SSH rows without leaking colliding local metadata', () => {
|
||||
const local = repo('shared', '/local/repo', { executionHostId: 'local' })
|
||||
const remote = repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
const deps = createDeps([local, remote])
|
||||
deps.metaById['shared::/local/worktree'] = {
|
||||
displayName: 'local workspace',
|
||||
hostId: 'local'
|
||||
} as WorktreeMeta
|
||||
;(
|
||||
deps.store as Store & {
|
||||
getAllWorktreeMetaForHost: () => Record<string, WorktreeMeta>
|
||||
}
|
||||
).getAllWorktreeMetaForHost = () => ({
|
||||
'shared::/remote/worktree': {
|
||||
displayName: 'remote workspace',
|
||||
hostId: 'ssh:builder'
|
||||
} as unknown as WorktreeMeta
|
||||
})
|
||||
|
||||
expect(listStoredWorktreeRowsForRepo(deps.store, remote, 2)).toEqual([
|
||||
expect.objectContaining({ path: '/remote/worktree' })
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['runtime:windows', String.raw`C:\Users\dev\orca worktree`],
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
worktreeIdComparisonKey
|
||||
} from '../../shared/worktree/id'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
readAllWorktreeMetaForHost,
|
||||
readWorktreeMetaForHost,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../persistence/host-qualified-worktree-meta'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
@@ -62,7 +67,9 @@ export function listStoredWorktreeRowsForRepo(
|
||||
): GitWorktreeInfo[] {
|
||||
const expectedHostId = getRepoExecutionHostId(repo)
|
||||
const byWorktreeId = new Map<string, GitWorktreeInfo>()
|
||||
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
|
||||
for (const [worktreeId, meta] of Object.entries(
|
||||
readAllWorktreeMetaForHost(store, expectedHostId)
|
||||
)) {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed || parsed.repoId !== repo.id) {
|
||||
continue
|
||||
@@ -134,15 +141,18 @@ export async function resolveRepoWorktreeRows(
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
// Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp.
|
||||
const existingMeta = metaById[worktreeId]
|
||||
const ownedExistingMeta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
// A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it.
|
||||
const ownedExistingMeta =
|
||||
readWorktreeMetaForHost(store, worktreeId, expectedHostId) ??
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const meta = ownedExistingMeta?.instanceId
|
||||
? ownedExistingMeta
|
||||
: ownedExistingMeta || (!existingMeta && repoOwnerCount === 1)
|
||||
? store.setWorktreeMeta(worktreeId, {})
|
||||
? writeWorktreeMetaForHost(store, worktreeId, expectedHostId, {})
|
||||
: undefined
|
||||
const merged = {
|
||||
...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName),
|
||||
hostId: repoOwnerCount === 1 ? (existingMeta?.hostId ?? expectedHostId) : expectedHostId
|
||||
hostId: meta?.hostId ?? expectedHostId
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseRemoteFederatedWorkerStartReceipt } from './orchestration-federated-attach-receipt'
|
||||
|
||||
describe('remote federated worker start receipt', () => {
|
||||
it.each([
|
||||
{ name: 'missing runtime epoch', receipt: {} },
|
||||
{ name: 'non-string runtime epoch', receipt: { runtimeEpoch: 42 } },
|
||||
{ name: 'missing worktree id', receipt: { runtimeEpoch: 'epoch_remote' } },
|
||||
{
|
||||
name: 'missing terminal handle',
|
||||
receipt: {
|
||||
runtimeEpoch: 'epoch_remote',
|
||||
worktreeId: 'worktree_remote',
|
||||
terminalHandle: undefined
|
||||
}
|
||||
}
|
||||
])('rejects a ready receipt with $name', ({ receipt }) => {
|
||||
expect(() =>
|
||||
parseRemoteFederatedWorkerStartReceipt({
|
||||
dispatchId: 'ctx_remote',
|
||||
state: 'ready',
|
||||
terminalHandle: 'term_remote',
|
||||
...receipt
|
||||
})
|
||||
).toThrow('invalid ready receipt')
|
||||
})
|
||||
|
||||
it('accepts a non-ready receipt without ready-only resources', () => {
|
||||
expect(
|
||||
parseRemoteFederatedWorkerStartReceipt({
|
||||
dispatchId: 'ctx_remote',
|
||||
state: 'outcome_unknown'
|
||||
})
|
||||
).toMatchObject({ dispatchId: 'ctx_remote', state: 'outcome_unknown' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences'
|
||||
|
||||
export type RemoteFederatedWorkerStartReceipt = {
|
||||
dispatchId: string
|
||||
state: string
|
||||
runtimeEpoch?: string
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setup?: { state: string }
|
||||
launch?: OrchestrationWorkerLaunchReceipt
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
export type ReadyRemoteFederatedWorkerStartReceipt = RemoteFederatedWorkerStartReceipt & {
|
||||
state: 'ready'
|
||||
runtimeEpoch: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
}
|
||||
|
||||
export function parseRemoteFederatedWorkerStartReceipt(
|
||||
value: unknown
|
||||
): RemoteFederatedWorkerStartReceipt {
|
||||
if (!isRecord(value) || !isNonEmptyString(value.dispatchId) || !isNonEmptyString(value.state)) {
|
||||
throw new Error('The worker server returned an invalid attachment receipt.')
|
||||
}
|
||||
if (
|
||||
value.state === 'ready' &&
|
||||
(!isNonEmptyString(value.runtimeEpoch) ||
|
||||
!isNonEmptyString(value.worktreeId) ||
|
||||
!isNonEmptyString(value.terminalHandle))
|
||||
) {
|
||||
throw new Error('The worker server returned an invalid ready receipt.')
|
||||
}
|
||||
return value as RemoteFederatedWorkerStartReceipt
|
||||
}
|
||||
|
||||
export function isReadyRemoteFederatedWorkerStartReceipt(
|
||||
receipt: RemoteFederatedWorkerStartReceipt
|
||||
): receipt is ReadyRemoteFederatedWorkerStartReceipt {
|
||||
return receipt.state === 'ready'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import { startFederatedWorker } from './orchestration-federated-worker-start'
|
||||
|
||||
describe('federated worker start receipt validation', () => {
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const database of databases.splice(0)) {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a malformed ready receipt outcome unknown without persisting resources', async () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
databases.push(db)
|
||||
const run = db.createRun({
|
||||
objective: 'federated worker',
|
||||
coordinatorHandle: 'term_coord',
|
||||
coordinatorPaneKey: 'tab_coord:leaf_coord'
|
||||
})
|
||||
const task = db.createTask({ spec: 'remote work', runId: run.id })
|
||||
vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({
|
||||
environmentId: 'environment_remote',
|
||||
name: 'remote',
|
||||
peerFingerprint: 'remote_peer'
|
||||
})
|
||||
vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockImplementation(
|
||||
async (_environmentId, method, params) => {
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
capabilities: [
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
dispatchId: (params as { dispatchId: string }).dispatchId,
|
||||
state: 'ready',
|
||||
worktreeId: 'worktree_remote',
|
||||
terminalHandle: 'term_remote'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const result = (await startFederatedWorker({
|
||||
params: {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
on: 'remote',
|
||||
worktree: 'id:worktree_remote',
|
||||
terminal: 'term_remote'
|
||||
},
|
||||
runtime,
|
||||
db,
|
||||
runId: run.id,
|
||||
task,
|
||||
orchestrationMutation: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'remote_start',
|
||||
method: 'orchestration.workerStart',
|
||||
payloadHash: 'payload'
|
||||
}
|
||||
})) as { dispatchId: string; state: string; lastError?: string }
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'outcome_unknown',
|
||||
lastError: 'The worker server returned an invalid ready receipt.'
|
||||
})
|
||||
expect(db.getFederatedDispatch(result.dispatchId)).toMatchObject({
|
||||
remote_runtime_epoch: null,
|
||||
remote_worktree_id: null,
|
||||
remote_terminal_handle: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences'
|
||||
|
||||
export function federatedUnknownReceipt(
|
||||
worker: { dispatch_id: string; state: string; stage: string; last_error: string | null },
|
||||
taskId: string,
|
||||
serverName: string,
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
): unknown {
|
||||
return {
|
||||
taskId,
|
||||
dispatchId: worker.dispatch_id,
|
||||
state: 'outcome_unknown',
|
||||
stage: worker.stage,
|
||||
server: { name: serverName },
|
||||
launch,
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
effects: [],
|
||||
residualResources: [],
|
||||
nextCommands: [
|
||||
`orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`,
|
||||
`orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json`
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,17 @@ import {
|
||||
assertWorkerLaunchPreferencesRuntimeSupported,
|
||||
assertWorkerLaunchPreferencesCreateTerminal,
|
||||
createPendingWorkerLaunchReceipt,
|
||||
resolveFederatedWorkerLaunchReceipt,
|
||||
type OrchestrationWorkerLaunchReceipt
|
||||
resolveFederatedWorkerLaunchReceipt
|
||||
} from './orchestration-worker-launch-preferences'
|
||||
import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation'
|
||||
import { resolveFederatedWorkerStartBudgets } from './orchestration-worker-start-budgets'
|
||||
import { resolveDispatchCreator } from './orchestration-dispatch-creator'
|
||||
import {
|
||||
isReadyRemoteFederatedWorkerStartReceipt,
|
||||
parseRemoteFederatedWorkerStartReceipt
|
||||
} from './orchestration-federated-attach-receipt'
|
||||
import { isWorkerStartTimeoutWithinTimerLimit } from '../../../../shared/orchestration-timing-budgets'
|
||||
import { federatedUnknownReceipt } from './orchestration-federated-worker-start-unknown-receipt'
|
||||
|
||||
export async function startFederatedWorker(args: {
|
||||
params: WorkerStartInput
|
||||
@@ -37,6 +43,12 @@ export async function startFederatedWorker(args: {
|
||||
}
|
||||
}): Promise<unknown> {
|
||||
const { params, runtime, db, task, runId, orchestrationMutation } = args
|
||||
if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.'
|
||||
)
|
||||
}
|
||||
if (!orchestrationMutation) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
@@ -58,13 +70,13 @@ export async function startFederatedWorker(args: {
|
||||
model: params.model,
|
||||
effort: params.effort
|
||||
})
|
||||
|
||||
const server = runtime.resolveOrchestrationWorkerServer(params.on as string)
|
||||
const budgets = resolveFederatedWorkerStartBudgets(params.timeoutMs)
|
||||
const status = (await runtime.callOrchestrationWorkerServer(
|
||||
server.environmentId,
|
||||
'status.get',
|
||||
undefined,
|
||||
params.timeoutMs
|
||||
budgets.preflightTimeoutMs
|
||||
)) as RuntimeStatus
|
||||
if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) {
|
||||
throw new OrchestrationError(
|
||||
@@ -112,7 +124,7 @@ export async function startFederatedWorker(args: {
|
||||
terminal: params.terminal ?? null,
|
||||
agent: params.agent ?? null,
|
||||
launch: requestedLaunch,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
timeoutMs: budgets.readinessTimeoutMs,
|
||||
setup: setupDecision,
|
||||
setupSource: createsWorktree
|
||||
? params.setup
|
||||
@@ -131,39 +143,42 @@ export async function startFederatedWorker(args: {
|
||||
})
|
||||
db.recordWorkerStage({ dispatchId: started.dispatch.id, stage: 'remote_attach_requested' })
|
||||
try {
|
||||
const remote = (await runtime.callOrchestrationWorkerServer(
|
||||
server.environmentId,
|
||||
'orchestration.federationAttachStart',
|
||||
{
|
||||
dispatchId: started.dispatch.id,
|
||||
taskId: task.id,
|
||||
taskSpec: task.spec,
|
||||
// Carry the home dispatch depth across the federation boundary so a
|
||||
// remote worker cannot be mistaken for a root when it dispatches again.
|
||||
depth: started.dispatch.depth,
|
||||
protocolVersion: federationProtocolVersion,
|
||||
worktree,
|
||||
name: params.name,
|
||||
repo: params.repo,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
comment: params.comment,
|
||||
setup: createsWorktree ? (params.setup ?? 'run') : undefined,
|
||||
setupSource: createsWorktree
|
||||
? params.setup
|
||||
? 'explicit_request'
|
||||
: 'orchestration_default'
|
||||
: undefined,
|
||||
terminal: params.terminal,
|
||||
agent: params.agent,
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
timeoutMs: params.timeoutMs,
|
||||
devMode: params.devMode
|
||||
},
|
||||
(params.timeoutMs ?? 60_000) + 15_000,
|
||||
{ orchestrationRequestId: orchestrationMutation.requestId }
|
||||
)) as RemoteStartReceipt
|
||||
const remote = parseRemoteFederatedWorkerStartReceipt(
|
||||
await runtime.callOrchestrationWorkerServer(
|
||||
server.environmentId,
|
||||
'orchestration.federationAttachStart',
|
||||
{
|
||||
dispatchId: started.dispatch.id,
|
||||
taskId: task.id,
|
||||
taskSpec: task.spec,
|
||||
// Carry the home dispatch depth across the federation boundary so a
|
||||
// remote worker cannot be mistaken for a root when it dispatches again.
|
||||
depth: started.dispatch.depth,
|
||||
protocolVersion: federationProtocolVersion,
|
||||
worktree,
|
||||
name: params.name,
|
||||
repo: params.repo,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
comment: params.comment,
|
||||
setup: createsWorktree ? (params.setup ?? 'run') : undefined,
|
||||
setupSource: createsWorktree
|
||||
? params.setup
|
||||
? 'explicit_request'
|
||||
: 'orchestration_default'
|
||||
: undefined,
|
||||
terminal: params.terminal,
|
||||
agent: params.agent,
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
timeoutMs: budgets.readinessTimeoutMs,
|
||||
devMode: params.devMode
|
||||
},
|
||||
budgets.attachDeadlineMs,
|
||||
{ orchestrationRequestId: orchestrationMutation.requestId },
|
||||
{ contractVerified: true }
|
||||
)
|
||||
)
|
||||
if (remote.dispatchId !== started.dispatch.id) {
|
||||
throw new OrchestrationError(
|
||||
'resource_server_mismatch',
|
||||
@@ -175,7 +190,7 @@ export async function startFederatedWorker(args: {
|
||||
requestedLaunch,
|
||||
remote.state === 'ready'
|
||||
)
|
||||
if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) {
|
||||
if (isReadyRemoteFederatedWorkerStartReceipt(remote)) {
|
||||
db.updateFederatedDispatchResources({
|
||||
dispatchId: started.dispatch.id,
|
||||
remoteRuntimeEpoch: remote.runtimeEpoch,
|
||||
@@ -202,7 +217,7 @@ export async function startFederatedWorker(args: {
|
||||
server: { environmentId: server.environmentId, name: server.name },
|
||||
setup: remote.setup,
|
||||
launch,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
timeoutMs: budgets.readinessTimeoutMs,
|
||||
effects: remote.effects ?? [],
|
||||
residualResources: remote.residualResources ?? []
|
||||
}
|
||||
@@ -257,20 +272,6 @@ export async function startFederatedWorker(args: {
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteStartReceipt = {
|
||||
dispatchId: string
|
||||
state: string
|
||||
runtimeEpoch: string
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setup?: { state: string }
|
||||
launch?: OrchestrationWorkerLaunchReceipt
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
function isKnownRemoteStartFailure(code: string): boolean {
|
||||
return [
|
||||
'invalid_argument',
|
||||
@@ -280,27 +281,3 @@ function isKnownRemoteStartFailure(code: string): boolean {
|
||||
'capability_unsupported'
|
||||
].includes(code)
|
||||
}
|
||||
|
||||
function federatedUnknownReceipt(
|
||||
worker: { dispatch_id: string; state: string; stage: string; last_error: string | null },
|
||||
taskId: string,
|
||||
serverName: string,
|
||||
launch: OrchestrationWorkerLaunchReceipt
|
||||
): unknown {
|
||||
return {
|
||||
taskId,
|
||||
dispatchId: worker.dispatch_id,
|
||||
state: 'outcome_unknown',
|
||||
stage: worker.stage,
|
||||
server: { name: serverName },
|
||||
launch,
|
||||
failedStage: worker.stage,
|
||||
lastError: worker.last_error,
|
||||
effects: [],
|
||||
residualResources: [],
|
||||
nextCommands: [
|
||||
`orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`,
|
||||
`orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
import { FederationAttachStartParams } from './orchestration-federation-start-schema'
|
||||
import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt'
|
||||
import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation'
|
||||
import {
|
||||
isWorkerStartTimeoutWithinTimerLimit,
|
||||
resolveWorkerStartReadinessTimeoutMs
|
||||
} from '../../../../shared/orchestration-timing-budgets'
|
||||
|
||||
export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -30,6 +34,13 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
'Federated worker attachment requires a durable retry request.'
|
||||
)
|
||||
}
|
||||
if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.'
|
||||
)
|
||||
}
|
||||
const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs)
|
||||
if (params.worktree === 'current' || params.worktree === 'new-child') {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
@@ -198,7 +209,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
failedStage = 'agent_readiness'
|
||||
const wait = await runtime.waitForTerminal(terminalHandle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: params.timeoutMs ?? 60_000
|
||||
timeoutMs: readinessTimeoutMs
|
||||
})
|
||||
persistFederatedSetupWaitOutcome({ ...setupStage, wait })
|
||||
if (!wait.satisfied) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
import { startFederatedWorker } from './orchestration-federated-worker-start'
|
||||
@@ -246,4 +248,83 @@ describe('orchestration migration behavior', () => {
|
||||
expect(db.getTask(task.id)?.status).toBe('ready')
|
||||
expect(db.getDispatchContext(task.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('runs one real contract preflight before federated attach', async () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const run = db.createRun({
|
||||
objective: 'federated worker',
|
||||
coordinatorHandle: 'term_coord',
|
||||
coordinatorPaneKey: 'tab_coord:tab'
|
||||
})
|
||||
const task = db.createTask({ spec: 'remote work', runId: run.id })
|
||||
databases.push(db)
|
||||
|
||||
const calls: { method: string; params: unknown }[] = []
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
orchestrationEnvironmentTransport: {
|
||||
resolve: () => ({
|
||||
environmentId: 'environment_windows',
|
||||
name: 'windows',
|
||||
peerFingerprint: 'windows_peer'
|
||||
}),
|
||||
call: async (_selector, method, params) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
...runtime.getStatus(),
|
||||
capabilities: [
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY
|
||||
]
|
||||
},
|
||||
_meta: { runtimeId: runtime.getRuntimeId() }
|
||||
} satisfies RuntimeRpcResponse<unknown>
|
||||
}
|
||||
return {
|
||||
id: 'attach',
|
||||
ok: true,
|
||||
result: {
|
||||
dispatchId: (params as { dispatchId: string }).dispatchId,
|
||||
state: 'outcome_unknown',
|
||||
runtimeEpoch: 'worker-runtime'
|
||||
},
|
||||
_meta: { runtimeId: runtime.getRuntimeId() }
|
||||
} satisfies RuntimeRpcResponse<unknown>
|
||||
}
|
||||
} satisfies OrchestrationEnvironmentTransport
|
||||
})
|
||||
runtime.setOrchestrationDb(db)
|
||||
|
||||
const result = await startFederatedWorker({
|
||||
params: {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
repo: 'id:windows-repo',
|
||||
name: 'remote-work',
|
||||
agent: 'codex'
|
||||
},
|
||||
runtime,
|
||||
db,
|
||||
runId: run.id,
|
||||
task,
|
||||
orchestrationMutation: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'remote_start',
|
||||
method: 'orchestration.workerStart',
|
||||
payloadHash: 'payload'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ state: 'outcome_unknown' })
|
||||
expect(calls.map(({ method }) => method)).toEqual([
|
||||
'status.get',
|
||||
'orchestration.federationAttachStart'
|
||||
])
|
||||
expect(calls.filter(({ method }) => method === 'status.get')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,6 +206,7 @@ function readArchivedTerminalTail(
|
||||
handle: args.resource.terminal_handle,
|
||||
status: 'exited',
|
||||
tail,
|
||||
...(!cursor && content.draft ? { draft: content.draft } : {}),
|
||||
truncated: content.truncated,
|
||||
nextCursor,
|
||||
returnedLineCount: tail.length
|
||||
|
||||
@@ -157,6 +157,31 @@ describe('exact orchestration worker output', () => {
|
||||
expect(JSON.stringify(result)).not.toContain(capability)
|
||||
})
|
||||
|
||||
it('redacts dispatch capabilities from terminal composer drafts', async () => {
|
||||
const capability = `dcap_${'A'.repeat(43)}`
|
||||
readTerminal.mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['safe output'],
|
||||
draft: `send --dispatch-capability ${capability}`,
|
||||
truncated: false,
|
||||
nextCursor: '9'
|
||||
})
|
||||
providerSession = null
|
||||
|
||||
const result = await read()
|
||||
|
||||
expect(result).toMatchObject({
|
||||
source: 'terminal',
|
||||
terminal: {
|
||||
tail: ['safe output'],
|
||||
draft: 'send --dispatch-capability [dispatch capability redacted]'
|
||||
},
|
||||
warnings: ['Dispatch capability tokens were redacted from terminal output.']
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain(capability)
|
||||
})
|
||||
|
||||
it('rejects an old cursor after the exact provider session changes', async () => {
|
||||
const initial = await read()
|
||||
if (initial.source !== 'transcript') {
|
||||
|
||||
@@ -135,7 +135,12 @@ async function readTerminalOutput(
|
||||
cursor: cursor?.source === 'terminal' ? cursor.position : undefined,
|
||||
limit: args.limit
|
||||
})
|
||||
const redactedTerminal = redactWorkerTerminalLines(terminal.tail)
|
||||
const redactedTerminal = redactWorkerTerminalLines([
|
||||
...terminal.tail,
|
||||
...(terminal.draft ? [terminal.draft] : [])
|
||||
])
|
||||
const redactedTail = redactedTerminal.lines.slice(0, terminal.tail.length)
|
||||
const redactedDraft = terminal.draft ? redactedTerminal.lines.at(-1) : undefined
|
||||
const position =
|
||||
terminal.nextCursor !== null && /^\d+$/.test(terminal.nextCursor)
|
||||
? Number.parseInt(terminal.nextCursor, 10)
|
||||
@@ -148,7 +153,11 @@ async function readTerminalOutput(
|
||||
dispatchId: args.dispatchId,
|
||||
source: 'terminal',
|
||||
sourceIdentity,
|
||||
terminal: { ...terminal, tail: redactedTerminal.lines },
|
||||
terminal: {
|
||||
...terminal,
|
||||
tail: redactedTail,
|
||||
...(redactedDraft ? { draft: redactedDraft } : {})
|
||||
},
|
||||
cursor: nextCursor,
|
||||
status: {
|
||||
worker: args.workerState,
|
||||
|
||||
@@ -587,6 +587,7 @@ describe('orchestration worker release', () => {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'],
|
||||
draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`,
|
||||
truncated: false,
|
||||
nextCursor: '3'
|
||||
})
|
||||
@@ -596,19 +597,24 @@ describe('orchestration worker release', () => {
|
||||
const page1 = (await call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
limit: 2
|
||||
})) as { archived?: boolean; terminal: { tail: string[] }; cursor: string | null }
|
||||
expect(page1.archived).toBe(true)
|
||||
})) as {
|
||||
archived?: boolean
|
||||
terminal: { tail: string[]; draft?: string }
|
||||
cursor: string | null
|
||||
}
|
||||
expect(page1.terminal.tail).toEqual([
|
||||
'first line',
|
||||
'capability [dispatch capability redacted] leaked'
|
||||
])
|
||||
expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]')
|
||||
expect(page1.cursor).not.toBeNull()
|
||||
|
||||
const page2 = (await call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
cursor: page1.cursor as string
|
||||
})) as { terminal: { tail: string[] }; cursor: string | null }
|
||||
})) as { terminal: { tail: string[]; draft?: string }; cursor: string | null }
|
||||
expect(page2.terminal.tail).toEqual(['last line'])
|
||||
expect(page2.terminal.draft).toBeUndefined()
|
||||
expect(page2.cursor).toBeNull()
|
||||
// The live terminal is never consulted after release.
|
||||
expect(runtime.readTerminal).not.toHaveBeenCalled()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../../../shared/timer-delay'
|
||||
import {
|
||||
ORCHESTRATION_READINESS_TIMEOUT_MS,
|
||||
ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS
|
||||
} from '../../../../shared/orchestration-timing-budgets'
|
||||
import { resolveFederatedWorkerStartBudgets } from './orchestration-worker-start-budgets'
|
||||
|
||||
describe('worker-start transport budgets', () => {
|
||||
it('keeps the exact maximum derived timeout representable', () => {
|
||||
const timeoutMs = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS
|
||||
const budgets = resolveFederatedWorkerStartBudgets(timeoutMs, 1_000)
|
||||
expect(budgets.outerDeadlineMs).toBe(1_000 + MAX_TIMER_DELAY_MS)
|
||||
})
|
||||
|
||||
it('does not silently shorten an overflowing request', () => {
|
||||
const timeoutMs = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + 1
|
||||
expect(() => resolveFederatedWorkerStartBudgets(timeoutMs, 1_000)).toThrow(
|
||||
'derived timeout must fit'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes non-positive requests to the ordinary readiness default', () => {
|
||||
const expectedOuterDeadline =
|
||||
1_000 + ORCHESTRATION_READINESS_TIMEOUT_MS + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS
|
||||
expect(resolveFederatedWorkerStartBudgets(0, 1_000).readinessTimeoutMs).toBe(
|
||||
ORCHESTRATION_READINESS_TIMEOUT_MS
|
||||
)
|
||||
expect(resolveFederatedWorkerStartBudgets(0, 1_000).outerDeadlineMs).toBe(expectedOuterDeadline)
|
||||
expect(resolveFederatedWorkerStartBudgets(-1, 1_000).outerDeadlineMs).toBe(
|
||||
expectedOuterDeadline
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS,
|
||||
isWorkerStartTimeoutWithinTimerLimit,
|
||||
resolveFederationAttachDeadlineMs,
|
||||
resolveWorkerStartReadinessTimeoutMs,
|
||||
resolveWorkerStartClientTimeoutMs
|
||||
} from '../../../../shared/orchestration-timing-budgets'
|
||||
|
||||
export function resolveFederatedWorkerStartBudgets(
|
||||
timeoutMs: number | undefined,
|
||||
nowMs = Date.now()
|
||||
) {
|
||||
if (!isWorkerStartTimeoutWithinTimerLimit(timeoutMs)) {
|
||||
throw new Error(
|
||||
'--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.'
|
||||
)
|
||||
}
|
||||
// Normalize non-positive/invalid values exactly as the client does so local
|
||||
// and federated transports share the ordinary default contract.
|
||||
const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(timeoutMs)
|
||||
const outerDeadlineMs = nowMs + resolveWorkerStartClientTimeoutMs(readinessTimeoutMs)
|
||||
return {
|
||||
readinessTimeoutMs,
|
||||
outerDeadlineMs,
|
||||
preflightTimeoutMs: ORCHESTRATION_CONTRACT_PREFLIGHT_TIMEOUT_MS,
|
||||
attachDeadlineMs: resolveFederationAttachDeadlineMs({
|
||||
readinessTimeoutMs,
|
||||
outerDeadlineMs,
|
||||
nowMs
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt
|
||||
import { prepareLocalWorkerStart } from './orchestration-worker-start-validation'
|
||||
import { resolveDispatchCreator } from './orchestration-dispatch-creator'
|
||||
import { resolveOrchestrationCaller } from './orchestration-run-scope'
|
||||
import {
|
||||
isWorkerStartTimeoutWithinTimerLimit,
|
||||
resolveWorkerStartReadinessTimeoutMs
|
||||
} from '../../../../shared/orchestration-timing-budgets'
|
||||
|
||||
export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -31,6 +35,13 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
params,
|
||||
{ runtime, orchestrationMutation, orchestrationCompatibilityEvidence }
|
||||
) => {
|
||||
if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
`--timeout-ms is too large for worker-start transport grace; the derived timeout must fit within the timer limit.`
|
||||
)
|
||||
}
|
||||
const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs)
|
||||
const db = runtime.getOrchestrationDb()
|
||||
// Why: worker-start was the only Run-scoped verb that skipped this, so a
|
||||
// declared --from could name someone else's pane and inherit their depth.
|
||||
@@ -111,7 +122,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
terminal: params.terminal ?? null,
|
||||
agent: agent ?? null,
|
||||
launch: launch.receipt,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
timeoutMs: readinessTimeoutMs,
|
||||
setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable',
|
||||
setupSource: createsWorktree
|
||||
? params.setup
|
||||
@@ -208,7 +219,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
failedStage = 'agent_readiness'
|
||||
const wait = await runtime.waitForTerminal(terminalHandle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: params.timeoutMs ?? 60_000
|
||||
timeoutMs: readinessTimeoutMs
|
||||
})
|
||||
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
|
||||
if (!wait.satisfied) {
|
||||
@@ -268,7 +279,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
stage: worker.stage,
|
||||
setup: setupReceipt,
|
||||
launch: launch.receipt,
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
timeoutMs: readinessTimeoutMs,
|
||||
effects,
|
||||
residualResources: [],
|
||||
...(terminalRevealWarning ? { warning: terminalRevealWarning } : {})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import { classifyRuntimeLongPoll, OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import {
|
||||
sendRequest,
|
||||
openFramedSession,
|
||||
@@ -33,6 +33,36 @@ vi.mock('../git/worktree', () => {
|
||||
})
|
||||
|
||||
describe('OrcaRuntimeRpcServer', () => {
|
||||
it('classifies worker-start as a keepalive-backed long poll', () => {
|
||||
expect(
|
||||
classifyRuntimeLongPoll({
|
||||
id: 'req_worker_start',
|
||||
authToken: 'token',
|
||||
method: 'orchestration.workerStart',
|
||||
params: { task: 'task_1', timeoutMs: 60_000 }
|
||||
})
|
||||
).toBe('wait')
|
||||
})
|
||||
|
||||
it('keeps agent-prompt submission sockets alive during verification', () => {
|
||||
expect(
|
||||
classifyRuntimeLongPoll({
|
||||
id: 'req_prompt',
|
||||
authToken: 'token',
|
||||
method: 'terminal.send',
|
||||
params: { agentPrompt: true }
|
||||
})
|
||||
).toBe('wait')
|
||||
expect(
|
||||
classifyRuntimeLongPoll({
|
||||
id: 'req_direct',
|
||||
authToken: 'token',
|
||||
method: 'terminal.send',
|
||||
params: { agentPrompt: false }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects oversized RPC frames instead of buffering them indefinitely', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
@@ -74,6 +104,45 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
// Exercise the real socket (not a mock) so we catch buffer/flush regressions
|
||||
// that a unit-level test would miss.
|
||||
describe('long-poll transport (§3.1)', () => {
|
||||
it('emits keepalives while orchestration.workerStart blocks', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
keepaliveIntervalMs: 30
|
||||
})
|
||||
const dispatch = server['dispatcher']
|
||||
vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => {
|
||||
await sleep(120)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { dispatch: { id: 'dispatch_1' } },
|
||||
_meta: { runtimeId: runtime.getRuntimeId() }
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_worker_start',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'orchestration.workerStart',
|
||||
params: { task: 'task_1', timeoutMs: 60_000 }
|
||||
})
|
||||
await session.done
|
||||
|
||||
expect(
|
||||
session.frames.filter((frame) => frame._keepalive === true).length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits keepalive frames while a check --wait handler blocks', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
@@ -242,6 +311,45 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('emits keepalive frames while agent-prompt verification blocks', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
keepaliveIntervalMs: 30
|
||||
})
|
||||
const dispatch = server['dispatcher']
|
||||
vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => {
|
||||
await sleep(120)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: runtime.getRuntimeId() }
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_prompt',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.send',
|
||||
params: { agentPrompt: true }
|
||||
})
|
||||
await session.done
|
||||
|
||||
expect(
|
||||
session.frames.filter((frame) => frame._keepalive === true).length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases terminal.wait long-poll slot when the client closes mid-wait', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
@@ -466,12 +466,28 @@ export type RuntimeLongPollClass = 'ask' | 'browser-host' | 'wait'
|
||||
|
||||
// Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1.
|
||||
export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollClass | null {
|
||||
// Worker start waits for readiness and then verifies the submitted prompt;
|
||||
// the complete operation can run for 90–110s. Keep every local transport
|
||||
// (Unix sockets and Windows named pipes) alive for that long poll.
|
||||
if (request.method === 'orchestration.workerStart') {
|
||||
return 'wait'
|
||||
}
|
||||
if (request.method === 'browser.clientHost.attach') {
|
||||
return 'browser-host'
|
||||
}
|
||||
if (request.method === 'terminal.wait') {
|
||||
return 'wait'
|
||||
}
|
||||
// Agent-prompt submission waits for the PTY's lifecycle transition (up to
|
||||
// the verification budget); keep the local socket alive for that wait.
|
||||
if (
|
||||
request.method === 'terminal.send' &&
|
||||
typeof request.params === 'object' &&
|
||||
request.params !== null &&
|
||||
(request.params as { agentPrompt?: unknown }).agentPrompt === true
|
||||
) {
|
||||
return 'wait'
|
||||
}
|
||||
// Why: orchestration.ask blocks unconditionally (default 600 s) holding the
|
||||
// RPC open until a reply lands or the deadline passes, so it needs the same
|
||||
// keepalive as check --wait or the 30 s socket idle timer tears it down. It
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user