diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 089242f8..c883b1fe 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -30,6 +30,23 @@ type MessagesResponse = { messages: MessageEntry[] taskNotifications?: AgentTaskNotification[] } +export type SessionHistoryPage = MessagesResponse & { + page?: { + nextCursor: string | null + hasMore: boolean + historyComplete: boolean + sourceVersion: string + scannedBytes: number + omittedOversizedEntries: number + } +} +export type SessionHistoryRecovery = MessagesResponse & { + sourceVersion: string + status: 'ready' | 'incomplete' + completeness?: { goal: boolean; todos: boolean; activity: boolean; usage: boolean } + tokenUsage: { input_tokens: number; output_tokens: number; cache_read_tokens?: number; cache_creation_tokens?: number } | null + omittedRecords: number +} type CreateSessionResponse = { sessionId: string; workDir?: string } export type BatchDeleteSessionsResponse = { ok: boolean @@ -410,8 +427,18 @@ export const sessionsApi = { return api.get(`/api/sessions/project-history?${query.toString()}`, options) }, - getMessages(sessionId: string) { - return api.get(`/api/sessions/${sessionId}/messages`) + getMessages(sessionId: string, options?: ApiRequestOptions) { + return api.get(`/api/sessions/${sessionId}/messages`, options) + }, + + getHistoryPage(sessionId: string, page?: { cursor?: string }, options?: ApiRequestOptions) { + const query = new URLSearchParams() + if (page?.cursor) query.set('cursor', page.cursor) + return api.get(`/api/sessions/${sessionId}/messages${query.size ? `?${query}` : ''}`, options) + }, + + getHistoryRecovery(sessionId: string, options?: ApiRequestOptions) { + return api.get(`/api/sessions/${sessionId}/history-recovery`, options) }, getSummary(sessionId: string, options?: ApiRequestOptions) { @@ -422,8 +449,13 @@ export const sessionsApi = { return api.get(`/api/sessions/${sessionId}/chat/status`, { signal }) }, - getTrace(sessionId: string) { - return api.get(`/api/sessions/${sessionId}/trace`) + getTrace(sessionId: string, options?: ApiRequestOptions, page?: { offset?: number; revisionToken?: string; scanCursor?: string }) { + const query = new URLSearchParams() + if (page?.offset) query.set('offset', String(page.offset)) + if (page?.revisionToken) query.set('revisionToken', page.revisionToken) + if (page?.scanCursor) query.set('scanCursor', page.scanCursor) + const suffix = query.size ? `?${query}` : '' + return api.get(`/api/sessions/${sessionId}/trace${suffix}`, options) }, getTraceCall(sessionId: string, callId: string) { diff --git a/desktop/src/api/subagents.ts b/desktop/src/api/subagents.ts index ea86fe69..c2b0fd93 100644 --- a/desktop/src/api/subagents.ts +++ b/desktop/src/api/subagents.ts @@ -35,6 +35,8 @@ export type SubagentRunResponse = { /** Terminal events with the same fragment-scoped ids as activityMessages. */ activityTaskNotifications?: AgentTaskNotification[] truncated: boolean + historyComplete?: boolean + activityComplete?: boolean updatedAt?: string source: SubagentRunSource /** diff --git a/desktop/src/api/traces.ts b/desktop/src/api/traces.ts index b4a2eaf0..a69730ed 100644 --- a/desktop/src/api/traces.ts +++ b/desktop/src/api/traces.ts @@ -1,4 +1,4 @@ -import { api } from './client' +import { api, type ApiRequestOptions } from './client' import type { TraceCaptureSettings, TraceSessionDeleteResult, TraceSessionList } from '../types/trace' export type TraceSessionRevision = { @@ -10,22 +10,23 @@ export type TraceSessionRevision = { } export const tracesApi = { - list(options?: { limit?: number; offset?: number; query?: string }) { + list(options?: { limit?: number; offset?: number; query?: string }, requestOptions?: ApiRequestOptions) { const params = new URLSearchParams() if (options?.limit !== undefined) params.set('limit', String(options.limit)) if (options?.offset !== undefined) params.set('offset', String(options.offset)) if (options?.query) params.set('q', options.query) const suffix = params.toString() ? `?${params}` : '' - return api.get(`/api/traces${suffix}`) + return api.get(`/api/traces${suffix}`, requestOptions) }, - getRevision(sessionId: string, sinceRevision?: number, sinceRevisionToken?: string) { + getRevision(sessionId: string, sinceRevision?: number, sinceRevisionToken?: string, options?: ApiRequestOptions) { const params = new URLSearchParams() if (sinceRevision !== undefined) params.set('sinceRevision', String(sinceRevision)) if (sinceRevisionToken !== undefined) params.set('sinceRevisionToken', sinceRevisionToken) const suffix = params.toString() ? `?${params}` : '' return api.get( `/api/traces/${encodeURIComponent(sessionId)}/revision${suffix}`, + options, ) }, diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 9995ffec..86bed82f 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -53,6 +53,8 @@ function makeSessionState(overrides: Partial = {}): PerSessionS messages: [], chatState: 'idle', connectionState: 'connected', + historyStatus: 'ready', + historyHydrated: true, streamingText: '', streamingToolInput: '', activeToolUseId: null, @@ -338,6 +340,71 @@ describe('MessageList nested tool calls', () => { vi.useRealTimers() }) + it('does not load checkpoints from stale cached rows before history hydration completes', async () => { + const messages: UIMessage[] = [ + { id: 'cached-user', type: 'user_text', content: 'Cached prompt', timestamp: 1 }, + { id: 'cached-reply', type: 'assistant_text', content: 'Cached reply', timestamp: 2 }, + ] + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ + messages, historyStatus: 'idle', historyHydrated: false, historyWindowed: false, + }) } }) + render() + expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled() + act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ + messages, historyStatus: 'loading', historyHydrated: false, historyWindowed: false, + }) } })) + expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled() + act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ + messages, historyStatus: 'ready', historyHydrated: false, historyWindowed: false, + }) } })) + expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled() + const partialPage = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } + act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ + messages, historyWindowed: false, historyPage: partialPage, + }) } })) + expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled() + act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ + messages, historyWindowed: true, historyPage: partialPage, + }) } })) + expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Load undo checkpoints' })) + await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1)) + }) + + it('defers checkpoint transcript work until explicitly requested for a bounded history window', async () => { + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ historyWindowed: true, messages: [ + { id: 'user-1', transcriptMessageId: 'user-1', type: 'user_text', content: 'User prompt', timestamp: 1 }, + { id: 'reply', transcriptMessageId: 'reply', type: 'assistant_text', content: 'Assistant reply', timestamp: 2 }, + ] }) } }) + vi.mocked(sessionsApi.getTurnCheckpoints).mockResolvedValue({ checkpoints: [{ + target: { targetUserMessageId: 'a-different-old-user', userMessageIndex: 0, userMessageCount: 200 }, + code: { available: true, filesChanged: ['wrong.ts'], insertions: 1, deletions: 0 }, + }] }) + render() + expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Load undo checkpoints' })) + await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Load undo checkpoints' }).hasAttribute('disabled')).toBe(false)) + expect(screen.queryByRole('region', { name: 'Turn changed files' })).toBeNull() + }) + + it('pages through bounded history without replacing live state and returns to the latest window', async () => { + const page = { nextCursor: 'older-cursor', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ historyWindowed: true, historyPage: page, messages: [{ id: 'live', type: 'assistant_text', content: 'current live message', timestamp: 1 }] }) } }) + const getPage = vi.spyOn(sessionsApi, 'getHistoryPage') + .mockResolvedValueOnce({ messages: [{ id: 'old', type: 'assistant', content: 'older page message', timestamp: '2020-01-01T00:00:00Z' }], page: { ...page, nextCursor: null, hasMore: false } }) + .mockResolvedValueOnce({ messages: [{ id: 'latest', type: 'assistant', content: 'latest page message', timestamp: '2026-01-01T00:00:00Z' }], page: { ...page, historyComplete: true } }) + render() + expect(screen.getByTestId('history-window-notice')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Older messages' })) + expect(await screen.findByText('older page message')).toBeTruthy() + expect(useChatStore.getState().sessions[ACTIVE_TAB]?.messages[0]?.id).toBe('live') + expect(getPage).toHaveBeenCalledWith(ACTIVE_TAB, { cursor: 'older-cursor' }, expect.objectContaining({ signal: expect.any(AbortSignal) })) + fireEvent.click(screen.getByRole('button', { name: 'Latest messages' })) + expect(await screen.findByText('latest page message')).toBeTruthy() + expect(useChatStore.getState().sessions[ACTIVE_TAB]?.historyBrowseMessages).toBeUndefined() + }) + it('windows long transcripts instead of mounting every historical message at once', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 22250f1e..bba8cf03 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -2255,6 +2255,7 @@ export function MessageList({ const branchSession = useSessionStore((s) => s.branchSession) const stopGeneration = useChatStore((s) => s.stopGeneration) const reloadHistory = useChatStore((s) => s.reloadHistory) + const loadOlderHistory = useChatStore((s) => s.loadOlderHistory) const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) const memberSessionTeam = useTeamStore((s) => ( resolvedSessionId ? s.getTeamByMemberSessionId(resolvedSessionId) : null @@ -2294,12 +2295,17 @@ export function MessageList({ )) }, [teamSnapshot]) const addToast = useUIStore((s) => s.addToast) - const messages = sessionState?.messages ?? EMPTY_MESSAGES + const messages = sessionState?.historyBrowseMessages ?? sessionState?.messages ?? EMPTY_MESSAGES + const historyWindowKey = `${resolvedSessionId}:${messages[0]?.id ?? ''}:${messages.at(-1)?.id ?? ''}` + const checkpointHistoryReady = sessionState?.historyStatus === 'ready' && sessionState?.historyHydrated === true + const checkpointRequiresRequest = Boolean(sessionState?.historyWindowed || sessionState?.historyPage?.historyComplete === false) + const checkpointWindowKey = checkpointRequiresRequest ? historyWindowKey : '' + const [requestedWindowCheckpoints, setRequestedWindowCheckpoints] = useState<{ key: string; revision: number } | null>(null) const chatState = sessionState?.chatState ?? 'idle' const isPreparingTurn = Boolean(sessionState?.isPreparingTurn) const historyMutationEpoch = sessionState?.historyMutationEpoch ?? 0 - const streamingText = sessionState?.streamingText ?? '' - const streamingToolInput = sessionState?.streamingToolInput ?? '' + const streamingText = sessionState?.historyViewingOlder ? '' : sessionState?.streamingText ?? '' + const streamingToolInput = sessionState?.historyViewingOlder ? '' : sessionState?.streamingToolInput ?? '' const activeThinkingId = sessionState?.activeThinkingId ?? null const hasApiRetry = Boolean(sessionState?.apiRetry) const hasStreamingFallback = Boolean(sessionState?.streamingFallback) @@ -3015,7 +3021,8 @@ export function MessageList({ }, [renderItemKeys]) useEffect(() => { - if (!resolvedSessionId || completedTurnTargets.length === 0 || isDirectAgentSession) { + if (!resolvedSessionId || !checkpointHistoryReady || completedTurnTargets.length === 0 || isDirectAgentSession || + (checkpointRequiresRequest && requestedWindowCheckpoints?.key !== historyWindowKey)) { setTurnChangeCards([]) setTurnChangeLoadError(null) setIsLoadingTurnChangeCards(false) @@ -3056,7 +3063,7 @@ export function MessageList({ normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => { const target = targetByMessageId.get(checkpoint.target.targetUserMessageId) ?? - targetByUserMessageIndex.get(checkpoint.target.userMessageIndex) + (sessionState?.historyWindowed ? undefined : targetByUserMessageIndex.get(checkpoint.target.userMessageIndex)) if (!target) { return [] } @@ -3084,7 +3091,7 @@ export function MessageList({ cancelled = true controller.abort() } - }, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId]) + }, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId, sessionState?.historyWindowed, checkpointHistoryReady, checkpointRequiresRequest, checkpointWindowKey, requestedWindowCheckpoints]) const handleUndoCurrentTurn = useCallback(async (mode: SessionRewindMode = 'both') => { if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) return @@ -3107,7 +3114,7 @@ export function MessageList({ const result = await sessionsApi.rewind(resolvedSessionId, { targetUserMessageId: checkpointTarget.targetUserMessageId, userMessageIndex: checkpointTarget.userMessageIndex, - expectedContent: target.expectedContent, + ...(sessionState?.historyWindowed ? {} : { expectedContent: target.expectedContent }), mode, }) @@ -3616,6 +3623,27 @@ export function MessageList({ // the agent-teams workbench appeared. className="mx-auto max-w-[900px]" > + {sessionState?.historyWindowed || sessionState?.historyPage?.hasMore ? ( +
+ {t('chat.history.windowNotice')} + {sessionState.historyPage?.nextCursor ? ( + + ) : null} + + {completedTurnTargets.length > 0 ? ( + + ) : null} + {sessionState.historyRecoveryStatus === 'loading' ? {t('chat.history.recovering')} : null} + {sessionState.historyRecoveryStatus === 'incomplete' || sessionState.historyRecoveryStatus === 'error' ? {t('chat.history.recoveryIncomplete')} : null} + {sessionState.historyError ? {sessionState.historyError} : null} +
+ ) : null} {virtualTranscriptWindow.enabled ? ( ) : null} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ea5a1f9a..1d5430cc 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1,4 +1,17 @@ export const en = { + "chat.history.loadCheckpoints": "Load undo checkpoints", + "chat.history.windowNotice": "Only a bounded history window is shown. Long content is shortened; full history remains on disk.", + "chat.history.older": "Older messages", + "chat.history.latest": "Latest messages", + "chat.history.recovering": "Restoring session state…", + "chat.history.recoveryIncomplete": "Some session state could not be restored.", + "trace.windowNotice": "Transcript and trace use bounded pages.", + "trace.windowLimited": "Some records exceed the viewing limit; the original file is unchanged.", + "trace.windowFirst": "First page / Live", + "trace.windowPrevious": "Previous page", + "trace.windowNext": "Next page", + "trace.windowScanNext": "Scan next segment", + "extensions.myPlugins": "My plugins", "extensions.mySkills": "My skills", "extensions.browse": "Back to browse", diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index dc779a80..33b3d83c 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1,7 +1,19 @@ -import type { - TranslationKey } from './en' +import type { TranslationKey } from './en' export const jp: Record = { + "chat.history.loadCheckpoints": "取り消しチェックポイントを読み込む", + "chat.history.windowNotice": "履歴の一部を表示しています。長い内容は短縮され、完全な履歴はディスクに保存されています。", + "chat.history.older": "以前のメッセージ", + "chat.history.latest": "最新のメッセージ", + "chat.history.recovering": "セッション状態を復元中…", + "chat.history.recoveryIncomplete": "一部のセッション状態を復元できませんでした。", + "trace.windowNotice": "会話とトレースは制限付きページで読み込まれます。", + "trace.windowLimited": "表示上限を超えた記録があります。元のファイルは変更されません。", + "trace.windowFirst": "先頭 / ライブ", + "trace.windowPrevious": "前のページ", + "trace.windowNext": "次のページ", + "trace.windowScanNext": "次の区間を読み込む", + "extensions.myPlugins": "マイプラグイン", "extensions.mySkills": "マイスキル", "extensions.browse": "一覧に戻る", diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 0aeef63e..89e0bdb1 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1,7 +1,19 @@ -import type { - TranslationKey } from './en' +import type { TranslationKey } from './en' export const kr: Record = { + "chat.history.loadCheckpoints": "실행 취소 체크포인트 불러오기", + "chat.history.windowNotice": "일부 기록만 표시합니다. 긴 내용은 축약되며 전체 기록은 디스크에 보관됩니다.", + "chat.history.older": "이전 메시지", + "chat.history.latest": "최신 메시지", + "chat.history.recovering": "세션 상태 복원 중…", + "chat.history.recoveryIncomplete": "일부 세션 상태를 복원하지 못했습니다.", + "trace.windowNotice": "대화와 추적은 제한된 페이지로 불러옵니다.", + "trace.windowLimited": "일부 기록이 표시 한도를 초과했습니다. 원본 파일은 유지됩니다.", + "trace.windowFirst": "처음 / 실시간", + "trace.windowPrevious": "이전 페이지", + "trace.windowNext": "다음 페이지", + "trace.windowScanNext": "다음 구간 읽기", + "extensions.myPlugins": "내 플러그인", "extensions.mySkills": "내 스킬", "extensions.browse": "둘러보기로 돌아가기", diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 34dfac01..2778e922 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1,7 +1,19 @@ -import type { - TranslationKey } from './en' +import type { TranslationKey } from './en' export const zh: Record = { + "chat.history.loadCheckpoints": "載入復原檢查點", + "chat.history.windowNotice": "目前僅顯示部分歷史,較長內容已縮短;完整記錄仍儲存在磁碟。", + "chat.history.older": "更早訊息", + "chat.history.latest": "最新訊息", + "chat.history.recovering": "正在還原工作階段狀態…", + "chat.history.recoveryIncomplete": "部分工作階段狀態尚未還原。", + "trace.windowNotice": "聊天記錄與追蹤以有限視窗載入。", + "trace.windowLimited": "部分記錄超過檢視上限,原始檔案保持完整。", + "trace.windowFirst": "首頁 / 即時", + "trace.windowPrevious": "上一頁", + "trace.windowNext": "下一頁", + "trace.windowScanNext": "掃描下一段", + "extensions.myPlugins": "我的外掛", "extensions.mySkills": "我的技能", "extensions.browse": "返回瀏覽", diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e81ce404..99678d7a 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1,7 +1,19 @@ -import type { - TranslationKey } from './en' +import type { TranslationKey } from './en' export const zh: Record = { + "chat.history.loadCheckpoints": "加载撤回检查点", + "chat.history.windowNotice": "当前仅显示部分历史,较长内容已缩短;完整记录仍保存在磁盘。", + "chat.history.older": "更早消息", + "chat.history.latest": "最新消息", + "chat.history.recovering": "正在恢复会话状态…", + "chat.history.recoveryIncomplete": "部分会话状态尚未恢复。", + "trace.windowNotice": "聊天记录与追踪按有限窗口加载。", + "trace.windowLimited": "部分记录超出查看上限,原始文件保持完整。", + "trace.windowFirst": "首页 / 实时", + "trace.windowPrevious": "上一页", + "trace.windowNext": "下一页", + "trace.windowScanNext": "扫描下一段", + "extensions.myPlugins": "我的插件", "extensions.mySkills": "我的技能", "extensions.browse": "返回浏览", diff --git a/desktop/src/lib/chatHistoryBudget.test.ts b/desktop/src/lib/chatHistoryBudget.test.ts new file mode 100644 index 00000000..f672a1b5 --- /dev/null +++ b/desktop/src/lib/chatHistoryBudget.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { boundActivityText, boundChatHistory, CHAT_HISTORY_MAX_ROWS } from './chatHistoryBudget' +import type { UIMessage } from '../types/chat' + +function text(id: number, content = 'message'): UIMessage { + return { id: String(id), type: 'assistant_text', timestamp: id, content } +} + +describe('chat history retention', () => { + it('bounds many small rows and keeps the recent window', () => { + const result = boundChatHistory(Array.from({ length: 2000 }, (_, index) => text(index))) + expect(result.messages).toHaveLength(CHAT_HISTORY_MAX_ROWS) + expect(result.messages[0]?.id).toBe('1500') + expect(result.dropped).toBe(1500) + }) + + it('caps a huge structured tool payload without losing row identity or mutating input', () => { + const message: UIMessage = { id: 'tool', type: 'tool_use', toolName: 'Bash', toolUseId: 'id', timestamp: 123, input: { command: 'x'.repeat(2_000_000) } } + const result = boundChatHistory([message]) + expect(result.clipped).toBe(true) + expect(result.messages[0]).toMatchObject({ id: 'tool', toolUseId: 'id', timestamp: 123 }) + expect(JSON.stringify(result.messages).length).toBeLessThan(34_000) + expect((message.input as { command: string }).command).toHaveLength(2_000_000) + expect(boundChatHistory(result.messages).messages).toBe(result.messages) + }) + + it('enforces the byte budget for large rows and preserves unchanged references', () => { + const small = [text(0)] + expect(boundChatHistory(small).messages).toBe(small) + const result = boundChatHistory(Array.from({ length: 100 }, (_, index) => text(index, 'x'.repeat(30_000))), 128 * 1024) + expect(result.bytes).toBeLessThanOrEqual(128 * 1024) + expect(result.messages.at(-1)?.id).toBe('99') + }) + it('retains recent terminal records and every active lifecycle, including active-to-terminal transitions', () => { + const records = Object.fromEntries(Array.from({ length: 700 }, (_, index) => [String(index), { + taskId: String(index), status: 'completed', updatedAt: index, + }])) + records.active = { taskId: 'active', status: 'running', updatedAt: 0 } + const bounded = boundActivityText(records, 1024 * 1024)! + expect(Object.keys(bounded)).toHaveLength(501) + expect(bounded['199']).toBeUndefined() + expect(bounded['200']).toBe(records['200']) + expect(bounded.active).toBe(records.active) + const completed = boundActivityText({ ...bounded, active: { ...bounded.active!, status: 'completed', updatedAt: 999 } }, 1024 * 1024)! + expect(Object.keys(completed)).toHaveLength(500) + expect(completed.active).toMatchObject({ taskId: 'active', status: 'completed', updatedAt: 999 }) + expect(completed['200']).toBeUndefined() + const allRunning = Object.fromEntries(Array.from({ length: 1000 }, (_, index) => [String(index), { taskId: String(index), status: 'running' }])) + expect(boundActivityText(allRunning, 1024, 2)).toBe(allRunning) + }) + + it('memoizes unchanged activity records by identity and budgets instead of scanning on each delta', () => { + let enumerations = 0 + const records = new Proxy({ task: { status: 'completed', result: 'result' } }, { + ownKeys(target) { enumerations++; return Reflect.ownKeys(target) }, + }) + const first = boundActivityText(records, 1024, 500) + for (let index = 0; index < 100; index++) expect(boundActivityText(records, 1024, 500)).toBe(first) + expect(enumerations).toBe(1) + boundActivityText(records, 512, 500) + expect(enumerations).toBe(2) + boundActivityText(records, 512, 100) + expect(enumerations).toBe(3) + }) + +}) diff --git a/desktop/src/lib/chatHistoryBudget.ts b/desktop/src/lib/chatHistoryBudget.ts new file mode 100644 index 00000000..b7f2fc7f --- /dev/null +++ b/desktop/src/lib/chatHistoryBudget.ts @@ -0,0 +1,132 @@ +import type { UIMessage } from '../types/chat' + +export const CHAT_HISTORY_MAX_ROWS = 500 +export const CHAT_HISTORY_MAX_BYTES = 2 * 1024 * 1024 +export const CHAT_STREAM_MAX_CHARS = 64 * 1024 +const MESSAGE_MAX_CHARS = 32 * 1024 +const cache = new WeakMap() + +export function copyChatPreview(value: string, maxChars: number, tail = false): string { + if (value.length <= maxChars) return value + // V8 substrings may retain the entire source string's backing storage. + // Round-trip only the bounded slice so the preview owns its small buffer. + const slice = tail ? value.slice(-maxChars) : value.slice(0, maxChars) + return new TextDecoder().decode(new TextEncoder().encode(slice)) +} + +function previewMessage(message: UIMessage) { + const cached = cache.get(message) + if (cached) return cached + let remaining = MESSAGE_MAX_CHARS + let nodes = 0 + let clipped = false + function visit(value: unknown, depth: number): unknown { + if (++nodes > 2048 || depth > 16) { + clipped = true + return null + } + if (typeof value === 'string') { + const allowed = Math.max(0, remaining) + remaining -= Math.min(value.length, allowed) + if (value.length <= allowed) return value + clipped = true + // A partial base64 URI cannot render. Keep its attachment metadata only. + if (value.startsWith('data:')) return undefined + return copyChatPreview(value, allowed) + } + if (!value || typeof value !== 'object') return value + const output: Record | unknown[] = Array.isArray(value) ? [] : {} + let changed = false + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue + if (nodes > 2048 || remaining <= 0) { clipped = true; changed = true; break } + const before = (value as Record)[key] + const after = visit(before, depth + 1) + const target = output as Record + target[key] = after + changed ||= before !== after + } + return changed ? output : value + } + // Identity and timestamps stay intact. Only display payloads are shortened; + // permission requests and runtime recovery state live outside these rows. + const result = { ...message } as UIMessage & Record + for (const key of ['content', 'input', 'modelContent', 'partialInput', 'summary', 'objective', 'message', 'tasks', 'attachments', 'task', 'files']) { + if (key in result) result[key] = visit(result[key], 0) + } + const entry = { message: clipped ? result : message, bytes: 256 + (MESSAGE_MAX_CHARS - remaining) * 2 + nodes * 32, clipped } + cache.set(message, entry) + cache.set(entry.message, entry) + return entry +} + +export function boundChatHistory(messages: UIMessage[], budget = CHAT_HISTORY_MAX_BYTES) { + const retained: UIMessage[] = [] + let bytes = 0 + let clipped = false + let start = messages.length + while (start > 0 && retained.length < CHAT_HISTORY_MAX_ROWS) { + const entry = previewMessage(messages[start - 1]!) + if (bytes + entry.bytes > budget) break + retained.push(entry.message) + bytes += entry.bytes + clipped ||= entry.clipped + start-- + } + retained.reverse() + return { messages: start === 0 && retained.every((message, index) => message === messages[index]) ? messages : retained, bytes, dropped: start, clipped: clipped || start > 0 } +} + +export const CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION = 500 +export const CHAT_TERMINAL_ACTIVITY_MAX_TOTAL = 4000 +const activityCache = new WeakMap() + +// UI projections retain every active lifecycle, plus recent terminal evidence. +// Runtime/provider input and pending permission requests live elsewhere. +export function boundActivityText( + records: Record | undefined, + budget: number, + terminalLimit = CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION, +): Record | undefined { + if (!records) return records + const cached = activityCache.get(records) + if (cached?.budget === budget && cached.terminalLimit === terminalLimit) return cached.result as Record + const fields = ['prompt', 'result', 'summary', 'description'] as const + const entries = Object.entries(records) + const terminal = entries.filter(([, entry]) => { + const status = (entry as Record).status + return status === 'completed' || status === 'failed' || status === 'stopped' + }) + let result = records + if (terminal.length > terminalLimit) { + function time(entry: T): number { + const value = entry as Record + const timestamp = value.updatedAt ?? value.timestamp ?? value.startedAt + const parsed = typeof timestamp === 'number' ? timestamp : typeof timestamp === 'string' ? Date.parse(timestamp) : 0 + return Number.isFinite(parsed) ? parsed : 0 + } + // Stable sort keeps insertion order for bookends without timestamps. + terminal.sort((a, b) => time(a[1]) - time(b[1])) + const dropped = new Set(terminal.slice(0, terminal.length - terminalLimit).map(([id]) => id)) + result = Object.fromEntries(entries.filter(([id]) => !dropped.has(id))) + } + const retained = result === records ? entries : Object.entries(result) + const maxChars = Math.min(16 * 1024, Math.floor(budget / (2 * Math.max(1, retained.length) * fields.length))) + for (const [id, entry] of retained) { + let next = entry + for (const field of fields) { + const value = (entry as Record)[field] + if (typeof value !== 'string' || value.length <= maxChars) continue + if (next === entry) next = { ...entry } + const target = next as Record + target[field] = copyChatPreview(value, maxChars) + } + if (next === entry) continue + if (result === records) result = { ...records } + result[id] = next + } + const memo = { budget, terminalLimit, result } + activityCache.set(records, memo) + activityCache.set(result, memo) + return result +} diff --git a/desktop/src/lib/trace/callCache.test.ts b/desktop/src/lib/trace/callCache.test.ts index 1ae222af..45f08f0b 100644 --- a/desktop/src/lib/trace/callCache.test.ts +++ b/desktop/src/lib/trace/callCache.test.ts @@ -191,4 +191,23 @@ describe('fetchTraceCallDetail', () => { expect(traceCallRequests(fetchMock)).toHaveLength(34) }) + it('does not retain a single oversized semantic request', async () => { + const call = makeCall() + call.request.semantic = { version: 1, request: { prompt: 'x'.repeat(9 * 1024 * 1024) } } + const fetchMock = mockFetch(() => jsonResponse({ call })) + await fetchTraceCallDetail('session-1', 'call-1') + await fetchTraceCallDetail('session-1', 'call-1') + expect(traceCallRequests(fetchMock)).toHaveLength(2) + }) + + it('evicts by retained bytes before the entry-count limit', async () => { + const fetchMock = mockFetch((url) => { + const call = makeCall({ id: url.split('/').at(-1)! }) + call.request.body.preview = 'x'.repeat(3 * 1024 * 1024) + return jsonResponse({ call }) + }) + for (const id of ['a', 'b', 'c', 'b', 'a']) await fetchTraceCallDetail('session-1', id) + expect(traceCallRequests(fetchMock)).toHaveLength(4) + }) + }) diff --git a/desktop/src/lib/trace/callCache.ts b/desktop/src/lib/trace/callCache.ts index 6ae6d4dc..0d6f4a96 100644 --- a/desktop/src/lib/trace/callCache.ts +++ b/desktop/src/lib/trace/callCache.ts @@ -2,7 +2,9 @@ import { sessionsApi } from '../../api/sessions' import { getBaseUrl } from '../../api/client' import type { TraceCallRecord } from '../../types/trace' -const callCache = new Map() +const callCache = new Map() +let cachedBytes = 0 +const TRACE_CALL_CACHE_MAX_BYTES = 16 * 1024 * 1024 const TRACE_CALL_CACHE_MAX_ENTRIES = 32 export async function fetchTraceCallDetail( @@ -16,21 +18,24 @@ export async function fetchTraceCallDetail( if (cached) { callCache.delete(key) callCache.set(key, cached) - return cached + return cached.call } for (const existingKey of callCache.keys()) { - if (existingKey.startsWith(prefix)) callCache.delete(existingKey) + if (existingKey.startsWith(prefix)) removeCachedCall(existingKey) } try { const result = await sessionsApi.getTraceCall(sessionId, callId) const call = result?.call if (!call) return null - if (isTerminalCall(call)) { - callCache.set(key, call) - while (callCache.size > TRACE_CALL_CACHE_MAX_ENTRIES) { + const bytes = estimateRetainedBytes(call) + if (isTerminalCall(call) && bytes <= TRACE_CALL_CACHE_MAX_BYTES) { + removeCachedCall(key) + callCache.set(key, { call, bytes }) + cachedBytes += bytes + while (callCache.size > TRACE_CALL_CACHE_MAX_ENTRIES || cachedBytes > TRACE_CALL_CACHE_MAX_BYTES) { const oldestKey = callCache.keys().next().value if (oldestKey === undefined) break - callCache.delete(oldestKey) + removeCachedCall(oldestKey) } } return call @@ -41,6 +46,7 @@ export async function fetchTraceCallDetail( export function clearTraceCallCache(): void { callCache.clear() + cachedBytes = 0 } function isTerminalCall(call: TraceCallRecord): boolean { @@ -48,3 +54,39 @@ function isTerminalCall(call: TraceCallRecord): boolean { if (call.status === 'pending') return false return Boolean(call.response || call.error) } + + +function removeCachedCall(key: string): void { + const entry = callCache.get(key) + if (!entry) return + cachedBytes -= entry.bytes + callCache.delete(key) +} + +// Count strings and parsed semantic payloads without serializing a second copy. +// This is a conservative retention budget, not an exact JS heap measurement. +function estimateRetainedBytes(value: unknown): number { + let bytes = 0 + const seen = new WeakSet() + function visit(item: unknown, depth: number): void { + if (bytes > TRACE_CALL_CACHE_MAX_BYTES) return + if (typeof item === 'string') bytes += 32 + item.length * 2 + else if (item && typeof item === 'object') { + if (seen.has(item)) return + seen.add(item) + if (depth > 128) { + bytes = TRACE_CALL_CACHE_MAX_BYTES + 1 + return + } + bytes += 64 + for (const key in item) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue + bytes += 16 + key.length * 2 + visit((item as Record)[key], depth + 1) + if (bytes > TRACE_CALL_CACHE_MAX_BYTES) break + } + } else bytes += 8 + } + visit(value, 0) + return bytes +} diff --git a/desktop/src/lib/traceViewModel.test.ts b/desktop/src/lib/traceViewModel.test.ts index d9311d39..596bcbb2 100644 --- a/desktop/src/lib/traceViewModel.test.ts +++ b/desktop/src/lib/traceViewModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildTraceViewModel } from './traceViewModel' import type { MessageEntry } from '../types/session' import type { TraceSession } from '../types/trace' @@ -302,3 +302,32 @@ describe('traceViewModel', () => { }) }) }) + + +describe('large trace turn lookup', () => { + it('does not rescan all turn timestamps for each history message', () => { + const messages: MessageEntry[] = Array.from({ length: 1000 }, (_, index) => ({ + id: `user-${index}`, type: 'user', content: `Turn ${index}`, + timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString(), + })) + const getTime = vi.spyOn(Date.prototype, 'getTime') + try { + const result = buildTraceViewModel({ ...trace, calls: [], events: [] }, messages) + expect(result.spansById.get('message:user-999')?.turnIndex).toBe(999) + // The previous quadratic lookup exceeded a million conversions. + expect(getTime.mock.calls.length).toBeLessThan(100_000) + } finally { + getTime.mockRestore() + } + }) + + it('preserves the last matching turn for out-of-order and duplicate timestamps', () => { + const messages: MessageEntry[] = [3, 1, 2, 2].map((second, index) => ({ + id: `user-${index}`, type: 'user', content: `Turn ${index}`, + timestamp: `2026-06-09T10:00:0${second}.000Z`, + })) + messages.push({ id: 'reply', type: 'assistant', content: 'Reply', timestamp: '2026-06-09T10:00:02.500Z' }) + const result = buildTraceViewModel({ ...trace, calls: [], events: [] }, messages) + expect(result.spansById.get('message:reply')?.turnIndex).toBe(3) + }) +}) diff --git a/desktop/src/lib/traceViewModel.ts b/desktop/src/lib/traceViewModel.ts index 162c9c8c..057c298f 100644 --- a/desktop/src/lib/traceViewModel.ts +++ b/desktop/src/lib/traceViewModel.ts @@ -153,12 +153,13 @@ export function buildTraceViewModel( }) } + const findTurn = createTurnLookup(turns) const toolSpanIds = new Map() const resultBlocksByToolUseId = new Map() const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = [] for (const message of messages) { - const turn = findTurnForTimestamp(turns, message.timestamp) + const turn = findTurn(message.timestamp) const turnId = turn.id if (message.type === 'tool_result') { @@ -238,14 +239,14 @@ export function buildTraceViewModel( } for (const item of deferredToolResultSpans) { - const turn = findTurnForTimestamp(turns, item.message.timestamp) + const turn = findTurn(item.message.timestamp) const spanId = `tool_result:${item.message.id}:${item.block.tool_use_id ?? turn.spanIds.length}` addToolResultSpan(spans, spanId, item.parentId, turn.index, item.message, item.block) turn.spanIds.push(spanId) } for (const call of trace.calls) { - const turn = findTurnForTimestamp(turns, call.startedAt) + const turn = findTurn(call.startedAt) const spanId = `llm:${call.id}` const status = getCallStatus(call) const derivedCallTiming = call.durationMs !== undefined @@ -274,7 +275,7 @@ export function buildTraceViewModel( } for (const event of traceEvents) { - const turn = findTurnForTimestamp(turns, event.timestamp) + const turn = findTurn(event.timestamp) const callParentId = event.callId ? `llm:${event.callId}` : undefined const parentId = callParentId && spans.has(callParentId) ? callParentId : turn.id const spanId = `event:${event.id}` @@ -353,9 +354,8 @@ function buildDiagnosis( .sort((a, b) => compareSpanActivityTime(a, b)) .at(-1) const lastTurn = turns.at(-1) - const lastTurnSpans = lastTurn - ? lastTurn.spanIds.map((spanId) => spans.find((span) => span.id === spanId)).filter(Boolean) as TraceSpan[] - : [] + const lastTurnIds = new Set(lastTurn?.spanIds ?? []) + const lastTurnSpans = spans.filter((span) => lastTurnIds.has(span.id)) const lastTurnHasUser = lastTurnSpans.some((span) => span.message?.type === 'user') const lastTurnHasAgentWork = lastTurnSpans.some((span) => span.kind === 'llm' || @@ -655,17 +655,29 @@ function createTurnTitle(index: number, content: unknown): string { return preview === 'empty' ? `Turn ${index + 1}` : preview } -function findTurnForTimestamp(turns: TraceTurn[], timestamp: string): TraceTurn { - const time = new Date(timestamp).getTime() - if (!Number.isFinite(time)) return turns[0]! - let current = turns[0]! - for (const turn of turns) { - const turnTime = new Date(turn.timestamp).getTime() - if (Number.isFinite(turnTime) && turnTime <= time) { - current = turn +function createTurnLookup(turns: TraceTurn[]): (timestamp: string) => TraceTurn { + // Preserve last-matching-turn semantics for out-of-order child activity + // without scanning every turn for every message, call, and event. + const entries = turns.map((turn, index) => ({ time: new Date(turn.timestamp).getTime(), index })) + .filter((entry) => Number.isFinite(entry.time)) + .sort((a, b) => a.time - b.time) + let latestIndex = 0 + const latestTurns = entries.map((entry) => { + latestIndex = Math.max(latestIndex, entry.index) + return turns[latestIndex]! + }) + return (timestamp) => { + const time = new Date(timestamp).getTime() + if (!Number.isFinite(time)) return turns[0]! + let lo = 0 + let hi = entries.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (entries[mid]!.time <= time) lo = mid + 1 + else hi = mid } + return lo === 0 ? turns[0]! : latestTurns[lo - 1]! } - return current } function earliestTimestamp( diff --git a/desktop/src/pages/SubagentRunPage.test.tsx b/desktop/src/pages/SubagentRunPage.test.tsx index 27c2eaca..bc3cda1a 100644 --- a/desktop/src/pages/SubagentRunPage.test.tsx +++ b/desktop/src/pages/SubagentRunPage.test.tsx @@ -463,6 +463,16 @@ describe('SubagentRunPage', () => { expect(screen.queryByTestId('conversation-navigator')).not.toBeInTheDocument() }) + it('does not replace authoritative activity with a partial transcript tail', async () => { + const tabId = `${SUBAGENT_TAB_PREFIX}session-1__tool-1` + const tasks = { running: { taskId: 'running', toolUseId: 'owned-tool', status: 'running' as const, description: 'Live owned task', startedAt: 1, updatedAt: 2 } } + useChatStore.setState({ sessions: { [tabId]: { ...createDefaultSessionState(), backgroundAgentTasks: tasks } } }) + vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({ activityComplete: false, historyComplete: false, truncated: true })) + render() + await screen.findByTestId('subagent-conversation') + expect(useChatStore.getState().sessions[tabId]?.backgroundAgentTasks).toBe(tasks) + }) + it('auto-opens the owning SubAgent Task and Bash activity without another click', async () => { vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({ status: 'running', @@ -2135,6 +2145,7 @@ describe('SubagentRunPage', () => { useChatStore.setState({ sessions: { 'session-1': { + ...createDefaultSessionState(), backgroundAgentTasks: { 'agent-1': { taskId: 'agent-1', diff --git a/desktop/src/pages/SubagentRunPage.tsx b/desktop/src/pages/SubagentRunPage.tsx index 32566346..60a24ba6 100644 --- a/desktop/src/pages/SubagentRunPage.tsx +++ b/desktop/src/pages/SubagentRunPage.tsx @@ -153,11 +153,11 @@ export function SubagentRunPage({ // The server only sends `activityMessages` when it differs from `messages` // (truncation); below that threshold `messages` is the same projection. const projection = data?.activityMessages ?? data?.messages - if (!projection) return undefined + if (!projection || data?.activityComplete === false) return undefined return mapHistoryMessagesToUiMessages(projection, { includeTeammateMessages: true, }) - }, [data?.activityMessages, data?.messages]) + }, [data?.activityMessages, data?.messages, data?.activityComplete]) const handleReturn = () => { const store = useTabStore.getState() @@ -216,7 +216,7 @@ export function SubagentRunPage({ data.activityMessages ?? data.messages, data.activityTaskNotifications ?? data.taskNotifications ?? [], ) - useChatStore.setState((state) => { + useChatStore.getState().applyBoundedUpdate((state) => { const existing = state.sessions[tabId] ?? createDefaultSessionState() const localMessages = existing.messages.filter((message) => { if (message.type === 'error' && message.code === 'SUBAGENT_MESSAGE_FAILED') { @@ -229,7 +229,10 @@ export function SubagentRunPage({ const hasPendingMessage = localMessages.some((message) => ( message.type === 'user_text' && message.pending === true )) - const mergedActivity = mergeReconstructedRunActivity({ + const mergedActivity = data.activityComplete === false ? { + agentTaskNotifications: existing.agentTaskNotifications ?? {}, + backgroundAgentTasks: existing.backgroundAgentTasks ?? {}, + } : mergeReconstructedRunActivity({ agentTaskNotifications: existing.agentTaskNotifications ?? {}, backgroundAgentTasks: existing.backgroundAgentTasks ?? {}, }, runActivity, { diff --git a/desktop/src/pages/TraceList.test.tsx b/desktop/src/pages/TraceList.test.tsx index ffb1dfaa..88aeffa2 100644 --- a/desktop/src/pages/TraceList.test.tsx +++ b/desktop/src/pages/TraceList.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TraceList } from './TraceList' @@ -104,11 +104,46 @@ describe('TraceList', () => { useSettingsStore.setState({ locale: 'en' }) }) + it('keeps one poll in flight and aborts it when the settings page closes', async () => { + vi.useFakeTimers() + try { + let signal: AbortSignal | undefined + vi.mocked(tracesApi.list).mockResolvedValueOnce(traceList).mockImplementationOnce((_query, options) => { + signal = options?.signal + return new Promise(() => {}) + }) + const view = render() + await act(async () => { await Promise.resolve() }) + await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) + expect(tracesApi.list).toHaveBeenCalledTimes(2) + expect(signal?.aborted).toBe(false) + view.unmount() + expect(signal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts an obsolete search and ignores its late response', async () => { + let firstSignal: AbortSignal | undefined + let resolveFirst!: (data: TraceSessionList) => void + vi.mocked(tracesApi.list).mockImplementationOnce((_query, options) => { + firstSignal = options?.signal + return new Promise((resolve) => { resolveFirst = resolve }) + }).mockResolvedValueOnce(secondTraceList) + render() + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'second' } }) + await findTraceRow(/Second trace session/) + expect(firstSignal?.aborted).toBe(true) + await act(async () => { resolveFirst(traceList) }) + expect(screen.queryByRole('listitem', { name: /Debug stuck agent/ })).not.toBeInTheDocument() + }) + it('renders rows with title, model chips, failure count and metrics', async () => { render() const row = await findTraceRow(/Debug stuck agent/) - expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' }) + expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) })) // header: storage dir + collection badge + aggregate chips expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument() @@ -230,7 +265,7 @@ describe('TraceList', () => { await waitFor(() => { expect(screen.queryByText('Debug stuck agent')).not.toBeInTheDocument() }) - expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' }) + expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(useTabStore.getState().activeTabId).toBeNull() }) @@ -273,8 +308,8 @@ describe('TraceList', () => { expect(await screen.findByText('Second trace session')).toBeInTheDocument() expect(screen.getByText('Showing 2 of 2')).toBeInTheDocument() - expect(tracesApi.list).toHaveBeenNthCalledWith(1, { limit: 50, offset: 0, query: '' }) - expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 1, query: '' }) + expect(tracesApi.list).toHaveBeenNthCalledWith(1, { limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 1, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) it('sends title search text to the trace list API', async () => { @@ -286,7 +321,7 @@ describe('TraceList', () => { }) await waitFor(() => { - expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' }) + expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' }, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) }) diff --git a/desktop/src/pages/TraceList.tsx b/desktop/src/pages/TraceList.tsx index ac34cc99..ec0a87a0 100644 --- a/desktop/src/pages/TraceList.tsx +++ b/desktop/src/pages/TraceList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { KeyboardEvent } from 'react' import { ExternalLink, RefreshCw, Trash2, Workflow } from 'lucide-react' import { tracesApi } from '../api/traces' @@ -35,6 +35,7 @@ export function TraceList() { const [isLoadingMore, setIsLoadingMore] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [deletingSessionId, setDeletingSessionId] = useState(null) + const activeRequest = useRef(null) const host = getDesktopHost() useEffect(() => { @@ -50,16 +51,20 @@ export function TraceList() { offset?: number silent?: boolean }) => { + if (options?.silent && activeRequest.current) return + activeRequest.current?.abort() + const controller = new AbortController() + activeRequest.current = controller const append = options?.append === true const offset = options?.offset ?? 0 const limit = options?.limit ?? PAGE_SIZE try { - if (append) { - setIsLoadingMore(true) - } else if (!options?.silent) { + setIsLoadingMore(append) + if (!append && !options?.silent) { setState({ status: 'loading' }) } - const data = await tracesApi.list({ limit, offset, query }) + const data = await tracesApi.list({ limit, offset, query }, { signal: controller.signal }) + if (controller.signal.aborted || activeRequest.current !== controller) return setState((previous) => { if (!append || previous.status !== 'ready') { return { status: 'ready', data } @@ -73,17 +78,25 @@ export function TraceList() { } }) } catch (error) { + if (controller.signal.aborted || activeRequest.current !== controller) return setState({ status: 'error', message: error instanceof Error ? error.message : t('trace.list.loadFailed'), }) } finally { - if (append) setIsLoadingMore(false) + if (activeRequest.current === controller) { + activeRequest.current = null + setIsLoadingMore(false) + } } }, [query, t]) useEffect(() => { void load() + return () => { + activeRequest.current?.abort() + activeRequest.current = null + } }, [load]) useEffect(() => { diff --git a/desktop/src/pages/TraceSession.test.tsx b/desktop/src/pages/TraceSession.test.tsx index 22f03c33..5fda24cf 100644 --- a/desktop/src/pages/TraceSession.test.tsx +++ b/desktop/src/pages/TraceSession.test.tsx @@ -15,7 +15,7 @@ import type { TraceCallRecord, TraceSession as TraceSessionData } from '../types vi.mock('../api/sessions', () => ({ sessionsApi: { getTrace: vi.fn(), - getMessages: vi.fn(), + getHistoryPage: vi.fn(), getTraceCall: vi.fn(), }, })) @@ -173,7 +173,7 @@ describe('TraceSession', () => { window.localStorage.clear() useSettingsStore.setState({ locale: 'en' }) vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace) - vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: baseMessages }) + vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: baseMessages }) vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: fullCall }) vi.mocked(tracesApi.getRevision).mockResolvedValue({ sessionId: SESSION_ID, @@ -205,6 +205,25 @@ describe('TraceSession', () => { useSettingsStore.setState({ locale: 'en' }) }) + it('does not attribute old trace calls to an unrelated partial transcript tail', async () => { + vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [{ id: 'tail', type: 'user', timestamp: '2026-06-10T00:00:00Z', content: 'Unrelated later question' }], page: { historyComplete: false, hasMore: true, nextCursor: 'older', sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } }) + await renderReady() + expect(screen.queryByText('Unrelated later question')).not.toBeInTheDocument() + expect(screen.getByTestId('trace-split-layout')).toBeInTheDocument() + }) + + it('replaces bounded trace pages and forwards snapshot and scan cursors', async () => { + const window = { offset: 0, limit: 100, totalCalls: 201, totalEvents: 0, hasMore: true, revisionToken: 'snapshot-v1', state: 'limited' as const, oversizedRecords: 1, startByte: 0, scannedBytes: 64 * 1024 * 1024, fileBytes: 500 * 1024 * 1024, recordLimit: 10000, recordBytesLimit: 2 * 1024 * 1024, nextScanCursor: 'next-segment' } + vi.mocked(sessionsApi.getTrace).mockResolvedValue({ ...baseTrace, window }) + await renderReady() + fireEvent.click(screen.getByRole('button', { name: /^Next page$/ })) + await waitFor(() => expect(sessionsApi.getTrace).toHaveBeenLastCalledWith(SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }), { offset: 100, revisionToken: 'snapshot-v1' })) + await screen.findByRole('button', { name: 'Scan next segment' }) + fireEvent.click(screen.getByRole('button', { name: 'Scan next segment' })) + await waitFor(() => expect(sessionsApi.getTrace).toHaveBeenLastCalledWith(SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }), { scanCursor: 'next-segment' })) + expect(screen.queryByText('Some records exceed the viewing limit; the original file is unchanged.')).toBeInTheDocument() + }) + it('renders the two-pane layout with tree and detail', async () => { await renderReady() @@ -232,7 +251,7 @@ describe('TraceSession', () => { }) it('groups timeline rows by turn with user message previews', async () => { - vi.mocked(sessionsApi.getMessages).mockResolvedValue({ + vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [ ...baseMessages, { id: 'msg-5', type: 'user', content: 'Second question', timestamp: '2026-06-09T10:05:00.000Z' }, @@ -512,18 +531,18 @@ describe('TraceSession', () => { fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5')) await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1)) expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1) - expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1) + expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1) for (const expectedTraceCalls of [2, 3]) { await act(async () => { await vi.advanceTimersByTimeAsync(20) }) expect(sessionsApi.getTrace).toHaveBeenCalledTimes(expectedTraceCalls) - expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1) + expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1) expect(screen.queryByText('claude-sonnet-4-5 x2')).not.toBeInTheDocument() } await act(async () => { await vi.advanceTimersByTimeAsync(20) }) expect(sessionsApi.getTrace).toHaveBeenCalledTimes(4) - expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2) + expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(2) expect(screen.getByText('claude-sonnet-4-5 x2')).toBeInTheDocument() expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1) const detail = within(screen.getByTestId('trace-detail')) @@ -541,7 +560,42 @@ describe('TraceSession', () => { await waitFor(() => expect(vi.mocked(tracesApi.getRevision).mock.calls.length).toBeGreaterThanOrEqual(3)) expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1) - expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1) + expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1) + }) + + it('reuses message history when only trace calls change', async () => { + vi.mocked(sessionsApi.getTrace) + .mockResolvedValueOnce({ ...baseTrace, messageSignature: 'unchanged-transcript' }) + .mockResolvedValue({ + ...baseTrace, + messageSignature: 'unchanged-transcript', + summary: { ...baseTrace.summary, models: [{ model: 'updated-model', calls: 1 }] }, + }) + vi.mocked(tracesApi.getRevision) + .mockResolvedValueOnce({ sessionId: SESSION_ID, revision: 1, changed: true, reset: false }) + .mockResolvedValue({ sessionId: SESSION_ID, revision: 2, changed: true, reset: false }) + await renderReady(20) + expect(await screen.findByText('updated-model x1')).toBeInTheDocument() + expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1) + }) + + it('aborts in-flight trace data requests when the page is closed', async () => { + vi.mocked(sessionsApi.getTrace).mockImplementation(() => new Promise(() => {})) + const { unmount } = render() + const signal = vi.mocked(sessionsApi.getTrace).mock.calls[0]?.[1]?.signal + expect(signal?.aborted).toBe(false) + unmount() + expect(signal?.aborted).toBe(true) + }) + + it('aborts an outstanding revision probe when the page closes', async () => { + vi.mocked(tracesApi.getRevision).mockImplementation(() => new Promise(() => {})) + const { unmount } = render() + await waitFor(() => expect(tracesApi.getRevision).toHaveBeenCalled()) + const signal = vi.mocked(tracesApi.getRevision).mock.calls[0]?.[3]?.signal + expect(signal?.aborted).toBe(false) + unmount() + expect(signal?.aborted).toBe(true) }) it('uses the revision cursor for unchanged polls without refetching the full trace', async () => { @@ -549,8 +603,8 @@ describe('TraceSession', () => { await waitFor(() => expect(vi.mocked(tracesApi.getRevision).mock.calls.length).toBeGreaterThanOrEqual(2)) expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1) - expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1) - expect(tracesApi.getRevision).toHaveBeenLastCalledWith(SESSION_ID, 1, undefined) + expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1) + expect(tracesApi.getRevision).toHaveBeenLastCalledWith(SESSION_ID, 1, undefined, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) it('serializes slow revision probes so stale polls cannot overwrite newer state', async () => { @@ -589,7 +643,7 @@ describe('TraceSession', () => { vi.mocked(tracesApi.getRevision) .mockResolvedValueOnce({ sessionId: SESSION_ID, revision: 1, changed: true, reset: false }) .mockResolvedValue({ sessionId: SESSION_ID, revision: 2, changed: true, reset: false }) - vi.mocked(sessionsApi.getMessages) + vi.mocked(sessionsApi.getHistoryPage) .mockResolvedValueOnce({ messages: pendingMessages }) .mockReturnValue(refreshedMessages) @@ -599,7 +653,7 @@ describe('TraceSession', () => { fireEvent.click(tree.getByText('Bash')) expect(within(screen.getByTestId('trace-detail')).queryByText('file.txt')).not.toBeInTheDocument() - await waitFor(() => expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(2)) resolveRefreshedMessages({ messages: baseMessages }) expect(await within(screen.getByTestId('trace-detail')).findByText('file.txt')).toBeInTheDocument() }) @@ -774,7 +828,7 @@ describe('TraceSession', () => { }, calls: [], }) - vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] }) + vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [] }) render() @@ -881,7 +935,7 @@ describe('TraceSession', () => { }) it('says what an assistant turn did when the provider withheld its reasoning', async () => { - vi.mocked(sessionsApi.getMessages).mockResolvedValue({ + vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [ baseMessages[0]!, { diff --git a/desktop/src/pages/TraceSession.tsx b/desktop/src/pages/TraceSession.tsx index 5d2e53eb..da8e29a7 100644 --- a/desktop/src/pages/TraceSession.tsx +++ b/desktop/src/pages/TraceSession.tsx @@ -62,6 +62,7 @@ export function TraceSession({ const [state, setState] = useState({ status: 'loading' }) const [selectedId, setSelectedId] = useState(null) const [refreshNonce, setRefreshNonce] = useState(0) + const [tracePage, setTracePage] = useState<{ offset?: number; revisionToken?: string; scanCursor?: string }>({}) const [lastLoadedAt, setLastLoadedAt] = useState(null) const [refreshing, setRefreshing] = useState(false) const [clockNowMs, setClockNowMs] = useState(() => Date.now()) @@ -71,6 +72,9 @@ export function TraceSession({ useEffect(() => { let cancelled = false + const controller = new AbortController() + let loadedMessages: MessageEntry[] | undefined + let loadedMessageSignature: string | null | undefined let loadInFlight = false let revisionPollingAvailable = true let currentRevision: number | undefined @@ -92,6 +96,7 @@ export function TraceSession({ sessionId, currentRevision, currentRevisionToken, + { signal: controller.signal }, ) if (cancelled || requestGeneration !== revisionRequestGeneration) return if (!revision.changed) { @@ -108,14 +113,14 @@ export function TraceSession({ } if (silent) setRefreshing(true) - const trace = await sessionsApi.getTrace(sessionId) + const trace = await sessionsApi.getTrace(sessionId, { signal: controller.signal }, tracePage) if (!isTraceSessionData(trace)) { throw new Error(t('trace.snapshotEmpty')) } if (cancelled) return if (!silent && revisionPollingAvailable) { const requestGeneration = ++revisionRequestGeneration - void tracesApi.getRevision(sessionId).then((revision) => { + void tracesApi.getRevision(sessionId, undefined, undefined, { signal: controller.signal }).then((revision) => { if (cancelled || requestGeneration !== revisionRequestGeneration) return currentRevision = revision.revision currentRevisionToken = revision.revisionToken @@ -133,10 +138,22 @@ export function TraceSession({ } const signature = traceSnapshotSignature(trace) if (silent && snapshotSignatureRef.current === signature) return - const messageResponse = await sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })) + // Trace calls/events often advance without any transcript mutation. + // Reuse the transcript until its independent revision changes. + if (!loadedMessages || trace.messageSignature == null || trace.messageSignature !== loadedMessageSignature) { + const messageResponse = await sessionsApi.getHistoryPage(sessionId, {}, { signal: controller.signal }) + .catch(() => null) + if (cancelled) return + if (messageResponse) { + // A tail page cannot assign historical calls to their original turns. + // Keep those calls under session activity instead of inventing an association. + loadedMessages = messageResponse.page?.historyComplete === false ? [] : messageResponse.messages + loadedMessageSignature = trace.messageSignature + } + } if (cancelled) return - snapshotSignatureRef.current = signature - setState({ status: 'ready', trace, messages: messageResponse.messages }) + snapshotSignatureRef.current = loadedMessages ? signature : null + setState({ status: 'ready', trace, messages: loadedMessages ?? [] }) setClockNowMs(Date.now()) setLastLoadedAt(new Date().toISOString()) } catch (error) { @@ -154,16 +171,21 @@ export function TraceSession({ lastSpanIdRef.current = null void load(false) const interval = window.setInterval(() => { - void load(true) + // Keep a historical page stable. Returning to the first page resumes live polling. + if (!tracePage.offset && !tracePage.scanCursor) void load(true) }, pollIntervalMs) return () => { cancelled = true + controller.abort() window.clearInterval(interval) } - }, [sessionId, refreshNonce, pollIntervalMs, t]) + }, [sessionId, refreshNonce, pollIntervalMs, t, tracePage]) - const refresh = () => setRefreshNonce((value) => value + 1) + const refresh = () => { + setTracePage({}) + setRefreshNonce((value) => value + 1) + } const openWindow = () => { const host = getDesktopHost() @@ -281,6 +303,17 @@ export function TraceSession({ refreshing={refreshing} updatedAt={lastLoadedAt} /> + {trace.window && ( +
+ {t('trace.windowNotice')} + {trace.window.offset + 1}–{trace.window.offset + Math.max(trace.calls.length, trace.events?.length ?? 0)} / {Math.max(trace.window.totalCalls, trace.window.totalEvents)} + {(trace.window.state === 'limited' || trace.window.oversizedRecords > 0) && {t('trace.windowLimited')}} + + + + {trace.window.nextScanCursor && } +
+ )} {hasTraceContent && activeSpan ? (
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index b1e7f998..f9096ff1 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -105,6 +105,8 @@ vi.mock('../api/websocket', () => ({ vi.mock('../api/sessions', () => ({ sessionsApi: { getMessages: vi.fn(async () => ({ messages: [] })), + getHistoryPage: vi.fn(async () => ({ messages: [] })), + getHistoryRecovery: vi.fn(async () => ({ status: 'incomplete', messages: [] })), getSlashCommands: vi.fn(async () => ({ commands: [] })), }, })) @@ -1905,7 +1907,7 @@ describe('chatStore history mapping', () => { const historyLoad = useChatStore.getState().loadHistory(TEST_SESSION_ID) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { @@ -2831,7 +2833,7 @@ describe('chatStore history mapping', () => { const historyLoad = useChatStore.getState().loadHistory(TEST_SESSION_ID) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', @@ -8967,7 +8969,7 @@ describe('chatStore history mapping', () => { useChatStore.getState().connectToSession(TEST_SESSION_ID) await Promise.resolve() - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session' }) }) @@ -11772,7 +11774,7 @@ describe('chatStore history mapping', () => { ) }) const session = useChatStore.getState().sessions[TEST_SESSION_ID] - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(session?.streamingText).toBe('') expect(session?.messages).toContainEqual(expect.objectContaining({ type: 'assistant_text', @@ -11826,7 +11828,7 @@ describe('chatStore history mapping', () => { ?.backgroundAgentTasks?.['agent-task-1']?.status, ).toBe('stopped') }) - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle') }) @@ -11862,7 +11864,7 @@ describe('chatStore history mapping', () => { }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running') }) expect( @@ -11903,7 +11905,7 @@ describe('chatStore history mapping', () => { }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle') }) expect( @@ -11944,7 +11946,7 @@ describe('chatStore history mapping', () => { }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle') }) expect( @@ -11993,7 +11995,7 @@ describe('chatStore history mapping', () => { }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running') }) expect( @@ -12179,7 +12181,7 @@ describe('chatStore history mapping', () => { turnState: 'idle', }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) useChatStore.getState().sendMessage(TEST_SESSION_ID, 'new turn') @@ -12594,7 +12596,7 @@ describe('chatStore history mapping', () => { turnState: 'idle', }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) useChatStore.getState().sendMessage(TEST_SESSION_ID, 'new turn') @@ -12640,7 +12642,7 @@ describe('chatStore history mapping', () => { turnState: 'running', }) await vi.waitFor(() => { - expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]).toMatchObject({ @@ -15129,6 +15131,188 @@ describe('chatStore activity state survival across reload paths', () => { expect(session?.agentTaskNotifications).toEqual(notifications) }) + it('shows a bounded page immediately and restores state independently without treating the tail as authoritative', async () => { + const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } + vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [{ id: 'tail', type: 'assistant', content: 'recent', timestamp: '2026-01-01T00:00:00Z' }], page }) + let resolveRecovery!: (value: Awaited>) => void + vi.mocked(sessionsApi.getHistoryRecovery).mockReturnValueOnce(new Promise((resolve) => { resolveRecovery = resolve })) + const usage = { input_tokens: 321, output_tokens: 123 } + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ tokenUsage: usage }) } }) + await useChatStore.getState().loadHistory(TEST_SESSION_ID) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual(expect.arrayContaining([expect.objectContaining({ content: 'recent' })])) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toBe(usage) + expect(setTasksFromTodosMock).not.toHaveBeenCalled() + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('loading') + resolveRecovery({ status: 'ready', sourceVersion: 'v1', omittedRecords: 0, messages: [], tokenUsage: { input_tokens: 999, output_tokens: 888 } }) + await vi.waitFor(() => expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('ready')) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toEqual({ input_tokens: 999, output_tokens: 888 }) + }) + + it('cancels stale background recovery before an authoritative history reload', async () => { + const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 } + vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [], page }) + let resolveRecovery!: (value: Awaited>) => void + let signal: AbortSignal | undefined + vi.mocked(sessionsApi.getHistoryRecovery).mockImplementationOnce((_id, options) => { + signal = options?.signal + return new Promise((resolve) => { resolveRecovery = resolve }) + }) + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } }) + await useChatStore.getState().loadHistory(TEST_SESSION_ID) + await useChatStore.getState().reloadHistory(TEST_SESSION_ID) + expect(signal?.aborted).toBe(true) + resolveRecovery({ status: 'ready', sourceVersion: 'v1', messages: [], tokenUsage: { input_tokens: 999, output_tokens: 999 }, omittedRecords: 0 }) + await Promise.resolve() + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('ready') + }) + + it('restores complete recovery fields from a newer append without clearing incomplete fields', async () => { + const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: '1:2:10:1', scannedBytes: 10, omittedOversizedEntries: 0 } + vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [], page }) + vi.mocked(sessionsApi.getHistoryRecovery).mockResolvedValueOnce({ + status: 'incomplete', sourceVersion: '1:2:20:2', omittedRecords: 1, messages: [], + completeness: { usage: true, goal: false, todos: false, activity: false }, + tokenUsage: { input_tokens: 999, output_tokens: 888 }, + }) + const goal = { action: 'created' as const, objective: 'Preserve live goal', updatedAt: 1 } + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ activeGoal: goal }) } }) + await useChatStore.getState().loadHistory(TEST_SESSION_ID) + await vi.waitFor(() => expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('incomplete')) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toEqual({ input_tokens: 999, output_tokens: 888 }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toBe(goal) + expect(setTasksFromTodosMock).not.toHaveBeenCalled() + }) + + it('reads older pages into a separate display window without rewriting live task state', async () => { + const live = [{ id: 'live', type: 'assistant_text' as const, content: 'Live', timestamp: 1 }] + const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: live, chatState: 'thinking', historyPage: page }) } }) + vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce({ messages: [{ id: 'old', type: 'user', content: 'old page', timestamp: '2020-01-01T00:00:00Z' }], page: { ...page, nextCursor: null, hasMore: false } }) + await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID) + const current = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(current?.messages).toBe(live) + expect(current?.chatState).toBe('thinking') + expect(current?.historyBrowseMessages).toEqual(expect.arrayContaining([expect.objectContaining({ content: 'old page' })])) + expect(setTasksFromTodosMock).not.toHaveBeenCalled() + }) + + it.each(['loadHistory', 'reloadHistory'] as const)('cancels an older page before %s replaces the current history', async (action) => { + const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 } + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ historyPage: page }) } }) + let resolvePage!: (value: Awaited>) => void + let signal: AbortSignal | undefined + vi.mocked(sessionsApi.getHistoryPage).mockImplementationOnce((_id, _page, options) => { + signal = options?.signal + return new Promise((resolve) => { resolvePage = resolve }) + }) + const pending = useChatStore.getState().loadOlderHistory(TEST_SESSION_ID) + await useChatStore.getState()[action](TEST_SESSION_ID) + expect(signal?.aborted).toBe(true) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyPageLoading).toBe(false) + resolvePage({ messages: [{ id: 'stale', type: 'assistant', content: 'stale older page', timestamp: '2026-01-01T00:00:00Z' }], page }) + await pending + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyBrowseMessages).toBeUndefined() + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyPage).toBeUndefined() + }) + + it('bounds external session projections and activity text without changing task control state or permission input', () => { + const raw = 'x'.repeat(2_000_000) + const task = { taskId: 'task', toolUseId: 'tool', status: 'running' as const, startedAt: 1, updatedAt: 2, prompt: raw, result: raw, summary: raw } + const permission = { requestId: 'permission', toolName: 'Bash', input: { command: raw } } + useChatStore.getState().applyBoundedUpdate(() => ({ sessions: Object.fromEntries(Array.from({ length: 20 }, (_, index) => [String(index), makeSession({ + messages: Array.from({ length: 600 }, (_, row) => ({ id: String(row), type: 'assistant_text', content: 'x'.repeat(30_000), timestamp: row })), + backgroundAgentTasks: { task }, + agentTaskNotifications: { tool: { taskId: 'task', toolUseId: 'tool', status: 'completed', result: raw, summary: raw } }, + pendingPermission: permission, + })])) })) + let retained = 0 + for (const session of Object.values(useChatStore.getState().sessions)) { + expect(session.messages.length).toBeLessThanOrEqual(500) + expect(session.backgroundAgentTasks?.task).toMatchObject({ taskId: 'task', toolUseId: 'tool', status: 'running', startedAt: 1, updatedAt: 2 }) + expect(session.pendingPermission).toBe(permission) + retained += JSON.stringify(session.messages).length * 2 + retained += JSON.stringify(session.backgroundAgentTasks).length * 2 + retained += JSON.stringify(session.agentTaskNotifications).length * 2 + } + expect(retained).toBeLessThan(16 * 1024 * 1024) + expect(task.result).toHaveLength(2_000_000) + expect(permission.input.command).toHaveLength(2_000_000) + }) + + it('shares a terminal activity count budget across twenty sessions without dropping active lifecycles', () => { + const sessions = Object.fromEntries(Array.from({ length: 20 }, (_, sessionIndex) => { + const tasks = Object.fromEntries(Array.from({ length: 700 }, (_, index) => [String(index), { taskId: String(index), toolUseId: String(index), status: 'completed' as const, startedAt: index, updatedAt: index }])) + const notifications = Object.fromEntries(Array.from({ length: 700 }, (_, index) => [String(index), { taskId: String(index), toolUseId: String(index), status: 'completed' as const, timestamp: new Date(index).toISOString() }])) + return [String(sessionIndex), makeSession({ backgroundAgentTasks: { ...tasks, active: { taskId: 'active', status: 'running', startedAt: 0, updatedAt: 0 } }, agentTaskNotifications: notifications })] + })) + useChatStore.getState().applyBoundedUpdate(() => ({ sessions })) + let terminalCount = 0 + for (const session of Object.values(useChatStore.getState().sessions)) { + expect(session.backgroundAgentTasks?.active?.status).toBe('running') + expect(session.backgroundAgentTasks?.['699']?.status).toBe('completed') + expect(session.agentTaskNotifications['699']?.status).toBe('completed') + terminalCount += Object.values(session.backgroundAgentTasks ?? {}).filter((task) => task.status !== 'running').length + terminalCount += Object.keys(session.agentTaskNotifications).length + } + expect(terminalCount).toBe(4000) + const before = useChatStore.getState().sessions['0']! + useChatStore.getState().handleServerMessage('0', { type: 'content_delta', text: 'live text' }) + expect(useChatStore.getState().sessions['0']?.backgroundAgentTasks).toBe(before.backgroundAgentTasks) + expect(useChatStore.getState().sessions['0']?.agentTaskNotifications).toBe(before.agentTaskNotifications) + }) + + it('shares a bounded display budget across many running sessions', () => { + const sessions = Object.fromEntries(Array.from({ length: 20 }, (_, sessionIndex) => [ + `budget-${sessionIndex}`, + makeSession({ chatState: 'thinking', messages: Array.from({ length: 60 }, (_, index) => ({ id: `${sessionIndex}-${index}`, type: 'assistant_text', content: 'x'.repeat(30_000), timestamp: index })) }), + ])) + useChatStore.setState({ sessions }) + useChatStore.getState().handleServerMessage('budget-0', { type: 'status', state: 'thinking', verb: 'Working' }) + const retained = Object.values(useChatStore.getState().sessions) + expect(retained.every((session) => session.chatState === 'thinking')).toBe(true) + const retainedCharacters = retained.reduce((total, session) => total + session.messages.reduce((sum, message) => sum + ('content' in message && typeof message.content === 'string' ? message.content.length : 0), 0), 0) + expect(retainedCharacters * 2).toBeLessThanOrEqual(16 * 1024 * 1024) + }) + + it('bounds live message retention while preserving operational state', () => { + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: Array.from({ length: 2000 }, (_, index) => ({ id: String(index), type: 'assistant_text', content: 'old', timestamp: index })), chatState: 'thinking' }) } }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'status', state: 'thinking', verb: 'Working' }) + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.messages.length).toBeLessThanOrEqual(500) + expect(session?.historyWindowed).toBe(true) + expect(session?.chatState).toBe('thinking') + }) + + it('aborts a pending history download on disconnect', async () => { + let requestSignal: AbortSignal | undefined + vi.mocked(sessionsApi.getMessages).mockImplementationOnce((_id, options) => new Promise((_resolve, reject) => { + requestSignal = options?.signal + requestSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } }) + const pending = useChatStore.getState().loadHistory(TEST_SESSION_ID) + expect(requestSignal?.aborted).toBe(false) + useChatStore.getState().disconnectSession(TEST_SESSION_ID) + expect(requestSignal?.aborted).toBe(true) + await pending + expect(useChatStore.getState().sessions[TEST_SESSION_ID]).toBeUndefined() + }) + + it('aborts a superseded authoritative history reload', async () => { + let firstSignal: AbortSignal | undefined + vi.mocked(sessionsApi.getMessages).mockImplementationOnce((_id, options) => new Promise((_resolve, reject) => { + firstSignal = options?.signal + firstSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } }) + const first = useChatStore.getState().reloadHistory(TEST_SESSION_ID) + const second = useChatStore.getState().reloadHistory(TEST_SESSION_ID) + expect(firstSignal?.aborted).toBe(true) + await Promise.all([first, second]) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyStatus).toBe('ready') + }) + it('applies a cold history response against the latest optimistic user turn', async () => { let resolveHistory!: (value: { messages: MessageEntry[] }) => void vi.mocked(sessionsApi.getMessages).mockReturnValueOnce(new Promise((resolve) => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index b609bafc..6de3179a 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' +import { boundActivityText, boundChatHistory, copyChatPreview, CHAT_STREAM_MAX_CHARS, CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION, CHAT_TERMINAL_ACTIVITY_MAX_TOTAL } from '../lib/chatHistoryBudget' import { wsManager } from '../api/websocket' -import { sessionsApi } from '../api/sessions' +import { sessionsApi, type SessionHistoryPage } from '../api/sessions' import { ApiResponseParseError } from '../api/client' import { subagentsApi } from '../api/subagents' import { useTeamStore } from './teamStore' @@ -132,6 +133,12 @@ export type PerSessionState = { /** True once durable transcript history has been applied for this lifecycle. */ historyHydrated?: boolean historyError?: string | null + historyPage?: SessionHistoryPage['page'] + historyWindowed?: boolean + historyPageLoading?: boolean + historyViewingOlder?: boolean + historyBrowseMessages?: UIMessage[] + historyRecoveryStatus?: 'loading' | 'ready' | 'incomplete' | 'error' streamingText: string streamingToolInput: string activeToolUseId: string | null @@ -359,6 +366,7 @@ export type AskUserQuestionDraft = { } type ChatStore = { + applyBoundedUpdate: (update: (state: ChatStore) => Partial) => void sessions: Record /** sessionId → toolUseId → draft. In-memory, like `composerDraft`. */ askUserQuestionDrafts: Record> @@ -411,6 +419,7 @@ type ChatStore = { sessionId: string, options?: { mode?: 'terminal-reconnect' }, ) => Promise + loadOlderHistory: (sessionId: string, latest?: boolean) => Promise reloadHistory: ( sessionId: string, guard?: { @@ -665,7 +674,7 @@ function retireAgentStream(sessionId: string, streamId: string): void { } function advanceAgentStreamRevision(sessionId: string): void { - useChatStore.setState((state) => ({ + useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: { ...state.sessions, [sessionId]: { @@ -2192,9 +2201,14 @@ function describeHistoryLoadError(error: unknown): string { async function fetchAndMapSessionHistory( sessionId: string, existingOwnedToolUseIds = new Set(), + signal?: AbortSignal, ) { - const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) - const uiMessages = mapHistoryMessagesToUiMessages(messages) + const response = await sessionsApi.getMessages(sessionId, { signal }) + const { page } = response + const historyComplete = !page || page.historyComplete + const messages = historyComplete ? response.messages : [] + const taskNotifications = historyComplete ? response.taskNotifications : [] + const uiMessages = mapHistoryMessagesToUiMessages(response.messages) // Session history intentionally joins child tool activity back into the // parent transcript for the conversation timeline. Activity ownership is // narrower: restoring from that joined stream would put a child's shell @@ -2218,9 +2232,10 @@ async function fetchAndMapSessionHistory( rootRunMessages, rootRunNotifications, ) - const restoredGoalState = deriveActiveGoalStateFromMessages(uiMessages) + const restoredGoalState = deriveActiveGoalStateFromMessages(historyComplete ? uiMessages : []) return { - rawMessages: messages, + page, + historyComplete, uiMessages, activeGoal: restoredGoalState.activeGoal, hasRestoredGoalState: restoredGoalState.hasStateEvidence, @@ -2232,7 +2247,63 @@ async function fetchAndMapSessionHistory( } } +function recoveryMatchesHistorySource(pageVersion: string, recoveryVersion: string): boolean { + if (pageVersion === recoveryVersion) return true + const page = pageVersion.split(':') + const recovery = recoveryVersion.split(':') + // A newer append-only snapshot can restore an older page. Equal-size + // rewrites and inode replacements must never restore stale session state. + return page.length === 4 && recovery.length === 4 && page[0] === recovery[0] && page[1] === recovery[1] + && Number.isFinite(Number(page[2])) && Number(recovery[2]) > Number(page[2]) +} + +async function recoverSessionHistory(sessionId: string, sourceVersion: string): Promise { + historyRecoveryControllers.get(sessionId)?.abort() + const controller = new AbortController() + historyRecoveryControllers.set(sessionId, controller) + const lifecycle = currentHistoryLifecycle(sessionId) + const baseline = useChatStore.getState().sessions[sessionId] + if (!baseline) return + const taskBaseline = useCLITaskStore.getState() + useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyRecoveryStatus: 'loading' })) })) + try { + const recovery = await sessionsApi.getHistoryRecovery(sessionId, { signal: controller.signal, timeout: 120_000 }) + if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return + if (!recoveryMatchesHistorySource(sourceVersion, recovery.sourceVersion)) { + useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyRecoveryStatus: 'incomplete' })) })) + return + } + const complete = recovery.completeness ?? { goal: recovery.status === 'ready', todos: recovery.status === 'ready', activity: recovery.status === 'ready', usage: recovery.status === 'ready' } + const rootMessages = recovery.messages.filter((message) => !message.parentToolUseId) + const goal = deriveActiveGoalStateFromMessages(mapHistoryMessagesToUiMessages(rootMessages)) + const activity = reconstructRunActivityFromTranscript(rootMessages, (recovery.taskNotifications ?? []).filter((item) => !item.ownerAgentId)) + useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, (current) => ({ + historyRecoveryStatus: recovery.status, + activeGoal: complete.goal && current.activeGoalRevision === baseline.activeGoalRevision ? goal.activeGoal : current.activeGoal, + tokenUsage: complete.usage && current.tokenUsage === baseline.tokenUsage ? recovery.tokenUsage ?? current.tokenUsage : current.tokenUsage, + backgroundAgentTasks: complete.activity && current.backgroundAgentTasks === baseline.backgroundAgentTasks + ? mergeBackgroundAgentTaskRecords(current.backgroundAgentTasks ?? {}, activity.backgroundAgentTasks) + : current.backgroundAgentTasks, + agentTaskNotifications: complete.activity && current.agentTaskNotifications === baseline.agentTaskNotifications + ? mergeAgentTaskNotificationRecords(current.agentTaskNotifications, activity.agentTaskNotifications, current.backgroundAgentTasks ?? {}) + : current.agentTaskNotifications, + })) })) + const taskCurrent = useCLITaskStore.getState() + if (complete.todos && taskCurrent.sessionId === taskBaseline.sessionId && taskCurrent.tasks === taskBaseline.tasks) { + taskCurrent.setTasksFromTodos(extractLastTodoWriteFromHistory(rootMessages) ?? [], sessionId) + if (hasUserMessagesAfterTaskCompletion(rootMessages)) taskCurrent.markCompletedAndDismissed(sessionId) + } + } catch { + if (!controller.signal.aborted && isCurrentHistoryLifecycle(sessionId, lifecycle)) { + useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyRecoveryStatus: 'error' })) })) + } + } finally { + if (historyRecoveryControllers.get(sessionId) === controller) historyRecoveryControllers.delete(sessionId) + } +} + type HistoryLoadInFlight = { + controller: AbortController lifecycleGeneration: number promise: Promise } @@ -2263,6 +2334,9 @@ type TerminalReconnectHistoryBoundary = { } const historyLoadsInFlight = new Map() +const historyRecoveryControllers = new Map() +const historyPageControllers = new Map() +const historyReloadControllers = new Map() const historyReloadGenerations = new Map() const historyReloadCompletionGenerations = new Map() const historyLifecycleGenerations = new Map() @@ -2278,7 +2352,14 @@ function currentHistoryLifecycle(sessionId: string): number { function advanceHistoryLifecycle(sessionId: string): number { const nextGeneration = currentHistoryLifecycle(sessionId) + 1 historyLifecycleGenerations.set(sessionId, nextGeneration) + historyLoadsInFlight.get(sessionId)?.controller.abort() historyLoadsInFlight.delete(sessionId) + historyReloadControllers.get(sessionId)?.abort() + historyReloadControllers.delete(sessionId) + historyRecoveryControllers.get(sessionId)?.abort() + historyRecoveryControllers.delete(sessionId) + historyPageControllers.get(sessionId)?.abort() + historyPageControllers.delete(sessionId) terminalReconnectHistoryBoundaries.delete(sessionId) return nextGeneration } @@ -2625,7 +2706,41 @@ function shouldPrewarmSession(sessionId: string): boolean { return knownSession?.messageCount === 0 } -export const useChatStore = create((set, get) => ({ +export const useChatStore = create((setState, get) => { + const set = (update: Partial | ((state: ChatStore) => Partial)) => { + setState((previous) => { + const patch = typeof update === 'function' ? update(previous) : update + if (patch === previous || !patch.sessions) return patch + let sessions = patch.sessions + const sessionCount = Math.max(1, Object.keys(sessions).length) + const budget = Math.min(2 * 1024 * 1024, Math.floor(16 * 1024 * 1024 / sessionCount)) + const terminalLimit = Math.min(CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION, Math.floor(CHAT_TERMINAL_ACTIVITY_MAX_TOTAL / (2 * sessionCount))) + for (const [id, session] of Object.entries(sessions)) { + // Bound the aggregate as well as individual tabs. Operational state is + // retained even when an older tab's display history needs reloading. + const displayBudget = Math.floor(budget * 3 / 4) + const activityBudget = Math.floor(budget / 8) + const tasks = boundActivityText(session.backgroundAgentTasks, activityBudget, terminalLimit) + const notifications = boundActivityText(session.agentTaskNotifications, activityBudget, terminalLimit)! + const bounded = boundChatHistory(session.messages, session.historyBrowseMessages ? Math.floor(displayBudget / 2) : displayBudget) + const browse = session.historyBrowseMessages ? boundChatHistory(session.historyBrowseMessages, Math.floor(displayBudget / 2)).messages : undefined + const text = copyChatPreview(session.streamingText, CHAT_STREAM_MAX_CHARS, true) + const input = copyChatPreview(session.streamingToolInput, CHAT_STREAM_MAX_CHARS) + if (bounded.messages === session.messages && browse === session.historyBrowseMessages && text === session.streamingText && input === session.streamingToolInput && tasks === session.backgroundAgentTasks && notifications === session.agentTaskNotifications) continue + if (sessions === patch.sessions) sessions = { ...sessions } + sessions[id] = { + ...session, messages: bounded.messages, historyBrowseMessages: browse, streamingText: text, streamingToolInput: input, + backgroundAgentTasks: tasks, agentTaskNotifications: notifications, + historyWindowed: true, + ...(bounded.dropped && session.streamAttemptStartIndex !== undefined + ? { streamAttemptStartIndex: Math.max(0, session.streamAttemptStartIndex - bounded.dropped) } : {}), + } + } + return sessions === patch.sessions ? patch : { ...patch, sessions } + }) + } + return ({ + applyBoundedUpdate: set, sessions: {}, askUserQuestionDrafts: {}, @@ -3249,12 +3364,23 @@ export const useChatStore = create((set, get) => ({ }, loadHistory: async (sessionId, options) => { + if (historyPageControllers.has(sessionId)) { + historyPageControllers.get(sessionId)?.abort() + historyPageControllers.delete(sessionId) + set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: false })) })) + } const lifecycleGeneration = currentHistoryLifecycle(sessionId) const existingLoad = historyLoadsInFlight.get(sessionId) if (existingLoad?.lifecycleGeneration === lifecycleGeneration) { return existingLoad.promise } - if (existingLoad) historyLoadsInFlight.delete(sessionId) + if (existingLoad) { + existingLoad.controller.abort() + historyLoadsInFlight.delete(sessionId) + } + const controller = new AbortController() + historyRecoveryControllers.get(sessionId)?.abort() + historyRecoveryControllers.delete(sessionId) // Workflow runs are rebuilt from disk alongside the transcript. Without // this, reopening a session that ran a workflow showed no trace of it — @@ -3340,6 +3466,8 @@ export const useChatStore = create((set, get) => ({ }) const { uiMessages, + page, + historyComplete, activeGoal, hasRestoredGoalState, restoredNotifications, @@ -3350,6 +3478,7 @@ export const useChatStore = create((set, get) => ({ } = await fetchAndMapSessionHistory( sessionId, sessionOwnedActivityToolUseIds(get().sessions[sessionId]), + controller.signal, ) let historyApplied = false set((state) => { @@ -3491,6 +3620,11 @@ export const useChatStore = create((set, get) => ({ ) return { historyStatus: 'ready', + historyPage: page, + historyViewingOlder: false, + historyBrowseMessages: undefined, + historyWindowed: !historyComplete, + historyRecoveryStatus: historyComplete ? 'ready' : 'loading', historyHydrated: true, historyError: null, ...(shouldBackfillColdHistory ? { @@ -3505,7 +3639,7 @@ export const useChatStore = create((set, get) => ({ requestedActiveGoalRevision, activeGoal, hasRestoredGoalState, - discardBaselineMessages, + discardBaselineMessages && historyComplete, ), agentTaskNotifications, backgroundAgentTasks, @@ -3576,6 +3710,11 @@ export const useChatStore = create((set, get) => ({ ) return { historyStatus: 'ready', + historyPage: page, + historyViewingOlder: false, + historyBrowseMessages: undefined, + historyWindowed: !historyComplete, + historyRecoveryStatus: historyComplete ? 'ready' : 'loading', historyHydrated: true, historyError: null, ...(shouldBackfillColdHistory ? { @@ -3590,7 +3729,7 @@ export const useChatStore = create((set, get) => ({ requestedActiveGoalRevision, activeGoal, hasRestoredGoalState, - discardBaselineMessages, + discardBaselineMessages && historyComplete, ), agentTaskNotifications, backgroundAgentTasks, @@ -3604,6 +3743,7 @@ export const useChatStore = create((set, get) => ({ }) } }) if (!historyApplied) return + if (!historyComplete && page) void recoverSessionHistory(sessionId, page.sourceVersion) if ( terminalReconnectBoundary && !terminalReconnectBoundary.preHydrationGap && @@ -3615,7 +3755,7 @@ export const useChatStore = create((set, get) => ({ const taskStoreChangedWhileLoading = currentTaskStore.sessionId !== requestedTaskStoreSessionId || currentTaskStore.tasks !== requestedTasks - if (!taskStoreChangedWhileLoading) { + if (historyComplete && !taskStoreChangedWhileLoading) { if (lastTodos && lastTodos.length > 0) { if ( currentTaskStore.sessionId === sessionId && @@ -3690,14 +3830,54 @@ export const useChatStore = create((set, get) => ({ } })() - historyLoadsInFlight.set(sessionId, { lifecycleGeneration, promise: load }) + historyLoadsInFlight.set(sessionId, { lifecycleGeneration, promise: load, controller }) return load }, + loadOlderHistory: async (sessionId, latest = false) => { + const session = get().sessions[sessionId] + const cursor = session?.historyPage?.nextCursor + if (!session || (!latest && !cursor) || session.historyPageLoading) return + const lifecycle = currentHistoryLifecycle(sessionId) + const controller = new AbortController() + historyPageControllers.get(sessionId)?.abort() + historyPageControllers.set(sessionId, controller) + set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: true, historyError: null })) })) + try { + const result = await sessionsApi.getHistoryPage(sessionId, latest ? undefined : { cursor: cursor! }, { signal: controller.signal }) + if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return + const messages = mapHistoryMessagesToUiMessages(result.messages) + set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, (current) => ({ + ...(latest ? { + messages: mergeColdRestoredHistoryIntoLiveMessages(messages, current.messages), + historyBrowseMessages: undefined, + } : { historyBrowseMessages: messages }), + historyPage: result.page, historyViewingOlder: !latest, historyWindowed: true, + })) })) + if (latest && result.page && !result.page.historyComplete) void recoverSessionHistory(sessionId, result.page.sourceVersion) + } catch (error) { + if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return + set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyError: describeHistoryLoadError(error) })) })) + } finally { + if (historyPageControllers.get(sessionId) === controller) { + historyPageControllers.delete(sessionId) + set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: false })) })) + } + } + }, + reloadHistory: async (sessionId, guard) => { + if (historyPageControllers.has(sessionId)) { + historyPageControllers.get(sessionId)?.abort() + historyPageControllers.delete(sessionId) + set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: false })) })) + } const lifecycleGeneration = currentHistoryLifecycle(sessionId) const reloadGeneration = (historyReloadGenerations.get(sessionId) ?? 0) + 1 historyReloadGenerations.set(sessionId, reloadGeneration) + historyReloadControllers.get(sessionId)?.abort() + const controller = new AbortController() + historyReloadControllers.set(sessionId, controller) try { const sessionAtReloadStart = get().sessions[sessionId] const requestedMutationEpoch = sessionAtReloadStart?.historyMutationEpoch ?? 0 @@ -3705,7 +3885,9 @@ export const useChatStore = create((set, get) => ({ if (pendingLoad?.lifecycleGeneration === lifecycleGeneration) { await pendingLoad.promise } - if (!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration)) return + if (!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration) || controller.signal.aborted) return + historyRecoveryControllers.get(sessionId)?.abort() + historyRecoveryControllers.delete(sessionId) // A reload can queue behind a cold load. Capture snapshot baselines only // after that load settles so its REST state is not mistaken for a live // mutation that should override the newer reload response. @@ -3723,6 +3905,8 @@ export const useChatStore = create((set, get) => ({ ) const { uiMessages, + page, + historyComplete, activeGoal, restoredNotifications, restoredBackgroundTasks, @@ -3732,6 +3916,7 @@ export const useChatStore = create((set, get) => ({ } = await fetchAndMapSessionHistory( sessionId, sessionOwnedActivityToolUseIds(get().sessions[sessionId]), + controller.signal, ) if ( @@ -3803,9 +3988,14 @@ export const useChatStore = create((set, get) => ({ return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyStatus: 'ready', + historyPage: page, + historyViewingOlder: false, + historyBrowseMessages: undefined, + historyWindowed: !historyComplete, + historyRecoveryStatus: historyComplete ? 'ready' : 'loading', historyHydrated: true, historyError: null, - activeGoal: activeGoalChangedWhileLoading + activeGoal: activeGoalChangedWhileLoading || !historyComplete ? session.activeGoal ?? null : activeGoal, agentTaskNotifications, @@ -3835,6 +4025,7 @@ export const useChatStore = create((set, get) => ({ }) if (!historyApplied) return + if (!historyComplete && page) void recoverSessionHistory(sessionId, page.sourceVersion) const terminalReconnectBoundary = terminalReconnectHistoryBoundaries.get(sessionId) if ( terminalReconnectBoundary?.lifecycleGeneration === lifecycleGeneration && @@ -3882,7 +4073,7 @@ export const useChatStore = create((set, get) => ({ const taskStoreChangedWhileReloading = currentTaskStore.sessionId !== requestedTaskStoreSessionId || currentTaskStore.tasks !== requestedTasks - if (!taskStoreChangedWhileReloading) { + if (historyComplete && !taskStoreChangedWhileReloading) { if (lastTodos && lastTodos.length > 0) { currentTaskStore.setTasksFromTodos(lastTodos, sessionId) } else { @@ -3919,6 +4110,8 @@ export const useChatStore = create((set, get) => ({ )), } }) + } finally { + if (historyReloadControllers.get(sessionId) === controller) historyReloadControllers.delete(sessionId) } }, @@ -5330,6 +5523,10 @@ export const useChatStore = create((set, get) => ({ queuedUserMessages: [], historyMutationEpoch: (session?.historyMutationEpoch ?? 0) + 1, historyStatus: 'ready', + historyPage: undefined, + historyViewingOlder: false, + historyBrowseMessages: undefined, + historyWindowed: false, historyHydrated: true, historyError: null, })) @@ -5582,7 +5779,8 @@ export const useChatStore = create((set, get) => ({ break } }, -})) + }) +}) function updateOptimisticSessionTitle(sessionId: string, content: string): void { const title = deriveSessionTitle(content) diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index 2109f5b8..b3f15775 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -803,7 +803,7 @@ function syncMemberSessionMessages( memberIsWorking(member, snapshot) || unsettledReplies.length > 0 ) - useChatStore.setState((state) => { + useChatStore.getState().applyBoundedUpdate((state) => { const existing = state.sessions[sessionId] const nextState = existing ?? createMemberSessionState() const currentStreamRevision = nextState.agentStreamRevision ?? 0 @@ -919,7 +919,7 @@ function clearMemberSessionState(sessionIds: Iterable): void { initialMemberSessionLoads.delete(sessionId) awaitingMemberReplies.delete(sessionId) } - useChatStore.setState((state) => { + useChatStore.getState().applyBoundedUpdate((state) => { const sessions = { ...state.sessions } for (const sessionId of ids) delete sessions[sessionId] return { sessions } diff --git a/desktop/src/types/trace.ts b/desktop/src/types/trace.ts index 4d2fcaca..6573f435 100644 --- a/desktop/src/types/trace.ts +++ b/desktop/src/types/trace.ts @@ -87,6 +87,22 @@ export type TraceSessionSummary = { } export type TraceSession = { + window?: { + offset: number + limit: number + totalCalls: number + totalEvents: number + hasMore: boolean + revisionToken: string + state: 'ready' | 'indexing' | 'limited' + oversizedRecords: number + startByte: number + scannedBytes: number + fileBytes: number + recordLimit: number + recordBytesLimit: number + nextScanCursor?: string + } sessionId: string messageSignature?: string | null session?: { diff --git a/scripts/quality-gate/persistence-upgrade.ts b/scripts/quality-gate/persistence-upgrade.ts index 20bb0f10..6ed8ee40 100644 --- a/scripts/quality-gate/persistence-upgrade.ts +++ b/scripts/quality-gate/persistence-upgrade.ts @@ -28,6 +28,10 @@ const checks: Check[] = [ '--test-name-pattern', 'frozen v[45]', ], }, + { + title: 'Trace projection resource-window schema migrations', + command: ['bun', 'test', './src/server/services/localIndex/traceIndex.test.ts'], + }, { title: 'Server persistent JSON migrations', command: ['bun', 'test', './src/server/__tests__/persistence-upgrade.test.ts'], diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index a04f0b6e..cc48e638 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -1511,6 +1511,14 @@ describe('ConversationService', () => { expect(contextEstimate?.model).toBe('MiniMax-M3') expect(contextEstimate?.rawMaxTokens).toBe(1_000_000) + + // No transcript append: provider edits alone must invalidate inspection data. + await providerService.updateProvider(provider.id, { + modelContextWindows: { 'MiniMax-M3': 64_000 }, + }) + const changed = await svc.getInspectionTranscriptSnapshot(sessionId) + expect(changed?.contextEstimate?.rawMaxTokens).toBe(64_000) + expect(changed?.usage?.models[0]?.contextWindow).toBe(64_000) } finally { if (previousConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR @@ -2181,7 +2189,7 @@ describe('WebSocket Chat Integration', () => { return messages } - async function runTurnUntilComplete(sessionId: string, content: string): Promise { + async function runTurnUntilComplete(sessionId: string, content: string, onMessage?: (message: any) => void): Promise { const messages: any[] = [] const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) @@ -2194,6 +2202,7 @@ describe('WebSocket Chat Integration', () => { ws.onmessage = (e) => { const msg = JSON.parse(e.data as string) messages.push(msg) + onMessage?.(msg) if (msg.type === 'connected') { ws.send(JSON.stringify({ type: 'user_message', content })) } @@ -2970,9 +2979,19 @@ describe('WebSocket Chat Integration', () => { }, 15_000) it('should not add a CLI exit error after a reported SDK API error', async () => { + const sessionId = `chat-api-error-exit-${crypto.randomUUID()}` const messages = await runTurnUntilComplete( - `chat-api-error-exit-${crypto.randomUUID()}`, + sessionId, 'trigger api error then exit', + (message) => { + if (message.type === 'error' && message.code === 'invalid_request') { + // Prove the error was reported before asking the fixture to exit. + // Transport flushing alone does not settle the async SDK handler. + void conversationService.requestControl(sessionId, { + subtype: 'mock_exit_after_api_error_ack', + }).catch(() => undefined) + } + }, ) const errors = messages.filter((m) => m.type === 'error') @@ -3926,6 +3945,20 @@ describe('WebSocket Chat Integration', () => { const originalStartSession = conversationService.startSession.bind(conversationService) const originalSendMessage = conversationService.sendMessage.bind(conversationService) + const originalStopSession = conversationService.stopSession.bind(conversationService) + const originalAppendMetadata = sessionService.appendSessionMetadata.bind(sessionService) + let firstStartPending = true + let stoppedDuringFirstStart = false + let markRuntimePersisted!: () => void + const runtimePersisted = new Promise((resolve) => { markRuntimePersisted = resolve }) + conversationService.stopSession = ((sid: string) => { + if (sid === sessionId && firstStartPending) stoppedDuringFirstStart = true + return originalStopSession(sid) + }) as typeof conversationService.stopSession + sessionService.appendSessionMetadata = (async (sid, metadata) => { + await originalAppendMetadata(sid, metadata) + if (sid === sessionId && metadata.runtimeModelId === 'first-turn-sonnet') markRuntimePersisted() + }) as typeof sessionService.appendSessionMetadata const startCalls: Array<{ sessionId: string options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined @@ -3948,8 +3981,13 @@ describe('WebSocket Chat Integration', () => { ) { startCalls.push({ sessionId: sid, options }) if (startCalls.length === 1) { + // Expose a registered process while the public startup promise is + // deliberately unresolved. hasSession alone must not admit a restart. + await originalStartSession(sid, workDir, sdkUrl, options) markFirstStart() await firstStartGate + firstStartPending = false + return } return originalStartSession(sid, workDir, sdkUrl, options) }) as typeof conversationService.startSession @@ -3977,13 +4015,17 @@ describe('WebSocket Chat Integration', () => { if (msg.type === 'connected') { ws.send(JSON.stringify({ type: 'prewarm_session' })) - void firstStartEntered.then(() => { + void firstStartEntered.then(async () => { ws.send(JSON.stringify({ type: 'user_message', content: 'first turn while runtime changes' })) ws.send(JSON.stringify({ type: 'set_runtime_config', providerId: provider.id, modelId: 'first-turn-sonnet', })) + await runtimePersisted + // Drain the transition continuation after its controlled metadata + // write, without depending on a wall-clock delay or SDK timing. + await new Promise((resolve) => setImmediate(resolve)) releaseFirstStart() }) return @@ -4007,6 +4049,7 @@ describe('WebSocket Chat Integration', () => { } }) + expect(stoppedDuringFirstStart).toBe(false) expect(startCalls).toHaveLength(2) expect(startCalls[0]).toMatchObject({ sessionId }) expect(startCalls[0]?.options?.providerId).toBeNull() @@ -4026,6 +4069,9 @@ describe('WebSocket Chat Integration', () => { ws.close() conversationService.startSession = originalStartSession conversationService.sendMessage = originalSendMessage + conversationService.stopSession = originalStopSession + sessionService.appendSessionMetadata = originalAppendMetadata + releaseFirstStart() conversationService.stopSession(sessionId) } }, 20_000) @@ -4493,6 +4539,15 @@ describe('WebSocket Chat Integration', () => { const { sessionId } = await createRes.json() as { sessionId: string } const originalStartSession = conversationService.startSession.bind(conversationService) + let terminalSdkResultReceived = false + let restartedBeforeTerminalResult = false + const originalHandleSdkPayload = conversationService.handleSdkPayload.bind(conversationService) + const sdkPayloadSpy = spyOn(conversationService, 'handleSdkPayload').mockImplementation((sid, payload, options) => { + if (sid === sessionId && payload.split('\n').some((line) => { + try { return JSON.parse(line).type === 'result' } catch { return false } + })) terminalSdkResultReceived = true + originalHandleSdkPayload(sid, payload, options) + }) const startCalls: Array<{ sessionId: string options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined @@ -4504,6 +4559,7 @@ describe('WebSocket Chat Integration', () => { sdkUrl: string, options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { + if (startCalls.length > 0 && !terminalSdkResultReceived) restartedBeforeTerminalResult = true startCalls.push({ sessionId: sid, options }) return originalStartSession(sid, workDir, sdkUrl, options) }) as typeof conversationService.startSession @@ -4568,7 +4624,8 @@ describe('WebSocket Chat Integration', () => { if (msg.type === 'message_complete' && switchTriggered && !turnComplete) { turnComplete = true - expect(startCalls).toHaveLength(1) + // The server may already have restarted by the time this queued + // client event arrives; ordering is checked at SDK receipt above. return } @@ -4585,6 +4642,7 @@ describe('WebSocket Chat Integration', () => { } }) + expect(restartedBeforeTerminalResult).toBe(false) expect(startCalls).toHaveLength(2) expect(startCalls[0]).toMatchObject({ sessionId, @@ -4602,6 +4660,7 @@ describe('WebSocket Chat Integration', () => { }) } finally { ws.close() + sdkPayloadSpy.mockRestore() conversationService.startSession = originalStartSession conversationService.stopSession(sessionId) } @@ -4647,6 +4706,15 @@ describe('WebSocket Chat Integration', () => { const { sessionId } = await createRes.json() as { sessionId: string } const originalStartSession = conversationService.startSession.bind(conversationService) + let terminalSdkResultReceived = false + let restartedBeforeTerminalResult = false + const originalHandleSdkPayload = conversationService.handleSdkPayload.bind(conversationService) + const sdkPayloadSpy = spyOn(conversationService, 'handleSdkPayload').mockImplementation((sid, payload, options) => { + if (sid === sessionId && payload.split('\n').some((line) => { + try { return JSON.parse(line).type === 'result' } catch { return false } + })) terminalSdkResultReceived = true + originalHandleSdkPayload(sid, payload, options) + }) const startCalls: Array<{ sessionId: string options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined @@ -4658,6 +4726,7 @@ describe('WebSocket Chat Integration', () => { sdkUrl: string, options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { + if (startCalls.length > 0 && !terminalSdkResultReceived) restartedBeforeTerminalResult = true startCalls.push({ sessionId: sid, options }) if (startCalls.length > 1) { throw new Error('deferred restart failed') @@ -4706,7 +4775,8 @@ describe('WebSocket Chat Integration', () => { if (msg.type === 'message_complete' && switchTriggered && !turnComplete) { turnComplete = true - expect(startCalls).toHaveLength(1) + // The server may already have restarted by the time this queued + // client event arrives; ordering is checked at SDK receipt above. return } @@ -4739,6 +4809,7 @@ describe('WebSocket Chat Integration', () => { expect(switchTriggered).toBe(true) expect(turnComplete).toBe(true) expect(restartError).toBe(true) + expect(restartedBeforeTerminalResult).toBe(false) expect(startCalls).toHaveLength(2) expect(startCalls[1]).toMatchObject({ sessionId, @@ -4749,6 +4820,7 @@ describe('WebSocket Chat Integration', () => { }) } finally { ws.close() + sdkPayloadSpy.mockRestore() conversationService.startSession = originalStartSession conversationService.stopSession(sessionId) } diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts index f6a85277..49058119 100644 --- a/src/server/__tests__/fixtures/mock-sdk-cli.ts +++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts @@ -275,6 +275,9 @@ ws.addEventListener('message', (event) => { void (async () => { for (const line of lines) { const parsed = JSON.parse(line) + if (parsed.type === 'control_request' && parsed.request?.subtype === 'mock_exit_after_api_error_ack') { + process.exit(1) + } if (parsed.type === 'user') { sendInit() @@ -340,7 +343,10 @@ ws.addEventListener('message', (event) => { session_id: sessionId, }) if (text.includes('then exit')) { - setTimeout(() => process.exit(1), 10) + // Wait for the test client's acknowledgment that the API error + // was reported. A 10ms exit could outrun the async SDK handler. + // Bound fixture lifetime if that acknowledgment never arrives. + setTimeout(() => process.exit(1), 5_000) continue } emit(ws, { diff --git a/src/server/__tests__/session-messages-http.test.ts b/src/server/__tests__/session-messages-http.test.ts index be444f37..ceb087de 100644 --- a/src/server/__tests__/session-messages-http.test.ts +++ b/src/server/__tests__/session-messages-http.test.ts @@ -11,15 +11,17 @@ * These tests fail if that flag is dropped. */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { handleApiRequest } from '../router.js' +import { sessionService } from '../services/sessionService.js' const SUBAGENT_SENTINEL = 'SUBPAGENT_ONLY_SENTINEL_read_alpha' let tmpDir: string +let previousConfig: string | undefined async function api(method: string, pathname: string): Promise { const url = new URL(pathname, 'http://localhost:3456') @@ -123,16 +125,76 @@ async function seedSessionWithSubagent(): Promise { } beforeEach(async () => { + previousConfig = process.env.CLAUDE_CONFIG_DIR tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-messages-http-')) process.env.CLAUDE_CONFIG_DIR = tmpDir }) afterEach(async () => { - delete process.env.CLAUDE_CONFIG_DIR + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfig await fs.rm(tmpDir, { recursive: true, force: true }) }) describe('session messages HTTP surface', () => { + it('bounds both public history endpoints and returns a continuation without canonical hydration', async () => { + const sessionId = await seedSessionWithSubagent() + const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`) + const content = 'x'.repeat(256 * 1024) + for (let n = 0; n < 20; n++) await fs.appendFile(filePath, JSON.stringify({ type: 'assistant', uuid: `large-${n}`, timestamp: '2026-01-02T00:00:00Z', message: { role: 'assistant', content } }) + '\n') + const full = spyOn(sessionService, 'getSession').mockImplementation(() => { throw new Error('canonical hydration is forbidden on UI routes') }) + try { + for (const suffix of ['/messages', '']) { + const response = await api('GET', `/api/sessions/${sessionId}${suffix}`) + expect(response.status).toBe(200) + const raw = await response.text() + expect(Buffer.byteLength(raw)).toBeLessThan(2 * 1024 * 1024) + const body = JSON.parse(raw) + expect(body.page.hasMore).toBe(true) + expect(body.page.nextCursor).toBeString() + expect(body.messages.at(-1).id).toBe('large-19') + } + expect(full).not.toHaveBeenCalled() + } finally { full.mockRestore() } + }) + + it('rejects oversized automatic checkpoint previews before canonical history loading', async () => { + const sessionId = await seedSessionWithSubagent() + const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`) + await fs.truncate(filePath, 17 * 1024 * 1024) + const canonical = spyOn(sessionService, 'getSessionMessagesWithEvidence').mockImplementation(() => { throw new Error('full history must not be hydrated') }) + try { + const response = await api('GET', `/api/sessions/${sessionId}/turn-checkpoints`) + expect(response.status).toBe(413) + expect((await response.json() as { error: string }).error).toBe('HISTORY_CHECKPOINT_PREVIEW_LIMIT') + expect(canonical).not.toHaveBeenCalled() + } finally { canonical.mockRestore() } + }) + + it('returns messages and task notifications without a second transcript scan', async () => { + const sessionId = await seedSessionWithSubagent() + await fs.appendFile(path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`), + JSON.stringify({ + type: 'cc-haha-task-notification', + taskNotification: { taskId: 'task-1', toolUseId: 'Agent:0', status: 'completed', summary: 'Done' }, + timestamp: '2026-01-01T00:02:00.000Z', + }) + '\n') + const separateNotifications = spyOn(sessionService, 'getSessionTaskNotifications') + try { + const response = await api('GET', `/api/sessions/${sessionId}/messages`) + expect(response.status).toBe(200) + const body = await response.json() as { messages: unknown[]; taskNotifications: unknown[] } + expect(body.messages.length).toBeGreaterThan(0) + expect(body.taskNotifications).toEqual([{ + taskId: 'task-1', toolUseId: 'Agent:0', status: 'completed', summary: 'Done', + timestamp: '2026-01-01T00:02:00.000Z', + }]) + expect(separateNotifications).not.toHaveBeenCalled() + } finally { + separateNotifications.mockRestore() + } + }) + it('never inlines linked subagent tool messages in /messages', async () => { const sessionId = await seedSessionWithSubagent() diff --git a/src/server/__tests__/session-summary.test.ts b/src/server/__tests__/session-summary.test.ts index ca78a7de..81227e0d 100644 --- a/src/server/__tests__/session-summary.test.ts +++ b/src/server/__tests__/session-summary.test.ts @@ -46,6 +46,30 @@ function requestSummary(sessionId: string, method = 'GET') { } describe('session summary', () => { + it('shares unchanged title metadata scans across trace polling and session summaries', async () => { + const fixture = await seedSession() + const service = new SessionService() + const internals = service as unknown as { + scanSessionListSummary: (...args: unknown[]) => Promise + } + const scan = spyOn(internals, 'scanSessionListSummary') + try { + const [first, second] = await Promise.all([ + service.getSessionTitleAndMeta(fixture.filePath), + service.getSessionTitleAndMeta(fixture.filePath), + ]) + expect(first).toEqual(second) + expect(first.title).toBe('Old conversation') + expect((await service.getSessionSummary(SESSION_ID))?.title).toBe(first.title) + expect(scan).toHaveBeenCalledTimes(1) + await fs.appendFile(fixture.filePath, `${JSON.stringify({ type: 'custom-title', customTitle: 'Updated title' })}\n`) + expect((await service.getSessionTitleAndMeta(fixture.filePath)).title).toBe('Updated title') + expect(scan).toHaveBeenCalledTimes(2) + } finally { + scan.mockRestore() + } + }) + it('reads only the selected old transcript with list-equivalent runtime and workspace metadata', async () => { const fixture = await seedSession() await seedSession('12870001-bbbb-cccc-dddd-eeeeeeeeeeee') diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 6abff1da..7d0bdd92 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1736,6 +1736,96 @@ describe('SessionService', () => { .toHaveLength(0) }) + it('shares bounded inspection across metadata/context/usage readers and invalidates after append', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-inspection-budget' + await writeSessionFile(projectDir, sessionId, [makeSessionMetaEntry('/tmp/inspection'), makeUserEntry('x'.repeat(256 * 1024), crypto.randomUUID())]) + const fullRead = spyOn(service as any, 'readJsonlFile').mockImplementation(() => { throw new Error('unbounded history read') }) + const stream = spyOn(service as any, 'streamJsonlFile') + try { + await Promise.all([service.getTranscriptMetadata(sessionId), service.getTranscriptContextEstimate(sessionId), service.getTranscriptUsage(sessionId)]) + expect(fullRead).not.toHaveBeenCalled() + expect(stream).toHaveBeenCalledTimes(1) + await fs.appendFile(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`), JSON.stringify({ ...makeUserEntry('next', crypto.randomUUID()), cwd: '/tmp/new-inspection' }) + '\n') + expect((await service.getTranscriptMetadata(sessionId))?.cwd).toBe('/tmp/new-inspection') + expect(stream).toHaveBeenCalledTimes(2) + } finally { fullRead.mockRestore(); stream.mockRestore() } + }) + + it('reports oversized inspection records rather than returning partial authoritative state', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + await writeSessionFile('-tmp-inspection-limit', sessionId, [makeUserEntry('x'.repeat(2 * 1024 * 1024), crypto.randomUUID())]) + await expect(service.getTranscriptMetadata(sessionId)).rejects.toMatchObject({ statusCode: 413, code: 'HISTORY_INSPECTION_LIMIT' }) + }) + + it('coalesces overlapping history reads without retaining stale history', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-project' + await writeSessionFile(projectDir, sessionId, [makeUserEntry('hello', crypto.randomUUID())]) + const readSpy = spyOn(service as any, 'readJsonlFileWithDiagnostics') + try { + const histories = await Promise.all([ + service.getSessionHistory(sessionId), + service.getSessionHistory(sessionId), + ]) + expect(readSpy).toHaveBeenCalledTimes(1) + expect(histories[0]).toEqual(histories[1]) + expect(histories[0]!.messages).toHaveLength(1) + await fs.appendFile(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`), + `${JSON.stringify(makeUserEntry('new message', crypto.randomUUID()))}\n`) + expect((await service.getSessionHistory(sessionId)).messages).toHaveLength(2) + expect(readSpy).toHaveBeenCalledTimes(2) + } finally { + readSpy.mockRestore() + } + }) + + it('streams transcript diagnostics while preserving malformed and missing evidence', async () => { + const filePath = path.join(tmpDir, 'diagnostics.jsonl') + await fs.writeFile(filePath, '{"type":"user"}\nmalformed\n\n{"type":"assistant"}') + const readSpy = spyOn(fs, 'readFile') + try { + const result = await (service as any).readJsonlFileWithDiagnostics(filePath) + expect(result).toEqual({ + entries: [{ type: 'user' }, { type: 'assistant' }], + exists: true, + parseComplete: false, + }) + expect(await (service as any).readJsonlFileWithDiagnostics(`${filePath}.missing`)).toEqual({ + entries: [], exists: false, parseComplete: false, + }) + expect(readSpy).not.toHaveBeenCalled() + } finally { + readSpy.mockRestore() + } + }) + + it('computes stable message signatures without reading transcript payloads', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-project' + await writeSessionFile(projectDir, sessionId, [makeUserEntry('hello', crypto.randomUUID())]) + const child = await writeSubagentTranscriptFile(projectDir, sessionId, 'abc123', []) + const streamSpy = spyOn(service as any, 'streamJsonlFile') + const readSpy = spyOn(fs, 'readFile') + try { + const before = await service.getSessionMessagesSignature(sessionId) + expect(await service.getSessionMessagesSignature(sessionId)).toBe(before) + await fs.appendFile(child, 'child append\n') + const appended = await service.getSessionMessagesSignature(sessionId) + expect(appended).not.toBe(before) + await fs.rm(child) + const removed = await service.getSessionMessagesSignature(sessionId) + expect(removed).not.toBe(appended) + await fs.appendFile(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`), 'root append\n') + expect(await service.getSessionMessagesSignature(sessionId)).not.toBe(removed) + expect(streamSpy).not.toHaveBeenCalled() + expect(readSpy).not.toHaveBeenCalled() + } finally { + streamSpy.mockRestore() + readSpy.mockRestore() + } + }) + it('should include linked subagent transcript changes in the message signature', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const projectDir = '-tmp-project' diff --git a/src/server/__tests__/team-watcher.test.ts b/src/server/__tests__/team-watcher.test.ts index 24d7ab78..0832db97 100644 --- a/src/server/__tests__/team-watcher.test.ts +++ b/src/server/__tests__/team-watcher.test.ts @@ -2,8 +2,9 @@ * Unit tests for TeamWatcher — real-time team status push via WebSocket */ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test' import * as fs from 'node:fs/promises' +import * as syncFs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' import type { ServerMessage } from '../ws/events.js' @@ -664,3 +665,20 @@ describe('TeamWatcher broadcast', () => { await cleanupTmpDir() }) }) + +// A sparse fixture catches read-all-then-slice without retaining a large test string. +it('reads only the subagent name prefix of a large transcript', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'watcher-prefix-')) + const file = path.join(directory, 'agent.jsonl') + await fs.writeFile(file, JSON.stringify({ agentName: 'bounded-agent' }) + '\n') + await fs.truncate(file, 64 * 1024 * 1024) + const readAll = spyOn(syncFs, 'readFileSync').mockImplementation(() => { throw new Error('Unbounded transcript read') }) + try { + const watcher = new TeamWatcher() + expect((watcher as unknown as { extractSubagentName(path: string): string | null }).extractSubagentName(file)).toBe('bounded-agent') + expect(readAll).not.toHaveBeenCalled() + } finally { + readAll.mockRestore() + await fs.rm(directory, { recursive: true, force: true }) + } +}) diff --git a/src/server/__tests__/teams.test.ts b/src/server/__tests__/teams.test.ts index 85f5d84d..b470030b 100644 --- a/src/server/__tests__/teams.test.ts +++ b/src/server/__tests__/teams.test.ts @@ -2,7 +2,7 @@ * Unit tests for TeamService and Teams API */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' @@ -14,7 +14,7 @@ import { teamIncarnationId, } from '../services/teamService.js' import type { TeamWorkbenchSnapshot } from '../services/teamService.js' -import type { MessageEntry } from '../services/sessionService.js' +import { sessionService, type MessageEntry } from '../services/sessionService.js' import * as lockfile from '../../utils/lockfile.js' import { getSessionCreatedTeams } from '../../bootstrap/state.js' import { @@ -1219,6 +1219,28 @@ describe('TeamService', () => { expect(reopened?.snapshots.at(-1)).toEqual(live) }) + it('uses the bounded incremental Team projection instead of canonical history during workbench polling', async () => { + const filePath = path.join(tmpDir, 'poll-session.jsonl') + const unrelated = JSON.stringify({ uuid: 'ordinary', message: { role: 'assistant', content: 'x'.repeat(8192) } }) + '\n' + await fs.writeFile(filePath, unrelated.repeat(640)) + const canonical = spyOn(sessionService, 'getSessionMessages').mockImplementation(async () => { throw new Error('canonical history must not be loaded') }) + service = new TeamService({ sessionLocator: { findSessionFile: async () => ({ filePath, projectDir: '-tmp-project' }) } }) + try { + expect(await service.getWorkbenchForSession('poll-session')).toBeNull() + expect(await service.getWorkbenchForSession('poll-session')).toBeNull() + await fs.appendFile(filePath, JSON.stringify({ uuid: 'team-create', timestamp: '2026-01-01T00:00:00Z', message: { role: 'assistant', content: [{ type: 'tool_use', id: 'create-team', name: 'TeamCreate', input: { team_name: 'bounded-team' } }] } }) + '\n') + expect(await service.getWorkbenchForSession('poll-session')).toMatchObject({ teamName: 'bounded-team', source: 'transcript' }) + expect(canonical).not.toHaveBeenCalled() + } finally { canonical.mockRestore() } + }) + + it('reports an incomplete Team projection instead of pretending the session has no team', async () => { + const filePath = path.join(tmpDir, 'oversized-team.jsonl') + await fs.writeFile(filePath, JSON.stringify({ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'team', name: 'TeamCreate', input: { team_name: 'large', description: 'x'.repeat(2 * 1024 * 1024) } }] } }) + '\n') + service = new TeamService({ sessionLocator: { findSessionFile: async () => ({ filePath, projectDir: '-tmp-project' }) } }) + await expect(service.getWorkbenchForSession('oversized-team')).rejects.toMatchObject({ statusCode: 503, code: 'TEAM_HISTORY_INCOMPLETE' }) + }) + it('reconciles the final task tail from the matching Team incarnation before deletion', async () => { const teamName = 'delete-before-next-poll' const leadSessionId = 'delete-before-next-poll-lead' diff --git a/src/server/__tests__/trace-capture.test.ts b/src/server/__tests__/trace-capture.test.ts index 601a0e9b..7445d1db 100644 --- a/src/server/__tests__/trace-capture.test.ts +++ b/src/server/__tests__/trace-capture.test.ts @@ -1338,6 +1338,34 @@ describe('session trace API', () => { expect(body.events).toEqual([]) }) + test('streams original trace bytes, rejects oversized detail, and validates overview offsets', async () => { + const sessionId = 'route-resource-bounds' + const traceDir = path.join(tmpDir, 'cc-haha', 'traces') + await fs.mkdir(traceDir, { recursive: true }) + const raw = Buffer.from(JSON.stringify({ type: 'call', record: { + id: 'huge-call', sessionId, source: 'proxy', startedAt: '2026-01-01T00:00:00Z', + request: { method: 'POST', url: 'https://example.test', headers: {}, body: { + contentType: 'text', bytes: 3 * 1024 * 1024, sha256: '', truncated: false, preview: '字'.repeat(1024 * 1024), + } }, + } }) + '\n') + const filePath = path.join(traceDir, `${sessionId}.jsonl`) + await fs.writeFile(filePath, raw) + const rawRequest = new Request(`http://localhost:3456/api/sessions/${sessionId}/trace/raw`) + const download = await handleApiRequest(rawRequest, new URL(rawRequest.url)) + expect(download.status).toBe(200) + expect(download.headers.get('content-disposition')).toContain('attachment') + expect(Buffer.from(await download.arrayBuffer())).toEqual(raw) + const detailRequest = new Request(`http://localhost:3456/api/sessions/${sessionId}/trace/calls/huge-call`) + const detail = await handleApiRequest(detailRequest, new URL(detailRequest.url)) + expect(detail.status).toBe(413) + expect(await detail.json()).toMatchObject({ error: 'TRACE_RECORD_TOO_LARGE' }) + for (const offset of ['-1', '1.5', 'Infinity', '9007199254740992']) { + const request = new Request(`http://localhost:3456/api/sessions/${sessionId}/trace?offset=${offset}`) + expect((await handleApiRequest(request, new URL(request.url))).status).toBe(400) + } + expect(await fs.readFile(filePath)).toEqual(raw) + }) + test('serves the session trace overview from the index without rereading the JSONL', async () => { const recorded = await traceCaptureService.recordCall({ sessionId: 'session-trim-api', @@ -1996,6 +2024,57 @@ describe('trace read cache', () => { } }) + test('rebuilds cold trace summaries with bounded reads instead of hydrating the whole file', async () => { + const sessionId = 'session-streamed-projection' + const traceDir = path.join(tmpDir, 'cc-haha', 'traces') + const filePath = path.join(traceDir, `${sessionId}.jsonl`) + await fs.mkdir(traceDir, { recursive: true }) + // Each UTF-8 record crosses chunk boundaries; the final version of call-a + // must win without changing its first insertion order. + const first = buildTraceCallLine('call-a', sessionId, '中文'.repeat(100_000)) + const second = buildTraceCallLine('call-b', sessionId, 'x'.repeat(300_000)) + const final = buildTraceCallLine('call-a', sessionId, 'updated') + await fs.writeFile(filePath, first + second + final + '{"incomplete":') + clearTraceCaptureStateForTests() + const originalOpen = mutableFs.open.bind(mutableFs) + let fullFileReads = 0 + const readLengths: number[] = [] + const openSpy = spyOn(mutableFs, 'open').mockImplementation(async (...args) => { + const handle = await originalOpen(...args) + if (String(args[0]) !== filePath) return handle + return new Proxy(handle, { + get(target, property) { + if (property === 'readFile') { + return async (...readArgs: Parameters) => { + fullFileReads += 1 + return target.readFile(...readArgs) + } + } + if (property === 'read') { + return async (buffer: Uint8Array, offset: number, length: number, position: number) => { + readLengths.push(length) + return target.read(buffer, offset, length, position) + } + } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + try { + const overview = await traceCaptureService.getSessionTraceOverview(sessionId) + expect(overview.summary.apiCalls).toBe(2) + expect(overview.calls.map(call => call.id)).toEqual(['call-a', 'call-b']) + expect(fullFileReads).toBe(0) + expect(readLengths.length).toBeGreaterThan(3) + expect(Math.max(...readLengths)).toBeLessThanOrEqual(256 * 1024) + const call = await traceCaptureService.getSessionTraceCall(sessionId, 'call-a') + expect(call?.request.body.preview).toContain('updated') + } finally { + openSpy.mockRestore() + } + }) + test('projects an external append from the stored boundary without rereading the prefix', async () => { const traceDir = path.join(tmpDir, 'cc-haha', 'traces') const filePath = path.join(traceDir, 'session-projection-external-append.jsonl') @@ -2118,6 +2197,43 @@ describe('trace read cache', () => { } }) + test.skipIf(process.platform === 'win32')('rebuilds when an append target is replaced after change detection with identical sampled windows', async () => { + const sessionId = 'session-append-replaced-inode' + const dir = path.join(tmpDir, 'cc-haha', 'traces') + const filePath = path.join(dir, `${sessionId}.jsonl`) + await fs.mkdir(dir, { recursive: true }) + const middle = JSON.parse(buildTraceCallLine('middle', sessionId)) + middle.record.model = 'model-old' + const original = buildTraceCallLine('first', sessionId, 'x'.repeat(100_000)) + + JSON.stringify(middle) + '\n' + + buildTraceCallLine('last', sessionId, 'x'.repeat(100_000)) + await fs.writeFile(filePath, original) + await traceCaptureService.getSessionTraceOverview(sessionId) + const append = buildTraceCallLine('appended', sessionId) + await fs.appendFile(filePath, append) + const replacementPath = `${filePath}.replacement` + await fs.writeFile(replacementPath, original.replace('model-old', 'model-new') + append) + const originalOpen = mutableFs.open.bind(mutableFs) + let opens = 0 + let replaced = false + const openSpy = spyOn(mutableFs, 'open').mockImplementation(async (...args) => { + if (String(args[0]) === filePath && ++opens === 1) { + await fs.rename(replacementPath, filePath) + replaced = true + } + return originalOpen(...args) + }) + try { + const overview = await traceCaptureService.getSessionTraceOverview(sessionId) + expect(replaced).toBe(true) + expect(overview.summary.models).toContainEqual({ model: 'model-new', calls: 1 }) + expect(overview.summary.models).not.toContainEqual({ model: 'model-old', calls: 1 }) + expect(overview.calls).toHaveLength(4) + } finally { + openSpy.mockRestore() + } + }) + test('invalidates a projection after a same-size rewrite with restored mtime', async () => { const traceDir = path.join(tmpDir, 'cc-haha', 'traces') const filePath = path.join(traceDir, 'session-projection-same-size.jsonl') diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index f16cccec..bb56f84e 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -9,7 +9,6 @@ import { __markPrewarmedForTests, __resetWebSocketHandlerStateForTests, __resolveRuntimeRestartWorkDirForTests, - __settleActiveTurnForTests, closeSessionConnection, getActiveSessionIds, handleWebSocket, diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts index 4de31224..10b49199 100644 --- a/src/server/__tests__/workspace-service.test.ts +++ b/src/server/__tests__/workspace-service.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises' import { execFileSync } from 'node:child_process' import * as os from 'node:os' import * as path from 'node:path' +import { ApiError } from '../middleware/errorHandler.js' import { WorkspaceService } from '../services/workspaceService.js' import { clearFilesystemAccessRootsForTests, @@ -144,6 +145,15 @@ describe('WorkspaceService outside-workspace preview', () => { }) describe('WorkspaceService', () => { + it('surfaces bounded transcript failures instead of reporting a clean workspace', async () => { + const workDir = await makeTempDir('workspace-history-budget-') + const failure = new ApiError(413, 'History viewing budget exceeded', 'HISTORY_WORKSPACE_LIMIT') + const messages = new WorkspaceService(async () => workDir, async () => { throw failure }) + const snapshots = new WorkspaceService(async () => workDir, async () => [], async () => { throw failure }) + await expect(messages.getStatus('session')).rejects.toBe(failure) + await expect(snapshots.getStatus('session')).rejects.toBe(failure) + }) + it('returns git status for modified, added, deleted, and untracked files', async () => { const repoDir = await createGitWorkspace() const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 6fa7da1e..09086989 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -80,8 +80,12 @@ const workspaceService = new WorkspaceService( conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId) ), - async (sessionId) => sessionService.getSessionMessages(sessionId), - async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId), + async (sessionId) => { + const recovery = await sessionService.getSessionHistoryRecovery(sessionId) + if (!(recovery.completeness?.workspace ?? recovery.status === 'ready')) throw new ApiError(413, 'Workspace transcript exceeds the viewing budget', 'HISTORY_WORKSPACE_LIMIT') + return recovery.messages + }, + async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId, { bounded: true }), ) const reviewService = new ReviewService(async (sessionId) => ( @@ -193,7 +197,11 @@ export async function handleSessionsApi( { status: 405 } ) } - return await getSessionMessages(sessionId) + return await getSessionMessages(req, sessionId, url) + } + + if (subResource === 'history-recovery' && req.method === 'GET') { + return Response.json(await sessionService.getSessionHistoryRecovery(sessionId, { signal: req.signal })) } if (subResource === 'trace') { @@ -203,9 +211,18 @@ export async function handleSessionsApi( { status: 405 } ) } + if (segments[4] === 'raw') { + const source = await traceCaptureService.getSessionTraceFile(sessionId) + if (!source) throw ApiError.notFound(`Trace not found: ${sessionId}`) + return new Response(Bun.file(source.path), { headers: { + 'content-type': 'application/x-ndjson', + 'content-disposition': 'attachment; filename="trace.jsonl"', + 'cache-control': 'no-store', + } }) + } return segments[4] === 'calls' ? await getSessionTraceCall(sessionId, segments[5]) - : await getSessionTrace(sessionId) + : await getSessionTrace(req, sessionId, url) } if (subResource === 'git-info') { @@ -399,6 +416,11 @@ export async function handleSessionsApi( ) } } catch (error) { + const code = (error as { code?: string } | null)?.code + const status = code === 'TRACE_PAGE_STALE' || code === 'HISTORY_PAGE_STALE' ? 409 + : code === 'TRACE_RECORD_TOO_LARGE' ? 413 + : code === 'TRACE_INDEX_BUSY' ? 503 : code === 'HISTORY_QUEUE_FULL' ? 429 : undefined + if (status) return errorResponse(new ApiError(status, error instanceof Error ? error.message : code!, code)) return errorResponse(error) } } @@ -448,27 +470,29 @@ async function listSessions(req: Request, url: URL): Promise { } async function getSession(sessionId: string): Promise { - // Browser/HTTP callers render the timeline, where the linked subagent tool - // stream is fetched per Agent card. Merging it in here is what pushed a real - // session past the 536,870,888-character response limit. - const detail = await sessionService.getSession(sessionId, { includeSubagents: false }) - if (!detail) { - throw ApiError.notFound(`Session not found: ${sessionId}`) - } - return Response.json(detail) -} - -async function getSessionMessages(sessionId: string): Promise { - const [messages, taskNotifications] = await Promise.all([ - sessionService.getSessionMessages(sessionId, { includeSubagents: false }), - sessionService.getSessionTaskNotifications(sessionId), + const [summary, history] = await Promise.all([ + sessionService.getSessionSummary(sessionId), + sessionService.getSessionHistoryPage(sessionId), ]) - return Response.json({ messages, taskNotifications }) + if (!summary) throw ApiError.notFound(`Session not found: ${sessionId}`) + return Response.json({ ...summary, ...history }) } -async function getSessionTrace(sessionId: string): Promise { +async function getSessionMessages(req: Request, sessionId: string, url: URL): Promise { + return Response.json(await sessionService.getSessionHistoryPage(sessionId, { + cursor: url.searchParams.get('cursor') ?? undefined, + signal: req.signal, + })) +} + +async function getSessionTrace(req: Request, sessionId: string, url: URL): Promise { const [trace, sessionMeta, messageSignature] = await Promise.all([ - traceCaptureService.getSessionTraceOverview(sessionId), + traceCaptureService.getSessionTraceOverview(sessionId, { + offset: parseTracePageOffset(url), + revisionToken: url.searchParams.get('revisionToken') ?? undefined, + scanCursor: url.searchParams.get('scanCursor') ?? undefined, + signal: req.signal, + }), getSessionTraceMeta(sessionId), sessionService.getSessionMessagesSignature(sessionId), ]) @@ -487,6 +511,14 @@ async function getSessionTrace(sessionId: string): Promise { }) } +function parseTracePageOffset(url: URL): number { + const value = url.searchParams.get('offset') ?? '0' + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) { + throw ApiError.badRequest('Invalid trace offset') + } + return Number(value) +} + async function getSessionTraceMeta(sessionId: string): Promise<{ title: string projectPath: string @@ -1004,7 +1036,7 @@ async function getSessionInspection(req: Request, sessionId: string, url: URL): let transcriptSnapshot: Awaited> | undefined const getTranscriptSnapshot = async () => { if (transcriptSnapshot !== undefined) return transcriptSnapshot - transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId).catch(() => null) + transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId) return transcriptSnapshot } @@ -1427,7 +1459,15 @@ async function branchSession(req: Request, sessionId: string): Promise } } +async function assertCheckpointPreviewBudget(sessionId: string): Promise { + const found = await sessionService.findSessionFile(sessionId) + if (found && Bun.file(found.filePath).size > 16 * 1024 * 1024) { + throw new ApiError(413, 'This transcript exceeds the full checkpoint preview budget. Chat history remains available in pages.', 'HISTORY_CHECKPOINT_PREVIEW_LIMIT') + } +} + async function getTurnCheckpoints(req: Request, sessionId: string): Promise { + await assertCheckpointPreviewBudget(sessionId) const checkpoints = await listSessionTurnCheckpoints(sessionId, req.signal, new URL(req.url).searchParams.get('frozen') === 'true') // Make this turn's real changed files previewable even when they live outside // the session workdir (e.g. the user told the model to write to an absolute @@ -1441,6 +1481,7 @@ async function getTurnCheckpoints(req: Request, sessionId: string): Promise { + await assertCheckpointPreviewBudget(sessionId) const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined const userMessageIndexParam = url.searchParams.get('userMessageIndex') const path = url.searchParams.get('path') diff --git a/src/server/services/boundedSessionHistory.test.ts b/src/server/services/boundedSessionHistory.test.ts new file mode 100644 index 00000000..20c6a64a --- /dev/null +++ b/src/server/services/boundedSessionHistory.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile, appendFile, open, rename } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readBoundedHistoryPage, streamBoundedHistory, withHistoryReadBudget, HISTORY_SCAN_BYTES, HISTORY_RECORD_BYTES } from './boundedSessionHistory.js' + +let directory: string +let file: string +beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'history-budget-test-')); file = join(directory, 'session.jsonl') }) +afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +const row = (id: string, text = id) => JSON.stringify({ type: 'assistant', uuid: id, message: { role: 'assistant', content: [{ type: 'text', text }] } }) + '\n' + +describe('bounded history pages', () => { + test('reads a bounded tail and pages every ordinary large record without loss', async () => { + const handle = await open(file, 'w') + for (let index = 0; index < 60; index++) await handle.write(row(String(index), 'x'.repeat(256 * 1024))) + await handle.close() + let cursor: string | undefined + const ids: string[] = [] + do { + const result = await readBoundedHistoryPage(file, { cursor }) + expect(result.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES) + expect(result.page.omittedOversizedEntries).toBe(0) + expect(result.entries.length).toBeGreaterThan(0) + ids.unshift(...result.entries.map(item => item.entry.uuid as string)) + cursor = result.page.nextCursor ?? undefined + } while (cursor) + expect(ids).toEqual(Array.from({ length: 60 }, (_, index) => String(index))) + }) + + test('skips giant lines across bounded windows and preserves both neighboring messages', async () => { + await writeFile(file, row('before')) + const handle = await open(file, 'a') + await handle.write('{"message":"') + for (let index = 0; index < 12; index++) await handle.write('x'.repeat(1024 * 1024)) + await handle.write('"}\n' + row('after')) + await handle.close() + let cursor: string | undefined + const ids: string[] = [] + let omissions = 0 + let requests = 0 + do { + const result = await readBoundedHistoryPage(file, { cursor }) + expect(result.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES) + omissions += result.page.omittedOversizedEntries + ids.unshift(...result.entries.map(item => item.entry.uuid as string)) + cursor = result.page.nextCursor ?? undefined + requests++ + expect(requests).toBeLessThan(8) + } while (cursor) + expect(ids).toEqual(['before', 'after']) + expect(omissions).toBe(1) + }) + + test('snapshot cursors tolerate appends but reject replacement and malformed cursors', async () => { + await writeFile(file, row('a') + row('b')) + const first = await readBoundedHistoryPage(file, { limit: 1 }) + await appendFile(file, row('c')) + const second = await readBoundedHistoryPage(file, { cursor: first.page.nextCursor! }) + expect(second.entries.map(item => item.entry.uuid)).toEqual(['a']) + await writeFile(`${file}.new`, row('replacement')) + await rename(`${file}.new`, file) + await expect(readBoundedHistoryPage(file, { cursor: first.page.nextCursor! })).rejects.toMatchObject({ statusCode: 409 }) + await expect(readBoundedHistoryPage(file, { cursor: 'garbage' })).rejects.toThrow('Invalid history cursor') + }) + + test('forward recovery bounds single records, parses surrounding evidence, and aborts promptly', async () => { + await writeFile(file, row('a') + row('too-large', 'x'.repeat(HISTORY_RECORD_BYTES + 100)) + row('b')) + const ids: unknown[] = [] + const result = await streamBoundedHistory(file, entry => ids.push(entry.uuid)) + expect(ids).toEqual(['a', 'b']) + expect(result.omittedRecords).toBe(1) + const controller = new AbortController() + let visits = 0 + await expect(streamBoundedHistory(file, () => { visits++; controller.abort() }, controller.signal)).rejects.toThrow() + expect(visits).toBe(1) + }) + + test('rejects queue overflow and removes aborted waiters without starving later reads', async () => { + let release!: () => void + const hold = new Promise(resolve => { release = resolve }) + const active = [withHistoryReadBudget(undefined, () => hold), withHistoryReadBudget(undefined, () => hold)] + const controller = new AbortController() + const cancelled = withHistoryReadBudget(controller.signal, async () => 'unreachable').catch(error => error) + const queued = Array.from({ length: 7 }, () => withHistoryReadBudget(undefined, async () => 'ok')) + await expect(withHistoryReadBudget(undefined, async () => 'overflow')).rejects.toMatchObject({ statusCode: 429 }) + controller.abort() + await cancelled + const replacement = withHistoryReadBudget(undefined, async () => 'replacement') + release() + await Promise.all(active) + expect(await Promise.all(queued)).toEqual(Array(7).fill('ok')) + expect(await replacement).toBe('replacement') + }) +}) diff --git a/src/server/services/boundedSessionHistory.ts b/src/server/services/boundedSessionHistory.ts new file mode 100644 index 00000000..ba37c79f --- /dev/null +++ b/src/server/services/boundedSessionHistory.ts @@ -0,0 +1,211 @@ +import { open } from 'node:fs/promises' +import { ApiError } from '../middleware/errorHandler.js' + +export const HISTORY_SCAN_BYTES = 4 * 1024 * 1024 +export const HISTORY_RECORD_BYTES = 1024 * 1024 +export const HISTORY_PAGE_BYTES = 1536 * 1024 +export const HISTORY_PAGE_RECORDS = 100 + +type Cursor = { version: 1; dev: string; ino: string; size: number; mtime: string; offset: number; skipping: boolean } +export type HistoryPageInfo = { + nextCursor: string | null + hasMore: boolean + historyComplete: boolean + sourceVersion: string + scannedBytes: number + contextScanBytes?: number + omittedOversizedEntries: number +} +export type BoundedHistoryEntry = { entry: Record; byteStart: number; byteEnd: number } + +function aborted(signal?: AbortSignal) { + if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError') +} + +// A fixed admission budget prevents repeated tab switches from queuing unlimited +// scans. Waiters are removed on abort, not just ignored when their scan completes. +type Waiter = { start: () => void; reject: (reason: unknown) => void; signal?: AbortSignal; abort: () => void } +const pools = { + page: { active: 0, capacity: 2, queueCapacity: 8, waiting: [] as Waiter[] }, + context: { active: 0, capacity: 1, queueCapacity: 4, waiting: [] as Waiter[] }, + recovery: { active: 0, capacity: 1, queueCapacity: 2, waiting: [] as Waiter[] }, + metadata: { active: 0, capacity: 2, queueCapacity: 128, waiting: [] as Waiter[] }, +} +async function acquire(signal: AbortSignal | undefined, lane: keyof typeof pools): Promise<() => void> { + aborted(signal) + const pool = pools[lane] + if (pool.active >= pool.capacity) { + if (pool.waiting.length >= pool.queueCapacity) throw new ApiError(429, 'History reader is busy; retry shortly', 'HISTORY_BUSY') + await new Promise((resolve, reject) => { + const item: Waiter = { start: resolve, reject, signal, abort: () => {} } + item.abort = () => { + const index = pool.waiting.indexOf(item) + if (index >= 0) pool.waiting.splice(index, 1) + reject(signal?.reason ?? new DOMException('Aborted', 'AbortError')) + } + pool.waiting.push(item) + signal?.addEventListener('abort', item.abort, { once: true }) + }) + } else pool.active++ + return () => { + const next = pool.waiting.shift() + if (next) { + next.signal?.removeEventListener('abort', next.abort) + next.start() + } else pool.active-- + } +} + +export async function withHistoryReadBudget(signal: AbortSignal | undefined, read: () => Promise, lane: keyof typeof pools = 'page'): Promise { + const release = await acquire(signal, lane) + try { aborted(signal); return await read() } finally { release() } +} + +function decodeCursor(value: string): Cursor { + try { + if (value.length > 2048) throw new Error('long cursor') + const cursor = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Cursor + if (cursor.version !== 1 || typeof cursor.dev !== 'string' || typeof cursor.ino !== 'string' || typeof cursor.mtime !== 'string' || + !Number.isSafeInteger(cursor.size) || cursor.size < 0 || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || cursor.offset > cursor.size || typeof cursor.skipping !== 'boolean') throw new Error('invalid cursor') + return cursor + } catch { throw ApiError.badRequest('Invalid history cursor') } +} + +export async function readBoundedHistoryPage(filePath: string, options: { cursor?: string; limit?: number; signal?: AbortSignal } = {}): Promise<{ entries: BoundedHistoryEntry[]; page: HistoryPageInfo }> { + if (options.limit !== undefined && (!Number.isFinite(options.limit) || options.limit < 1)) throw new ApiError(400, 'History limit must be a positive finite number', 'INVALID_HISTORY_LIMIT') + return withHistoryReadBudget(options.signal, async () => { + const handle = await open(filePath, 'r') + try { + aborted(options.signal) + const stat = await handle.stat({ bigint: true }) + const current = { dev: String(stat.dev), ino: String(stat.ino), size: Number(stat.size), mtime: String(stat.mtimeNs) } + const cursor: Cursor = options.cursor ? decodeCursor(options.cursor) : { version: 1, ...current, offset: current.size, skipping: false } + if (cursor.dev !== current.dev || cursor.ino !== current.ino || current.size < cursor.size || (current.size === cursor.size && current.mtime !== cursor.mtime)) { + throw new ApiError(409, 'Session history changed; reload the newest page', 'HISTORY_CHANGED') + } + const sourceVersion = `${cursor.dev}:${cursor.ino}:${cursor.size}:${cursor.mtime}` + const length = Math.min(HISTORY_SCAN_BYTES, cursor.offset) + const start = cursor.offset - length + const buffer = Buffer.allocUnsafe(length) + let read = 0 + while (read < length) { + aborted(options.signal) + const result = await handle.read(buffer, read, length - read, start + read) + if (!result.bytesRead) throw new ApiError(409, 'Session history changed during read', 'HISTORY_CHANGED') + read += result.bytesRead + } + const limit = Math.max(1, Math.min(HISTORY_PAGE_RECORDS, Math.floor(options.limit ?? HISTORY_PAGE_RECORDS))) + const entries: BoundedHistoryEntry[] = [] + let position = length + let outputBytes = 0 + let skipping = cursor.skipping + let omitted = 0 + while (position > 0 && entries.length < limit) { + aborted(options.signal) + const lineEnd = buffer[position - 1] === 10 ? position - 1 : position + const newline = buffer.lastIndexOf(10, lineEnd - 1) + const lineStart = newline + 1 + const lineLength = lineEnd - lineStart + if (newline < 0 && start > 0) { + // Retry a small boundary-straddling record on the next page. A record + // spanning the whole scan budget is skipped without ever assembling it. + if (!skipping && lineLength <= HISTORY_RECORD_BYTES && entries.length > 0) break + if (!skipping) omitted++ + skipping = true + position = 0 + break + } + if (skipping) { + skipping = false + position = lineStart + continue + } + if (lineLength > HISTORY_RECORD_BYTES) { + omitted++ + position = lineStart + continue + } + if (lineLength && outputBytes + lineLength > HISTORY_PAGE_BYTES && entries.length) break + position = lineStart + if (!lineLength) continue + try { + const entry = JSON.parse(buffer.subarray(lineStart, lineEnd).toString('utf8')) + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + entries.push({ entry, byteStart: start + lineStart, byteEnd: start + lineEnd }) + outputBytes += lineLength + } + } catch { omitted++ } + // Each individual parse has a byte ceiling; also yield between records + // so a large page cannot monopolize the shared server event loop. + await new Promise(resolve => setImmediate(resolve)) + } + const offset = start + position + const after = await handle.stat({ bigint: true }) + if (after.ino !== stat.ino || after.size < stat.size || (after.size === stat.size && after.mtimeNs !== stat.mtimeNs)) throw new ApiError(409, 'Session history changed during read', 'HISTORY_CHANGED') + const nextCursor = offset > 0 ? Buffer.from(JSON.stringify({ ...cursor, offset, skipping })).toString('base64url') : null + return { + entries: entries.reverse(), + page: { nextCursor, hasMore: offset > 0, historyComplete: !options.cursor && offset === 0 && omitted === 0, sourceVersion, scannedBytes: read, omittedOversizedEntries: omitted }, + } + } finally { await handle.close() } + }) +} + +/** Forward reducer source: never retain an unbounded JSONL line or source file. */ +export async function streamBoundedHistory(filePath: string, onEntry: (entry: Record, completeLine: boolean, byteStart: number) => void, signal?: AbortSignal, options: { startOffset?: number; endOffset?: number; onSkipped?: () => void } = {}): Promise<{ sourceVersion: string; omittedRecords: number; oversizedRecords: number; scannedBytes: number; nextOffset: number }> { + const handle = await open(filePath, 'r') + try { + const stat = await handle.stat({ bigint: true }) + const size = Math.min(Number(stat.size), options.endOffset ?? Number(stat.size)) + const firstOffset = options.startOffset ?? 0 + let lineStart = firstOffset + let nextOffset = firstOffset + const chunk = Buffer.allocUnsafe(64 * 1024) + let parts: Buffer[] = [] + let length = 0 + let skipping = false + let omittedRecords = 0 + let oversizedRecords = 0 + const flush = (completeLine = true) => { + if (skipping) { omittedRecords++; oversizedRecords++; options.onSkipped?.() } + else if (length) { + let entry: unknown + try { + entry = JSON.parse((parts.length === 1 ? parts[0]! : Buffer.concat(parts, length)).toString('utf8')) + } catch { omittedRecords++; options.onSkipped?.() } + // Consumer failures (limits, cancellation, I/O) must propagate. They + // are not malformed JSON and must never become a successful snapshot. + if (entry && typeof entry === 'object' && !Array.isArray(entry)) onEntry(entry as Record, completeLine, lineStart) + } + parts = []; length = 0; skipping = false + } + for (let offset = firstOffset; offset < size;) { + aborted(signal) + const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - offset), offset) + if (!bytesRead) throw new ApiError(409, 'Session history changed during recovery', 'HISTORY_CHANGED') + offset += bytesRead + let start = 0 + while (start < bytesRead) { + aborted(signal) + const found = chunk.indexOf(10, start) + const end = found >= 0 && found < bytesRead ? found : bytesRead + if (!skipping) { + length += end - start + if (length > HISTORY_RECORD_BYTES) { skipping = true; parts = [] } + else parts.push(Buffer.from(chunk.subarray(start, end))) + } + start = end + 1 + if (end < bytesRead) { + flush() + lineStart = offset - bytesRead + end + 1 + nextOffset = lineStart + await new Promise(resolve => setImmediate(resolve)) + } + } + } + if (length || skipping) flush(false) + const after = await handle.stat({ bigint: true }) + if (after.size < stat.size || (after.size === stat.size && after.mtimeNs !== stat.mtimeNs)) throw new ApiError(409, 'Session changed during recovery; retry', 'HISTORY_CHANGED') + return { sourceVersion: `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`, omittedRecords, oversizedRecords, scannedBytes: size - firstOffset, nextOffset } + } finally { await handle.close() } +} diff --git a/src/server/services/legacyTranscriptBudget.test.ts b/src/server/services/legacyTranscriptBudget.test.ts new file mode 100644 index 00000000..763e0ed3 --- /dev/null +++ b/src/server/services/legacyTranscriptBudget.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LEGACY_TRANSCRIPT_BYTES, readLegacyTranscriptFiles } from './legacyTranscriptBudget.js' + +test('rejects oversized single and combined fragments before body allocation', async () => { + const dir = await mkdtemp(join(tmpdir(), 'legacy-history-budget-')) + try { + const first = join(dir, 'first.jsonl') + const second = join(dir, 'second.jsonl') + await writeFile(first, '{}\n') + expect((await readLegacyTranscriptFiles([first]))[0]!.bytes.toString()).toBe('{}\n') + await truncate(first, 500 * 1024 * 1024) + await expect(readLegacyTranscriptFiles([first])).rejects.toMatchObject({ statusCode: 413, code: 'TEAM_TRANSCRIPT_TOO_LARGE' }) + await truncate(first, LEGACY_TRANSCRIPT_BYTES / 2 + 1) + await writeFile(second, '') + await truncate(second, LEGACY_TRANSCRIPT_BYTES / 2 + 1) + await expect(readLegacyTranscriptFiles([first, second])).rejects.toMatchObject({ statusCode: 413 }) + } finally { await rm(dir, { recursive: true, force: true }) } +}) diff --git a/src/server/services/legacyTranscriptBudget.ts b/src/server/services/legacyTranscriptBudget.ts new file mode 100644 index 00000000..41cf05e8 --- /dev/null +++ b/src/server/services/legacyTranscriptBudget.ts @@ -0,0 +1,35 @@ +import type { Stats } from 'node:fs' +import { open } from 'node:fs/promises' +import { ApiError } from '../middleware/errorHandler.js' + +export const LEGACY_TRANSCRIPT_BYTES = 8 * 1024 * 1024 + +/** Legacy parsers may materialize only a small, fixed aggregate source budget. */ +export async function readLegacyTranscriptFiles(paths: string[]): Promise> { + if (paths.length > 128) throw new ApiError(413, 'Too many transcript fragments for legacy history', 'TEAM_TRANSCRIPT_TOO_LARGE') + const handles: Awaited>[] = [] + try { + const sources = [] + let total = 0 + for (const filePath of paths) { + const handle = await open(filePath, 'r') + handles.push(handle) + const stat = await handle.stat() + total += stat.size + if (total > LEGACY_TRANSCRIPT_BYTES) throw new ApiError(413, 'Transcript exceeds the legacy history budget; use paged session history', 'TEAM_TRANSCRIPT_TOO_LARGE') + sources.push({ handle, stat }) + } + const result = [] + for (const source of sources) { + const bytes = Buffer.allocUnsafe(source.stat.size) + let position = 0 + while (position < bytes.length) { + const { bytesRead } = await source.handle.read(bytes, position, Math.min(128 * 1024, bytes.length - position), position) + if (!bytesRead) throw new ApiError(409, 'Transcript changed while reading', 'HISTORY_CHANGED') + position += bytesRead + } + result.push({ bytes, stat: source.stat }) + } + return result + } finally { await Promise.all(handles.map(handle => handle.close())) } +} diff --git a/src/server/services/localIndex/sessionProjector.test.ts b/src/server/services/localIndex/sessionProjector.test.ts index 1a24ea8d..46afd542 100644 --- a/src/server/services/localIndex/sessionProjector.test.ts +++ b/src/server/services/localIndex/sessionProjector.test.ts @@ -18,6 +18,8 @@ import { openLocalIndexDatabase } from './database.js' import { createSessionIndex, type SessionIndex } from './sessionIndex.js' import { SESSION_SUMMARY_PARSER_VERSION, + MAX_PROJECTION_RECORD_BYTES, + MAX_PROJECTION_RECORDS, createSessionProjector, type SessionSourceCandidate, } from './sessionProjector.js' @@ -86,6 +88,41 @@ async function sourceHash(path: string): Promise { } describe('session projector', () => { + it('rejects oversized records before concatenation and preserves the canonical file', async () => { + const root = await createTempDir('projector-record-budget') + const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'large', content: line(user('x'.repeat(MAX_PROJECTION_RECORD_BYTES + 1), '2026-01-01T00:00:00Z')) }) + const before = await sourceHash(candidate.path) + const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) + try { + const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root }) + await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' }) + expect(await sourceHash(candidate.path)).toBe(before) + } finally { database.close() } + }) + + it('bounds locator and reducer growth for arbitrarily many tiny records', async () => { + const root = await createTempDir('projector-count-budget') + const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'many', content: line({ type: 'unknown' }).repeat(MAX_PROJECTION_RECORDS + 1) }) + const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) + try { + const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root }) + await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' }) + } finally { database.close() } + }) + + it('keeps the record budget across incremental appends', async () => { + const root = await createTempDir('projector-append-budget') + const item = line({ type: 'unknown' }) + const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'append', content: item.repeat(MAX_PROJECTION_RECORDS) }) + const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) + try { + const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root }) + expect((await projector.projectSource(candidate)).kind).toBe('indexed') + await appendFile(candidate.path, item) + await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' }) + } finally { database.close() } + }) + it('fully builds without changing JSONL and preserves malformed/pending semantics', async () => { const root = await createTempDir('projector-full') const candidate = await createCandidate({ @@ -900,4 +937,35 @@ describe('session projector', () => { database.close() } }) + it('rejects oversized retained identities while accepting large discarded bodies', async () => { + const root = await createTempDir('metadata-budget') + const candidate = await createCandidate({ root, projectPath: '-repo-a', sessionId: 'metadata', + content: line({ ...user('x'.repeat(2 * 1024 * 1024), '2026-01-01T00:00:00.000Z'), uuid: 'ordinary' }), + }) + const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) + const index = createSessionIndex(database) + const projector = createSessionProjector({ database, index, scope: root }) + try { + expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed' }) + await appendFile(candidate.path, line({ ...user('small', '2026-01-01T00:00:01.000Z'), uuid: 'x'.repeat(4097) })) + await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' }) + } finally { database.close() } + }) + + it('enforces aggregate retained metadata across appended windows', async () => { + const root = await createTempDir('metadata-total-budget') + const record = (index: number) => line({ ...user('small', '2026-01-01T00:00:00.000Z'), uuid: `${index}:` + 'x'.repeat(3000) }) + const candidate = await createCandidate({ root, projectPath: '-repo-a', sessionId: 'metadata-total', + content: Array.from({ length: 3000 }, (_, index) => record(index)).join(''), + }) + const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) + const index = createSessionIndex(database) + const projector = createSessionProjector({ database, index, scope: root }) + try { + expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed' }) + await appendFile(candidate.path, Array.from({ length: 3000 }, (_, index) => record(index + 3000)).join('')) + await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' }) + } finally { database.close() } + }) + }) diff --git a/src/server/services/localIndex/sessionProjector.ts b/src/server/services/localIndex/sessionProjector.ts index 46820b97..d047356e 100644 --- a/src/server/services/localIndex/sessionProjector.ts +++ b/src/server/services/localIndex/sessionProjector.ts @@ -122,6 +122,20 @@ type SourceProjectionBundle = { locatorWrite: 'append' | 'replace' } +export const MAX_PROJECTION_RECORD_BYTES = 8 * 1024 * 1024 +export const MAX_PROJECTION_RECORDS = 50_000 +export const MAX_PROJECTION_METADATA_BYTES = 16 * 1024 * 1024 +const projectionMetadataBytes = new WeakMap() +const projectionRecordCounts = new WeakMap() +const MAX_CACHED_PROJECTIONS = 8 + +class ProjectionLimitError extends Error { + readonly code = 'LOCAL_INDEX_SOURCE_LIMIT' + constructor() { + super('Transcript exceeds the bounded local-index projection budget') + } +} + const READ_BUFFER_BYTES = 64 * 1024 const REDUCE_CHUNK_LIMIT = 256 const REDUCE_BYTE_LIMIT = 1024 * 1024 @@ -207,6 +221,7 @@ function initialProjection(candidate: SessionSourceCandidate): TranscriptProject } function retryFrom(error: unknown): Extract { + if (error instanceof ProjectionLimitError) throw error if (error instanceof SourceReadRetryError) return error.change return { kind: 'retry', reason: 'transient-io' } } @@ -251,6 +266,8 @@ async function streamProjection(options: { let pendingSegmentsLength = 0 let chunks: TranscriptChunk[] = [] let chunkBytes = 0 + let recordsRead = projectionRecordCounts.get(options.seed) ?? 0 + let metadataBytes = projectionMetadataBytes.get(options.seed) ?? 0 const entryLocators: TranscriptEntryLocator[] = [] const flush = (): void => { @@ -260,7 +277,48 @@ async function streamProjection(options: { const reduced = reduceTranscriptWithLocators( chunks, projection, - { isSubagent: options.isSubagent }, + { + isSubagent: options.isSubagent, + validateRetainedMetadata(entry) { + const fields = ['type', 'uuid', 'messageId', 'timestamp', 'parent_tool_use_id', + 'cwd', 'workDir', 'runtimeProviderId', 'runtimeModelId', 'customTitle', 'aiTitle', + 'repository', 'worktreeSession', 'requestId', 'version', 'sessionId'] + const message = entry.message as Record | undefined + const values: unknown[] = fields.map(field => entry[field]) + values.push(message?.id, message?.role, message?.model) + const content = message?.content + if (Array.isArray(content)) for (const block of content) { + if (block?.type !== 'tool_use') continue + values.push(block.name, block.name === 'Skill' ? block.input?.skill : undefined) + if (values.length > 16_384) throw new ProjectionLimitError() + } + const iterations = (message?.usage as { iterations?: unknown } | undefined)?.iterations + if (Array.isArray(iterations)) for (const iteration of iterations) { + if (iteration?.type === 'advisor_message') values.push(iteration.model) + if (values.length > 16_384) throw new ProjectionLimitError() + } + // Charge only metadata that the reducer/locators can retain. Large text + // and tool bodies are deliberately excluded from this lifetime budget. + let visited = 0 + while (values.length) { + const value = values.pop() + if (value === undefined || value === null) continue + if (++visited > 16_384) throw new ProjectionLimitError() + metadataBytes += 64 + if (typeof value === 'string') { + if (value.length > 4096) throw new ProjectionLimitError() + metadataBytes += Buffer.byteLength(value) + } else if (typeof value === 'object') { + for (const key in value) { + metadataBytes += Buffer.byteLength(key) + values.push((value as Record)[key]) + if (values.length > 16_384) throw new ProjectionLimitError() + } + } + if (metadataBytes > MAX_PROJECTION_METADATA_BYTES) throw new ProjectionLimitError() + } + }, + }, ) projection = reduced.projection entryLocators.push(...reduced.locators) @@ -282,6 +340,7 @@ async function streamProjection(options: { const newline = bytes.indexOf(0x0a, segmentStart) if (newline === -1) { const segment = bytes.subarray(segmentStart) + if (pendingSegmentsLength + segment.length > MAX_PROJECTION_RECORD_BYTES) throw new ProjectionLimitError() pendingSegments.push(segment) pendingSegmentsLength += segment.length work.maxBufferedBytes = Math.max( @@ -292,6 +351,9 @@ async function streamProjection(options: { } const finalSegment = bytes.subarray(segmentStart, newline + 1) + if (pendingSegmentsLength + finalSegment.length > MAX_PROJECTION_RECORD_BYTES) throw new ProjectionLimitError() + recordsRead += 1 + if (recordsRead > MAX_PROJECTION_RECORDS) throw new ProjectionLimitError() let completeLine: Buffer if (pendingSegments.length === 0) { completeLine = finalSegment @@ -322,6 +384,8 @@ async function streamProjection(options: { } } position += bytesRead + // File reads may resolve from cache; explicitly let UI/API work run between chunks. + await new Promise(resolve => setImmediate(resolve)) } flush() @@ -358,10 +422,13 @@ async function streamProjection(options: { } options.assertActive() + projectionMetadataBytes.set(projection, metadataBytes) + projectionRecordCounts.set(projection, recordsRead) return { projection, entryLocators, work } } catch (error) { thrown = error if ( + error instanceof ProjectionLimitError || error instanceof SourceReadRetryError || error instanceof TranscriptRebuildRequiredError || error instanceof ProjectionGenerationCancelledError @@ -814,7 +881,11 @@ export function createSessionProjector(options: SessionProjectorOptions): Sessio } throw error } + projectionCache.delete(candidate.path) projectionCache.set(candidate.path, built.projection) + while (projectionCache.size > MAX_CACHED_PROJECTIONS) { + projectionCache.delete(projectionCache.keys().next().value!) + } return { kind: 'indexed', action, ...built } }, @@ -958,7 +1029,11 @@ export function createSessionProjector(options: SessionProjectorOptions): Sessio } throw error } + projectionCache.delete(candidate.path) projectionCache.set(candidate.path, built.projection) + while (projectionCache.size > MAX_CACHED_PROJECTIONS) { + projectionCache.delete(projectionCache.keys().next().value!) + } return { kind: 'indexed', action, ...built } }, diff --git a/src/server/services/localIndex/traceDatabase.ts b/src/server/services/localIndex/traceDatabase.ts index 3b5cfd9b..d8ed3281 100644 --- a/src/server/services/localIndex/traceDatabase.ts +++ b/src/server/services/localIndex/traceDatabase.ts @@ -33,6 +33,7 @@ export type TraceIndexDatabase = { } type OwnedStatement = { + finalize(): void get(...bindings: TraceIndexBinding[]): unknown all(...bindings: TraceIndexBinding[]): unknown[] run(...bindings: TraceIndexBinding[]): { @@ -150,8 +151,11 @@ export function openTraceIndexDatabase(options?: { }, close() { if (closed) return - database.clearQueryCache() + // Bun may evict a query from its internal cache while our cache still owns + // it. Finalize every owned statement before the strict SQLite close. + for (const statement of statements.values()) statement.finalize() statements.clear() + database.clearQueryCache() database.close(true) closed = true }, diff --git a/src/server/services/localIndex/traceIndex.test.ts b/src/server/services/localIndex/traceIndex.test.ts index 51f04e44..f6f05ff4 100644 --- a/src/server/services/localIndex/traceIndex.test.ts +++ b/src/server/services/localIndex/traceIndex.test.ts @@ -87,6 +87,45 @@ describe('trace index', () => { expect(allColumns.map(row => row.name)).not.toContain('pending_tail') }) + test('aggregates large replacement and append batches once after resolving duplicate calls', async () => { + await createTestIndex() + const target = database! + let summaryUpdates = 0 + const index = createTraceIndex({ + ...target, + transaction: callback => target.transaction(operation => callback({ + ...operation, + run(statement, ...bindings) { + if (statement.includes('UPDATE trace_sessions SET')) summaryUpdates += 1 + return operation.run(statement, ...bindings) + }, + })), + }) + const calls = Array.from({ length: 2_000 }, (_, ordinal) => ({ + id: `call-${ordinal}`, ordinal, byteStart: ordinal, byteLength: 1, + startedAt: '2026-07-15T01:00:00.000Z', completedAt: null, + status: 'ok', source: 'proxy', model: ordinal % 2 ? 'model-b' : 'model-a', + durationMs: 2, failed: false, inputTokens: 3, outputTokens: 4, + })) + const source = { sessionId: 'batch', filePath: '/tmp/batch.jsonl', size: 2_002, indexedBytes: 2_002, mtimeMs: 1 } + index.replaceSession({ source, calls, events: [] }) + expect(summaryUpdates).toBeLessThanOrEqual(2) + expect(index.getSummary('batch')?.summary).toMatchObject({ + apiCalls: 2_000, totalInputTokens: 6_000, + models: [{ model: 'model-a', calls: 1_000 }, { model: 'model-b', calls: 1_000 }], + }) + summaryUpdates = 0 + index.appendEntries({ source, calls: [ + { ...calls[0]!, ordinal: 2_000, byteStart: 2_000, failed: true, inputTokens: 5 }, + { ...calls[1]!, ordinal: 2_001, byteStart: 2_001, model: 'model-a' }, + ], events: [] }) + expect(summaryUpdates).toBeLessThanOrEqual(2) + expect(index.getSummary('batch')?.summary).toMatchObject({ + apiCalls: 2_000, failedCalls: 1, totalInputTokens: 6_002, + models: [{ model: 'model-a', calls: 1_001 }, { model: 'model-b', calls: 999 }], + }) + }) + test('keeps the latest call locator and exposes revision-based changes without duplicates', async () => { const index = await createTestIndex() @@ -285,7 +324,7 @@ describe('trace index', () => { const index = createTraceIndex(database) expect(database.read(operation => operation.get<{ user_version: number }>('PRAGMA user_version'))) - .toEqual({ user_version: 5 }) + .toEqual({ user_version: 6 }) expect(index.getSession('frozen')).toMatchObject({ revision: 7, lastResetRevision: 3, @@ -352,6 +391,11 @@ describe('trace index', () => { const frozen = new Database(databasePath) frozen.exec(` + DROP INDEX trace_calls_page_order_idx; + DROP INDEX trace_calls_model_order_idx; + ALTER TABLE trace_sources DROP COLUMN oversized_records; + ALTER TABLE trace_sources DROP COLUMN scan_truncated; + ALTER TABLE trace_sources DROP COLUMN window_start_byte; DROP INDEX trace_session_models_order_idx; ALTER TABLE trace_session_models DROP COLUMN first_started_at; ALTER TABLE trace_session_models DROP COLUMN first_ordinal; @@ -370,14 +414,14 @@ describe('trace index', () => { const migrated = createTraceIndex(candidate) expect(candidate.read(operation => operation.get<{ user_version: number }>( 'PRAGMA user_version', - ))).toEqual({ user_version: 5 }) + ))).toEqual({ user_version: 6 }) expect(migrated.getSummary(`frozen-v${version}`)?.summary.models).toEqual([ { model: 'z-model', calls: 1 }, { model: 'a-model', calls: 1 }, ]) expect(migrated.getSource(`frozen-v${version}`)).toMatchObject({ state: 'degraded', - lastErrorCode: 'TRACE_INDEX_V5_REBUILD_REQUIRED', + lastErrorCode: 'TRACE_INDEX_V6_REBUILD_REQUIRED', }) expect(candidate.read(operation => operation.all<{ ordinal: number @@ -390,6 +434,35 @@ describe('trace index', () => { } }) + test('migrates a v5 fixture to bounded windows while preserving existing rows and unknown tables', async () => { + const index = await createTestIndex() + index.replaceSession({ + source: { sessionId: 'v5', filePath: '/tmp/v5.jsonl', size: 100, indexedBytes: 100, mtimeMs: 1 }, + calls: [], events: [], + }) + database!.write(operation => operation.exec(` + CREATE TABLE future_extension (value TEXT); + INSERT INTO future_extension VALUES ('preserve-me'); + DROP INDEX trace_calls_page_order_idx; + DROP INDEX trace_calls_model_order_idx; + ALTER TABLE trace_sources DROP COLUMN oversized_records; + ALTER TABLE trace_sources DROP COLUMN scan_truncated; + ALTER TABLE trace_sources DROP COLUMN window_start_byte; + PRAGMA user_version = 5; + `)) + database!.close() + database = openTraceIndexDatabase({ path: path.join(tmpDir!, 'trace-index-v1.sqlite') }) + const upgraded = createTraceIndex(database) + expect(upgraded.getSource('v5')).toMatchObject({ + size: 100, indexedBytes: 100, oversizedRecords: 0, scanTruncated: false, windowStartByte: 0, + state: 'degraded', lastErrorCode: 'TRACE_INDEX_V6_REBUILD_REQUIRED', + }) + expect(database.read(operation => operation.get<{ value: string }>('SELECT value FROM future_extension'))) + .toEqual({ value: 'preserve-me' }) + expect(database.read(operation => operation.get<{ user_version: number }>('PRAGMA user_version'))) + .toEqual({ user_version: 6 }) + }) + test('rolls back every v4 schema change when migration fails partway through', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-index-v4-rollback-')) const databasePath = path.join(tmpDir, 'trace-index-v3-broken.sqlite') @@ -397,7 +470,12 @@ describe('trace index', () => { initial.close() const frozen = new Database(databasePath) frozen.exec(` - DROP INDEX trace_session_models_order_idx; + DROP INDEX trace_calls_page_order_idx; + DROP INDEX trace_calls_model_order_idx; + ALTER TABLE trace_sources DROP COLUMN oversized_records; + ALTER TABLE trace_sources DROP COLUMN scan_truncated; + ALTER TABLE trace_sources DROP COLUMN window_start_byte; + DROP INDEX trace_session_models_order_idx; ALTER TABLE trace_session_models DROP COLUMN first_started_at; ALTER TABLE trace_session_models DROP COLUMN first_ordinal; ALTER TABLE trace_calls DROP COLUMN first_ordinal; @@ -416,3 +494,14 @@ describe('trace index', () => { inspected.close() }) }) + +test('strictly closes owned statements after Bun query-cache eviction', async () => { + await createTestIndex() + // Bun's internal query cache is bounded; our owned cache can outlive its entries. + for (let index = 0; index < 256; index += 1) { + expect(database!.read(operation => operation.get<{ value: number }>(`SELECT ${index} AS value`))).toEqual({ value: index }) + } + expect(() => database!.close()).not.toThrow() + expect(() => database!.close()).not.toThrow() + expect(() => database!.read(operation => operation.get('SELECT 1'))).toThrow('Trace index database is closed') +}) diff --git a/src/server/services/localIndex/traceIndex.ts b/src/server/services/localIndex/traceIndex.ts index 5ffa3300..a1b73e61 100644 --- a/src/server/services/localIndex/traceIndex.ts +++ b/src/server/services/localIndex/traceIndex.ts @@ -15,6 +15,10 @@ export type TraceSourceInput = { fingerprint?: string | null pendingTailBytes?: number nextOrdinal?: number + oversizedRecords?: number + scanTruncated?: boolean + windowStartByte?: number + oversizedContinuation?: boolean } export type TraceCallLocator = { @@ -77,6 +81,9 @@ export type TraceSourceRecord = Required ({ model: row.model, calls: row.call_count })) } @@ -432,12 +452,11 @@ function resolvedNextOrdinal( events: TraceEventLocator[], floor = 0, ): number { - const derived = Math.max( - floor, - ...calls.map(call => call.ordinal + 1), - ...events.map(event => event.ordinal + 1), - ) - return source.nextOrdinal ?? derived + if (source.nextOrdinal !== undefined) return source.nextOrdinal + let derived = floor + for (const call of calls) derived = Math.max(derived, call.ordinal + 1) + for (const event of events) derived = Math.max(derived, event.ordinal + 1) + return derived } function normalizeSource( @@ -455,7 +474,11 @@ function normalizeSource( !safeInteger(pendingTailBytes) || pendingTailBytes !== source.size - source.indexedBytes || !safeInteger(nextOrdinal) || - !Number.isFinite(source.mtimeMs) + !Number.isFinite(source.mtimeMs) || + !safeInteger(source.oversizedRecords ?? 0) || + !safeInteger(source.windowStartByte ?? 0) || + (source.windowStartByte ?? 0) > source.indexedBytes || + (source.scanTruncated !== undefined && typeof source.scanTruncated !== 'boolean') ) { throw new Error('Invalid trace source progress') } @@ -465,6 +488,10 @@ function normalizeSource( fingerprint: source.fingerprint ?? null, pendingTailBytes, nextOrdinal, + oversizedRecords: source.oversizedRecords ?? 0, + scanTruncated: source.scanTruncated ?? false, + windowStartByte: source.windowStartByte ?? 0, + oversizedContinuation: source.oversizedContinuation ?? false, } } @@ -504,10 +531,14 @@ function writeSource( source.indexedBytes, revision, lastResetRevision, + source.oversizedContinuation ? 'TRACE_OVERSIZED_RECORD_CONTINUATION' : null, Date.now(), source.fileIdentity, source.fingerprint, source.pendingTailBytes, + source.oversizedRecords, + source.scanTruncated ? 1 : 0, + source.windowStartByte, ) operation.run( UPSERT_SESSION_SQL, @@ -711,25 +742,57 @@ function resetSummary(operation: TraceIndexWriteOperation, sessionId: string): v operation.run('DELETE FROM trace_session_models WHERE session_id = ?', sessionId) } +// A rebuild already has the final LWW rows. Recomputing model order and the +// latest call after every insertion repeatedly scans/sorts the growing table. +function rebuildSummary(operation: TraceIndexWriteOperation, sessionId: string): void { + operation.run( + `UPDATE trace_sessions SET + (api_calls, failed_calls, total_duration_ms, total_input_tokens, total_output_tokens) = ( + SELECT COUNT(*), COALESCE(SUM(failed), 0), COALESCE(SUM(duration_ms), 0), + COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0) + FROM trace_calls WHERE session_id = ? + ), + summary_updated_at = ( + SELECT COALESCE(completed_at, started_at) FROM trace_calls + WHERE session_id = ? ORDER BY started_at DESC, first_ordinal DESC LIMIT 1 + ) + WHERE session_id = ?`, + sessionId, sessionId, sessionId, + ) + operation.run( + `INSERT INTO trace_session_models (session_id, model, call_count, first_started_at, first_ordinal) + SELECT ?, model, call_count, started_at, first_ordinal FROM ( + SELECT model, started_at, first_ordinal, + COUNT(*) OVER (PARTITION BY model) AS call_count, + ROW_NUMBER() OVER (PARTITION BY model ORDER BY started_at, first_ordinal) AS position + FROM trace_calls WHERE session_id = ? AND model IS NOT NULL AND model != '' + ) WHERE position = 1`, + sessionId, sessionId, + ) +} + function locatorRows( operation: TraceIndexReadOperation, sessionId: string, revisionFloor?: number, + page?: { offset: number; limit: number }, ): { calls: TraceCallLocator[]; events: TraceEventLocator[] } { const revisionWhere = revisionFloor === undefined ? '' : ' AND revision > ?' const bindings = revisionFloor === undefined ? [sessionId] as const : [sessionId, revisionFloor] as const + const pageSql = page ? ' LIMIT ? OFFSET ?' : '' + const pageBindings = page ? [page.limit, page.offset] : [] return { calls: operation.all( `SELECT * FROM trace_calls WHERE session_id = ?${revisionWhere} - ORDER BY started_at, first_ordinal`, - ...bindings, + ORDER BY started_at, first_ordinal${pageSql}`, + ...bindings, ...pageBindings, ).map(callFromRow), events: operation.all( - `SELECT * FROM trace_events WHERE session_id = ?${revisionWhere} ORDER BY timestamp, ordinal`, - ...bindings, + `SELECT * FROM trace_events WHERE session_id = ?${revisionWhere} ORDER BY timestamp, ordinal${pageSql}`, + ...bindings, ...pageBindings, ).map(eventFromRow), } } @@ -760,8 +823,8 @@ function replaceProjection( for (const call of input.calls) { validateLocator(call, source) writeCall(operation, source.sessionId, call, revision) - updateSummaryForCall(operation, source.sessionId, null, call) } + rebuildSummary(operation, source.sessionId) for (const event of input.events) { validateLocator(event, source) writeEvent(operation, source.sessionId, event, revision) @@ -797,13 +860,17 @@ function appendProjection( ) for (const call of input.calls) { validateLocator(call, source) - const previous = operation.get( + const previous = input.calls.length === 1 ? operation.get( 'SELECT * FROM trace_calls WHERE session_id = ? AND call_id = ?', source.sessionId, call.id, - ) + ) : null writeCall(operation, source.sessionId, call, revision) - updateSummaryForCall(operation, source.sessionId, previous, call) + if (input.calls.length === 1) updateSummaryForCall(operation, source.sessionId, previous, call) + } + if (input.calls.length > 1) { + resetSummary(operation, source.sessionId) + rebuildSummary(operation, source.sessionId) } for (const event of input.events) { validateLocator(event, source) @@ -848,6 +915,19 @@ export function createTraceIndex(database: TraceIndexDatabase): TraceIndex { return { ...overview, ...locatorRows(operation, sessionId) } }) }, + getSessionPage(sessionId, offset, limit) { + return database.read(operation => { + const overview = readOverview(operation, sessionId) + if (!overview) return null + const page = { offset: Math.max(0, Math.trunc(offset)), limit: Math.max(1, Math.min(100, Math.trunc(limit))) } + return { + ...overview, + ...locatorRows(operation, sessionId, undefined, page), + totalCalls: overview.summary.apiCalls, + totalEvents: operation.get<{ count: number }>('SELECT COUNT(*) AS count FROM trace_events WHERE session_id = ?', sessionId)?.count ?? 0, + } + }) + }, getCallLocator(sessionId, callId) { return database.read(operation => { const source = readSource(operation, sessionId) diff --git a/src/server/services/localIndex/traceMigrations.ts b/src/server/services/localIndex/traceMigrations.ts index b4d15a04..97fcc75d 100644 --- a/src/server/services/localIndex/traceMigrations.ts +++ b/src/server/services/localIndex/traceMigrations.ts @@ -1,6 +1,6 @@ import type { Database } from 'bun:sqlite' -export const TRACE_INDEX_SCHEMA_VERSION = 5 +export const TRACE_INDEX_SCHEMA_VERSION = 6 export const TRACE_INDEX_SCHEMA_UNSUPPORTED = 'TRACE_INDEX_SCHEMA_UNSUPPORTED' as const @@ -197,12 +197,22 @@ UPDATE trace_sources SET state = 'degraded', last_error_code = 'TRACE_INDEX_V5_REBUILD_REQUIRED'; ` +const SCHEMA_V6 = ` +ALTER TABLE trace_sources ADD COLUMN oversized_records INTEGER NOT NULL DEFAULT 0; +ALTER TABLE trace_sources ADD COLUMN scan_truncated INTEGER NOT NULL DEFAULT 0; +ALTER TABLE trace_sources ADD COLUMN window_start_byte INTEGER NOT NULL DEFAULT 0; +CREATE INDEX trace_calls_page_order_idx ON trace_calls(session_id, started_at, first_ordinal); +CREATE INDEX trace_calls_model_order_idx ON trace_calls(session_id, model, started_at, first_ordinal); +UPDATE trace_sources SET state = 'degraded', last_error_code = 'TRACE_INDEX_V6_REBUILD_REQUIRED'; +` + const MIGRATIONS = [ { version: 1, sql: SCHEMA_V1 }, { version: 2, sql: SCHEMA_V2 }, { version: 3, sql: SCHEMA_V3 }, { version: 4, sql: SCHEMA_V4 }, { version: 5, sql: SCHEMA_V5 }, + { version: 6, sql: SCHEMA_V6 }, ] as const export class UnsupportedTraceIndexSchemaError extends Error { diff --git a/src/server/services/localIndex/transcriptReducer.ts b/src/server/services/localIndex/transcriptReducer.ts index 863d1518..c688f163 100644 --- a/src/server/services/localIndex/transcriptReducer.ts +++ b/src/server/services/localIndex/transcriptReducer.ts @@ -120,6 +120,7 @@ type ReducerState = { export type TranscriptReductionOptions = { isSubagent?: boolean + validateRetainedMetadata?: (entry: Record) => void } const projectionStates = new WeakMap() @@ -756,6 +757,9 @@ export function reduceTranscriptWithLocators( malformedLineCount += 1 continue } + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + options.validateRetainedMetadata?.(entry) + } applyEntry(state, entry) const locator = locatorFromEntry(entry, chunk, state, jsonlLine) if (locator) { diff --git a/src/server/services/sessionHistoryContext.test.ts b/src/server/services/sessionHistoryContext.test.ts new file mode 100644 index 00000000..62ba4d02 --- /dev/null +++ b/src/server/services/sessionHistoryContext.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { appendFile, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readHistoryContexts } from './sessionHistoryContext.js' + +let directory: string +let file: string +beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'history-context-test-')); file = join(directory, 'session.jsonl') }) +afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +const row = (id: string, fields: object = {}) => JSON.stringify({ uuid: id, ...fields }) +const version = async (filePath = file) => { const info = await stat(filePath, { bigint: true }); return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}` } +const classify = (entry: Record) => ({ notification: entry.notification === true, reset: entry.reset === true, agentToolId: typeof entry.agent === 'string' ? entry.agent : undefined }) + +test('indexes EOF records and resumes at their boundary when a newline and new records are appended', async () => { + const first = row('notification', { notification: true }) + '\n' + const partial = row('user', { reset: true }) + await writeFile(file, first + partial) + const before = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0, Buffer.byteLength(first)], classify }) + expect(before.contexts.get(0)?.suppressed).toBe(true) + expect(before.contexts.get(Buffer.byteLength(first))?.suppressed).toBe(false) + const suffix = '\n' + row('assistant') + '\n' + await appendFile(file, suffix) + const after = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [Buffer.byteLength(first + partial + '\n')], classify }) + expect(after.scannedBytes).toBe(Buffer.byteLength(partial + suffix)) + expect([...after.contexts.values()]).toEqual([{ owner: undefined, suppressed: false }]) +}) + +test('joins concurrent builds and cancelling one subscriber does not cancel the remaining reader', async () => { + await writeFile(file, Array.from({ length: 128 }, (_, index) => row(String(index)) + '\n').join('')) + const sourceVersion = await version() + const controller = new AbortController() + let visits = 0 + const observed = (entry: Record) => { visits++; if (visits === 1) controller.abort(); return classify(entry) } + const first = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], signal: controller.signal, classify: observed }).catch(error => error) + const second = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], classify: observed }) + expect((await first).name).toBe('AbortError') + expect((await second).contexts.get(0)?.suppressed).toBe(false) + expect(visits).toBe(128) + expect((await readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], classify })).scannedBytes).toBe(0) +}) + +test('replacing a transcript invalidates old scalar state and rejects stale page identities', async () => { + await writeFile(file, row('notice', { notification: true }) + '\n') + const oldVersion = await version() + await readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], classify }) + await writeFile(file + '.new', row('new') + '\n') + await rename(file + '.new', file) + const fresh = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify }) + expect(fresh.contexts.get(0)?.suppressed).toBe(false) + await expect(readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], classify })).rejects.toMatchObject({ statusCode: 409 }) +}) + +test('bounds queued file builds and cleans a fully cancelled scan for subsequent retry', async () => { + const files = Array.from({ length: 6 }, (_, index) => join(directory, `${index}.jsonl`)) + await Promise.all(files.map(filePath => writeFile(filePath, row('one') + '\n' + row('two') + '\n'))) + const versions = await Promise.all(files.map(filePath => version(filePath))) + const requests = files.slice(0, 5).map((filePath, index) => readHistoryContexts({ filePath, sourceVersion: versions[index]!, offsets: [0], classify })) + await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], classify })).rejects.toMatchObject({ statusCode: 429 }) + await Promise.all(requests) + const controller = new AbortController() + await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], signal: controller.signal, classify: entry => { controller.abort(); return classify(entry) } })).rejects.toThrow() + expect((await readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], classify })).contexts.get(0)?.suppressed).toBe(false) +}) diff --git a/src/server/services/sessionHistoryContext.ts b/src/server/services/sessionHistoryContext.ts new file mode 100644 index 00000000..b5862e67 --- /dev/null +++ b/src/server/services/sessionHistoryContext.ts @@ -0,0 +1,140 @@ +import { Database } from 'bun:sqlite' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { streamBoundedHistory, withHistoryReadBudget } from './boundedSessionHistory.js' +import { ApiError } from '../middleware/errorHandler.js' + +type Context = { owner?: string; suppressed: boolean } +type Cache = { database: Database; directory: string; identity: string; size: number; mtime: string; offset: number; suppressed: boolean | null } +type Flight = { promise: Promise; controller: AbortController; users: number } +const cache = new Map() +const flights = new Map() +process.once('exit', () => { for (const entry of cache.values()) { entry.database.close(); rmSync(entry.directory, { recursive: true, force: true }) } }) + +/** Disk-backed visibility/ownership scalars. Payload records never enter this + * index. Only append suffixes are scanned after the initial bounded build. */ +export async function readHistoryContexts(options: { + filePath: string + sourceVersion: string + offsets: number[] + signal?: AbortSignal + classify: (entry: Record) => { notification: boolean; reset: boolean; agentToolId?: string } +}): Promise<{ contexts: Map; scannedBytes: number }> { + if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + const [dev, ino, size, mtime] = options.sourceVersion.split(':') + const targetSize = Number(size) + const identity = `${dev}:${ino}` + let scannedBytes = 0 + const ensure = async (signal: AbortSignal) => withHistoryReadBudget(signal, async () => { + const current = await stat(options.filePath, { bigint: true }) + if (`${current.dev}:${current.ino}` !== identity || Number(current.size) < targetSize || (Number(current.size) === targetSize && String(current.mtimeNs) !== mtime)) { + throw new ApiError(409, 'Session history changed during context lookup', 'HISTORY_CHANGED') + } + let state = cache.get(options.filePath) + if (state && (state.identity !== identity || Number(current.size) < state.size || (Number(current.size) === state.size && String(current.mtimeNs) !== state.mtime))) { + cache.delete(options.filePath) + state.database.close() + await rm(state.directory, { recursive: true, force: true }) + state = undefined + } + if (!state) { + while (cache.size >= 4) { + const key = cache.keys().next().value! + const evicted = cache.get(key)! + cache.delete(key) + evicted.database.close() + await rm(evicted.directory, { recursive: true, force: true }) + } + const directory = await mkdtemp(join(tmpdir(), 'claude-history-context-')) + const database = new Database(join(directory, 'context.sqlite')) + database.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE parents (id TEXT PRIMARY KEY, chain TEXT); CREATE TABLE context (offset INTEGER PRIMARY KEY, owner TEXT, suppressed INTEGER)') + state = { database, directory, identity, size: 0, mtime: '', offset: 0, suppressed: false } + cache.set(options.filePath, state) + } + cache.delete(options.filePath) + cache.set(options.filePath, state) + if (state.size >= targetSize) return + const getParent = state.database.query('SELECT chain FROM parents WHERE id = ?') + const saveParent = state.database.query('INSERT OR REPLACE INTO parents VALUES (?, ?)') + const saveContext = state.database.query('INSERT OR REPLACE INTO context VALUES (?, ?, ?)') + const originalOffset = state.offset + let suppressed = state.suppressed + let completeSuppression = suppressed + try { + state.database.exec('BEGIN') + const result = await streamBoundedHistory(options.filePath, (entry, completeLine, offset) => { + const classification = options.classify(entry) + const inherited = typeof entry.parentUuid === 'string' ? (getParent.get(entry.parentUuid) as { chain?: string } | null)?.chain : undefined + const explicit = typeof entry.parent_tool_use_id === 'string' && entry.parent_tool_use_id ? entry.parent_tool_use_id : undefined + const owner = explicit ?? (entry.isSidechain === true ? inherited : undefined) + const chain = classification.agentToolId ?? inherited + if (typeof entry.uuid === 'string') saveParent.run(entry.uuid, chain ?? null) + if (classification.notification) suppressed = true + else if (classification.reset) suppressed = false + // Unknown sidechain ancestry is never promoted into the root transcript. + saveContext.run(offset, owner ?? null, suppressed !== false || (entry.isSidechain === true && !owner) ? 1 : 0) + if (completeLine) completeSuppression = suppressed + }, signal, { startOffset: originalOffset, endOffset: targetSize, onSkipped: () => { suppressed = null; completeSuppression = null } }) + state.database.exec('COMMIT') + state.size = targetSize + state.mtime = mtime! + state.offset = result.nextOffset + state.suppressed = completeSuppression + scannedBytes += result.scannedBytes + } catch (error) { + state.database.exec('ROLLBACK') + // journal_mode=OFF cannot guarantee rollback restoration after a failed + // build; discard this regenerable index entirely. + cache.delete(options.filePath) + state.database.close() + await rm(state.directory, { recursive: true, force: true }) + throw error + } + }, 'context') + // Join one file build. Each caller may cancel independently; the scan is + // aborted when the final interested caller goes away. + while (!cache.get(options.filePath) || cache.get(options.filePath)!.size < targetSize || cache.get(options.filePath)!.identity !== identity || (cache.get(options.filePath)!.size === targetSize && cache.get(options.filePath)!.mtime !== mtime)) { + let flight = flights.get(options.filePath) + if (flight?.controller.signal.aborted) { + await flight.promise.catch(() => {}) + if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + continue + } + if (!flight) { + if (flights.size >= 5) throw new ApiError(429, 'History context reader is busy', 'HISTORY_BUSY') + const controller = new AbortController() + flight = { controller, users: 0, promise: Promise.resolve() } + const currentFlight = flight + flight.promise = ensure(controller.signal).finally(() => { if (flights.get(options.filePath) === currentFlight) flights.delete(options.filePath) }) + flights.set(options.filePath, flight) + } + flight.users++ + const joined = flight + await new Promise((resolve, reject) => { + let done = false + const finish = (error?: unknown) => { + if (done) return + done = true + options.signal?.removeEventListener('abort', abort) + joined.users-- + if (!joined.users && error) joined.controller.abort(error) + if (error) reject(error); else resolve() + } + const abort = () => finish(options.signal?.reason ?? new DOMException('Aborted', 'AbortError')) + options.signal?.addEventListener('abort', abort, { once: true }) + joined.promise.then(() => finish(), error => finish(error)) + if (options.signal?.aborted) abort() + }) + } + const state = cache.get(options.filePath)! + const query = state.database.query('SELECT owner, suppressed FROM context WHERE offset = ?') + const contexts = new Map() + for (const offset of options.offsets) { + const row = query.get(offset) as { owner: string | null; suppressed: number } | null + if (!row) throw new ApiError(409, 'History context is unavailable; reload the page', 'HISTORY_CHANGED') + contexts.set(offset, { owner: row.owner ?? undefined, suppressed: row.suppressed === 1 }) + } + return { contexts, scannedBytes } +} diff --git a/src/server/services/sessionHistoryRecovery.test.ts b/src/server/services/sessionHistoryRecovery.test.ts new file mode 100644 index 00000000..a73883e1 --- /dev/null +++ b/src/server/services/sessionHistoryRecovery.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, appendFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SessionService } from './sessionService.js' +import { HISTORY_SCAN_BYTES } from './boundedSessionHistory.js' + +let directory: string +let configBefore: string | undefined +let file: string +let service: SessionService +const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +const entry = (type: string, uuid: string, content: unknown, extra: object = {}) => ({ type, uuid, message: { role: type, content }, timestamp: '2026-01-01T00:00:00.000Z', ...extra }) +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'recovery-history-test-')) + configBefore = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = directory + const project = join(directory, 'projects', '-tmp-history') + await mkdir(project, { recursive: true }) + file = join(project, `${id}.jsonl`) + service = new SessionService({ getMode: () => 'off' } as any) +}) +afterEach(async () => { + if (configBefore === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = configBefore + await rm(directory, { recursive: true, force: true }) +}) + +test('recovery retains goal and todo evidence outside the visible tail, including dismissal boundary and exact usage deduplication', async () => { + const entries = [ + { type: 'system', subtype: 'local_command', uuid: 'goal', content: 'Goal set: finish project', timestamp: '2026-01-01T00:00:00.000Z' }, + entry('assistant', 'todo', [{ type: 'tool_use', id: 'todo-use', name: 'TodoWrite', input: { todos: [{ content: 'finish project', status: 'completed' }] } }]), + entry('user', 'after-todo', 'next task'), + ...Array.from({ length: 24 }, (_, index) => entry('assistant', `body-${index}`, [{ type: 'text', text: 'x'.repeat(256 * 1024) }], { + version: '1.0.0', sessionId: id, requestId: 'one-request', + message: { role: 'assistant', id: 'one-reply', content: [{ type: 'text', text: 'x'.repeat(256 * 1024) }], usage: { input_tokens: 10, output_tokens: 20 } }, + })), + ] + await writeFile(file, entries.map(value => JSON.stringify(value)).join('\n') + '\n') + const page = await service.getSessionHistoryPage(id) + expect(page.page.hasMore).toBe(true) + expect(page.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES) + expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(2 * 1024 * 1024) + expect(page.messages.every(message => message.id.startsWith('body-'))).toBe(true) + const recovery = await service.getSessionHistoryRecovery(id) + expect(recovery.status).toBe('ready') + expect(recovery.messages.map(message => message.id)).toEqual(['goal', 'todo', 'after-todo']) + expect(recovery.tokenUsage).toMatchObject({ input_tokens: 10, output_tokens: 20 }) + expect(Buffer.byteLength(JSON.stringify(recovery))).toBeLessThan(16 * 1024) +}) + +test('recovery retains tool lifecycle ownership and does not revive child tools as root work', async () => { + await writeFile(file, [ + entry('assistant', 'root-call', [{ type: 'tool_use', id: 'agent-1', name: 'Agent', input: { description: 'inspect' } }]), + entry('assistant', 'child-call', [{ type: 'tool_use', id: 'child-bash', name: 'Bash', input: { command: 'sleep 1', run_in_background: true } }], { parent_tool_use_id: 'agent-1' }), + entry('user', 'child-result', [{ type: 'tool_result', tool_use_id: 'child-bash', content: 'background task' }], { parentUuid: 'child-call', isSidechain: true }), + entry('user', 'root-result', [{ type: 'tool_result', tool_use_id: 'agent-1', content: 'done' }]), + ].map(value => JSON.stringify(value)).join('\n') + '\n') + const recovery = await service.getSessionHistoryRecovery(id) + expect(recovery.status).toBe('ready') + expect(recovery.messages.map(message => message.id)).toEqual(['root-call']) +}) + +test('oversized evidence is explicitly incomplete and cancellation never returns an authoritative snapshot', async () => { + await writeFile(file, JSON.stringify(entry('assistant', 'huge', [{ type: 'text', text: 'x'.repeat(2 * 1024 * 1024) }])) + '\n') + const recovery = await service.getSessionHistoryRecovery(id) + expect(recovery.status).toBe('incomplete') + expect(recovery.omittedRecords).toBe(1) + const controller = new AbortController() + controller.abort() + await expect(service.getSessionHistoryRecovery(id, { signal: controller.signal })).rejects.toThrow() +}) + + +test('large foreground shell output and old Todo writes do not poison scalar recovery; workspace limits are independent', async () => { + await writeFile(file, [ + ...Array.from({ length: 80 }, (_, index) => entry('assistant', `todo-${index}`, [{ type: 'tool_use', id: `todo-${index}`, name: 'TodoWrite', input: { todos: [{ content: `todo ${index}`, status: 'pending' }] } }])), + entry('assistant', 'shell', [{ type: 'tool_use', id: 'shell', name: 'Bash', input: { command: 'cat file' } }]), + entry('user', 'shell-result', [{ type: 'tool_result', tool_use_id: 'shell', content: 'x'.repeat(256 * 1024) }]), + entry('assistant', 'write', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'x'.repeat(128 * 1024) } }]), + ].map(value => JSON.stringify(value)).join('\n') + '\n') + const recovery = await service.getSessionHistoryRecovery(id) + expect(recovery.completeness).toEqual({ goal: true, todos: true, usage: true, activity: true, workspace: false }) + expect(recovery.messages.filter(message => message.id.startsWith('todo-')).map(message => message.id)).toEqual(['todo-79']) + expect(Buffer.byteLength(JSON.stringify(recovery))).toBeLessThan(16 * 1024) +}) + +test('launch metadata, title, work directory and metadata appends never materialize transcript history', async () => { + await writeFile(file, [ + { type: 'session-meta', workDir: '/tmp/history' }, + { type: 'custom-title', customTitle: 'large history' }, + ...Array.from({ length: 24 }, (_, index) => entry('assistant', `body-${index}`, 'x'.repeat(256 * 1024))), + ].map(value => JSON.stringify(value)).join('\n') + '\n') + ;(service as any).readJsonlFile = () => { throw new Error('unbounded transcript read') } + const [launch, workDir, title] = await Promise.all([service.getSessionLaunchInfo(id), service.getSessionWorkDir(id), service.getCustomTitle(id)]) + expect(launch?.transcriptMessageCount).toBe(24) + expect(workDir).toBe('/tmp/history') + expect(title).toBe('large history') + await service.appendSessionMetadata(id, { workDir: '/tmp/history', customTitle: 'updated title' }) + expect(await service.getCustomTitle(id)).toBe('updated title') +}) + + +test('history pages preserve cross-page notification suppression and sidechain ownership, and index only appended bytes', async () => { + const notification = 'taskagentcompleted' + await writeFile(file, [ + entry('assistant', 'owner', [{ type: 'tool_use', id: 'agent', name: 'Agent', input: {} }]), + entry('assistant', 'child', 'child response', { isSidechain: true, parentUuid: 'owner' }), + entry('user', 'notice', notification), + entry('assistant', 'hidden', 'internal notification response'), + ].map(value => JSON.stringify(value)).join('\n') + '\n') + const latest = await service.getSessionHistoryPage(id, { limit: 1 }) + expect(latest.messages).toEqual([]) + expect(latest.page.contextScanBytes).toBeGreaterThan(0) + const noticePage = await service.getSessionHistoryPage(id, { limit: 1, cursor: latest.page.nextCursor! }) + expect(noticePage.messages).toEqual([]) + const childPage = await service.getSessionHistoryPage(id, { limit: 1, cursor: noticePage.page.nextCursor! }) + expect(childPage.messages).toMatchObject([{ id: 'child', parentToolUseId: 'agent' }]) + expect(childPage.page.contextScanBytes).toBe(0) + const suffix = JSON.stringify(entry('user', 'real-user', 'continue', { parentUuid: 'child' })) + '\n' + await appendFile(file, suffix) + const appended = await service.getSessionHistoryPage(id, { limit: 1 }) + expect(appended.messages).toMatchObject([{ id: 'real-user' }]) + expect(appended.messages[0]!.parentToolUseId).toBeUndefined() + expect(appended.page.contextScanBytes).toBe(Buffer.byteLength(suffix)) +}) + +test('recovery resolves sidechain ancestry through Agent calls without attaching ordinary root descendants', async () => { + await writeFile(file, [ + entry('assistant', 'owner', [{ type: 'tool_use', id: 'agent', name: 'Agent', input: {} }]), + entry('assistant', 'child', [{ type: 'tool_use', id: 'child-todo', name: 'TodoWrite', input: { todos: [{ content: 'child work', status: 'pending' }] } }], { isSidechain: true, parentUuid: 'owner' }), + entry('assistant', 'root', [{ type: 'tool_use', id: 'root-todo', name: 'TodoWrite', input: { todos: [{ content: 'root work', status: 'pending' }] } }], { parentUuid: 'child' }), + ].map(value => JSON.stringify(value)).join('\n') + '\n') + const recovered = await service.getSessionHistoryRecovery(id) + expect(recovered.messages.map(message => message.id)).toEqual(['owner', 'root']) +}) diff --git a/src/server/services/sessionHistoryRecovery.ts b/src/server/services/sessionHistoryRecovery.ts new file mode 100644 index 00000000..030a0705 --- /dev/null +++ b/src/server/services/sessionHistoryRecovery.ts @@ -0,0 +1,215 @@ +import { Database } from 'bun:sqlite' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { streamBoundedHistory, withHistoryReadBudget } from './boundedSessionHistory.js' +import type { MessageEntry, SessionTaskNotification } from './sessionService.js' + +const RECOVERY_BYTES = 3 * 1024 * 1024 +const STATE_RECORD_BYTES = 64 * 1024 +const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) +const WORKSPACE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']) +const SHELL_TOOLS = new Set(['Bash', 'PowerShell']) +const BACKGROUND_RESULT = /\bCommand (?:running in background|was manually backgrounded by user|exceeded[^\n]*?\band was moved to the background) with ID:\s*([A-Za-z0-9_-]+)/i +export type SessionHistoryRecovery = { + sourceVersion: string + status: 'ready' | 'incomplete' + completeness?: { goal: boolean; todos: boolean; activity: boolean; usage: boolean; workspace?: boolean } + messages: MessageEntry[] + taskNotifications: SessionTaskNotification[] + tokenUsage: { input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_creation_tokens: number } | null + omittedRecords: number +} +type Evidence = { ordinal: number; message: MessageEntry } +const record = (value: unknown): Record | undefined => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined +const text = (value: unknown): string => typeof value === 'string' ? value : Array.isArray(value) ? value.filter(block => block?.type === 'text').map(block => block.text ?? '').join('\n') : '' +const brief = (value: unknown): string | undefined => typeof value === 'string' ? value.slice(0, 4096) : undefined + +/** Whole-source state reduction with bounded resident memory. Large historical + * tool outputs are never recovery state; preserve their task identity/status, + * and leave their bodies available through the separately paged transcript. */ +export async function recoverBoundedSessionHistory(options: { + filePath: string + signal?: AbortSignal + toMessage: (entry: Record, parentToolUseId?: string) => MessageEntry | null + notifications: (entry: Record) => SessionTaskNotification[] +}): Promise { + return withHistoryReadBudget(options.signal, async () => { + const directory = await mkdtemp(join(tmpdir(), 'claude-history-recovery-')) + let database: Database | undefined + try { + database = new Database(join(directory, 'state.sqlite')) + database.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE usage_keys (id TEXT PRIMARY KEY); CREATE TABLE parents (id TEXT PRIMARY KEY, owner TEXT); CREATE TABLE tools (id TEXT PRIMARY KEY, ordinal INTEGER, json TEXT, legacy INTEGER, name TEXT); CREATE TABLE evidence (id TEXT PRIMARY KEY, ordinal INTEGER, json TEXT, category TEXT); CREATE TABLE notices (id TEXT PRIMARY KEY, ordinal INTEGER, json TEXT); CREATE TABLE teammates (name TEXT PRIMARY KEY)') + database.exec('BEGIN') + const usageKey = database.query('INSERT OR IGNORE INTO usage_keys VALUES (?)') + const saveParent = database.query('INSERT OR REPLACE INTO parents VALUES (?, ?)') + const getParent = database.query('SELECT owner FROM parents WHERE id = ?') + const saveTool = database.query('INSERT OR REPLACE INTO tools VALUES (?, ?, ?, ?, ?)') + const getTool = database.query('SELECT ordinal, json, legacy, name FROM tools WHERE id = ?') + const saveEvidence = database.query('INSERT OR REPLACE INTO evidence VALUES (?, ?, ?, ?)') + const saveNotice = database.query('INSERT OR REPLACE INTO notices VALUES (?, ?, ?)') + const teammate = database.query('INSERT OR IGNORE INTO teammates VALUES (?)') + let ordinal = 0 + let lastUser: Evidence | undefined + let lastTodo: Evidence | undefined + let lastTask: Evidence | undefined + let goalBase: Evidence | undefined + let goalStatus: Evidence | undefined + let omitted = 0 + let suppressTaskNotificationResponse = false + const completeness = { goal: true, todos: true, activity: true, usage: true, workspace: true } + const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 } + const saveActivity = (evidence: Evidence, category: 'activity' | 'workspace' = 'activity') => { + const json = JSON.stringify(evidence.message) + if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness[category] = false; return } + saveEvidence.run(evidence.message.id, evidence.ordinal, json, category) + } + const scan = await streamBoundedHistory(options.filePath, (entry) => { + ordinal++ + const inherited = typeof entry.parentUuid === 'string' ? (getParent.get(entry.parentUuid) as { owner?: string } | null)?.owner : undefined + const owner = typeof entry.parent_tool_use_id === 'string' && entry.parent_tool_use_id + ? entry.parent_tool_use_id : entry.isSidechain === true ? inherited : undefined + const rawContent = (entry.message as { content?: unknown } | undefined)?.content + const agentCall = Array.isArray(rawContent) ? rawContent.find(block => block?.type === 'tool_use' && (block.name === 'Agent' || block.name === 'Task') && typeof block.id === 'string') : undefined + if (typeof entry.uuid === 'string') saveParent.run(entry.uuid, agentCall?.id ?? inherited ?? null) + const notifications = options.notifications(entry) + for (const notice of notifications) { + const compact = { ...notice, ...(notice.summary ? { summary: brief(notice.summary) } : {}), ...(notice.result ? { result: brief(notice.result) } : {}) } + const json = JSON.stringify(compact) + if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness.activity = false; continue } + saveNotice.run(JSON.stringify([notice.ownerAgentId ?? null, notice.toolUseId]), ordinal, json) + } + const rawMessage = entry.message as { role?: string; content?: unknown } | undefined + const notificationUser = rawMessage?.role === 'user' && notifications.length > 0 + const hasToolResult = Array.isArray(rawMessage?.content) && rawMessage.content.some((block: any) => block?.type === 'tool_result') + if (notificationUser) { suppressTaskNotificationResponse = true; return } + if (rawMessage?.role === 'user' && !hasToolResult) suppressTaskNotificationResponse = false + else if (suppressTaskNotificationResponse) return + const message = options.toMessage(entry, owner) + if (!message) return + if (message.usage && (!message.usageKey || usageKey.run(message.usageKey).changes > 0)) { + const amount = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 + usage.input_tokens += amount(message.usage.input_tokens) + usage.output_tokens += amount(message.usage.output_tokens) + usage.cache_read_tokens += amount(message.usage.cache_read_input_tokens) + usage.cache_creation_tokens += amount(message.usage.cache_creation_input_tokens) + } + if (owner || entry.isSidechain === true) return + const base = { ...message, usage: undefined, toolUseResult: undefined } + if (message.type === 'user') { + lastUser = { ordinal, message: { ...base, content: '[user message]' } } + const teammateParts: string[] = [] + for (const match of text(message.content).matchAll(/]*>\n?([\s\S]*?)\n?<\/teammate-message>/g)) { + const body = match[2]!.trim() + // Lifecycle JSON is not a meaningful completed task result. + try { + const lifecycle = JSON.parse(body) + if (['shutdown_approved', 'shutdown_rejected', 'shutdown_request', 'teammate_terminated', 'idle_notification'].includes(lifecycle?.type)) continue + } catch { /* Plain text is a meaningful teammate response. */ } + if (teammate.run(match[1]!).changes) teammateParts.push(`${body.slice(0, 4096)}`) + } + if (teammateParts.length) saveActivity({ ordinal, message: { ...base, content: teammateParts.join('\n') } }) + } + if (message.type === 'system' && typeof message.content === 'string') { + const output = message.content.match(/([\s\S]*?)<\/local-command-(?:stdout|stderr)>/)?.[1]?.trim() + if (output && /^(?:Goal set:|Goal cleared[.:]|No active goal\.|Goal continuing:|Goal marked complete\.)/.test(output)) { + const evidence = { ordinal, message: { ...base, content: `${output}` } } + if (/^(?:Goal set:|Goal cleared[.:]|No active goal\.)/.test(output)) { + goalBase = undefined; goalStatus = undefined; completeness.goal = true + if (Buffer.byteLength(JSON.stringify(evidence)) <= STATE_RECORD_BYTES) goalBase = evidence + else { omitted++; completeness.goal = false } + } else if (Buffer.byteLength(JSON.stringify(evidence)) <= STATE_RECORD_BYTES) goalStatus = evidence + else { omitted++; completeness.goal = false } + } + return + } + if (!Array.isArray(message.content)) return + const agentBlocks: unknown[] = [] + const workspaceCalls: Array> = [] + const workspaceResults: Array> = [] + for (const block of message.content as Array>) { + if (block?.type === 'tool_use' && typeof block.id === 'string') { + const input = record(block.input) ?? {} + if (WORKSPACE_TOOLS.has(block.name)) workspaceCalls.push(block) + if (TASK_TOOLS.has(block.name)) { + lastTask = { ordinal, message: { ...base, content: [{ type: 'tool_use', id: block.id, name: block.name, input: {} }] } } + if (block.name === 'TodoWrite' && Array.isArray(input.todos)) { + const evidence = { ordinal, message: { ...base, content: [block] } } + completeness.todos = Buffer.byteLength(JSON.stringify(evidence)) <= STATE_RECORD_BYTES + lastTodo = completeness.todos ? evidence : undefined + if (!completeness.todos) omitted++ + } + } + if (block.name === 'Agent' || block.name === 'Task') { + agentBlocks.push({ type: 'tool_use', id: block.id, name: block.name, input: { name: input.name, description: brief(input.description), subagent_type: input.subagent_type, run_in_background: input.run_in_background } }) + } + if (SHELL_TOOLS.has(block.name)) { + const compact = { ...base, id: `${base.id}:background:${block.id}`, content: [{ type: 'tool_use', id: block.id, name: block.name, input: { description: brief(input.description), command: brief(input.command), run_in_background: input.run_in_background } }] } + const json = JSON.stringify(compact) + if (Buffer.byteLength(json) <= STATE_RECORD_BYTES) saveTool.run(block.id, ordinal, json, input.run_in_background === true ? 1 : 0, block.name) + else { omitted++; completeness.activity = false } + } + } else if (block?.type === 'tool_result' && typeof block.tool_use_id === 'string') { + const tool = getTool.get(block.tool_use_id) as { ordinal: number; json: string; legacy: number; name: string } | null + if (!tool) continue + if (WORKSPACE_TOOLS.has(tool.name)) { workspaceResults.push(block); continue } + const structured = record(message.toolUseResult) + const taskId = structured + ? structured.backgroundTaskId ?? structured.background_task_id + : tool.legacy ? text(block.content).match(BACKGROUND_RESULT)?.[1] : undefined + if (typeof taskId !== 'string' || !taskId) continue + saveActivity({ ordinal: tool.ordinal, message: JSON.parse(tool.json) }) + saveActivity({ ordinal, message: { ...base, content: [{ type: 'tool_result', tool_use_id: block.tool_use_id, content: '' }], toolUseResult: { backgroundTaskId: taskId } } }) + } + } + if (workspaceCalls.length) { + const compact = { ...base, content: workspaceCalls } + const json = JSON.stringify(compact) + if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness.workspace = false } + else { + saveActivity({ ordinal, message: compact }, 'workspace') + for (const block of workspaceCalls) saveTool.run(block.id, ordinal, json, 0, block.name) + } + } + if (workspaceResults.length) saveActivity({ ordinal, message: { ...base, content: workspaceResults, toolUseResult: message.toolUseResult } }, 'workspace') + if (agentBlocks.length) saveActivity({ ordinal, message: { ...base, content: agentBlocks } }) + }, options.signal) + database.exec('COMMIT') + const priority = [goalBase, goalStatus, lastTodo, lastTask?.message.id === lastTodo?.message.id ? undefined : lastTask, lastUser].filter((value): value is Evidence => Boolean(value)) + const messages = new Map(priority.map(evidence => [evidence.message.id, evidence])) + const taskNotifications: SessionTaskNotification[] = [] + let bytes = Buffer.byteLength(JSON.stringify(priority)) + for (const row of database.query('SELECT ordinal, json, category FROM evidence ORDER BY ordinal DESC').iterate() as Iterable<{ ordinal: number; json: string; category: 'activity' | 'workspace' }>) { + if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + bytes += Buffer.byteLength(row.json) + if (bytes > RECOVERY_BYTES || messages.size >= 2048) { omitted++; completeness.activity = false; completeness.workspace = false; break } + const message = JSON.parse(row.json) + messages.set(message.id, { ordinal: row.ordinal, message }) + if (messages.size % 64 === 0) await new Promise(resolve => setImmediate(resolve)) + } + for (const row of database.query('SELECT json FROM notices ORDER BY ordinal DESC').iterate() as Iterable<{ json: string }>) { + bytes += Buffer.byteLength(row.json) + if (bytes > RECOVERY_BYTES || taskNotifications.length >= 2048) { omitted++; completeness.activity = false; break } + if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + taskNotifications.push(JSON.parse(row.json)) + if (taskNotifications.length % 64 === 0) await new Promise(resolve => setImmediate(resolve)) + } + if (scan.omittedRecords) { + omitted += scan.omittedRecords + completeness.goal = completeness.todos = completeness.activity = completeness.usage = completeness.workspace = false + } + return { + sourceVersion: scan.sourceVersion, + status: Object.values(completeness).every(Boolean) ? 'ready' : 'incomplete', + completeness, + messages: [...messages.values()].sort((a, b) => a.ordinal - b.ordinal).map(item => item.message), + taskNotifications: taskNotifications.reverse(), + tokenUsage: Object.values(usage).some(value => value > 0) ? usage : null, + omittedRecords: omitted, + } + } finally { + database?.close() + await rm(directory, { recursive: true, force: true }) + } + }, 'recovery') +} diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index a92b0e6c..45df61cf 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -1,3 +1,5 @@ +import { readHistoryContexts } from './sessionHistoryContext.js' +import { recoverBoundedSessionHistory, type SessionHistoryRecovery } from './sessionHistoryRecovery.js' /** * Session Service — 会话文件的读写操作封装 * @@ -5,6 +7,7 @@ * 确保 Desktop App 与 CLI 的数据完全互通。 */ +import { readBoundedHistoryPage, streamBoundedHistory, withHistoryReadBudget, type HistoryPageInfo } from './boundedSessionHistory.js' import { constants, createReadStream, type Stats } from 'node:fs' import { createHash } from 'node:crypto' import * as fs from 'node:fs/promises' @@ -51,13 +54,10 @@ import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' import { extractGoalCreationTitle, extractTranscriptUserTitle, - reduceTranscript, } from './localIndex/transcriptReducer.js' import type { PersistedWorktreeSession, SessionListSummary, - TranscriptChunk, - TranscriptProjection, } from './localIndex/types.js' import { localIndexCoordinator } from './localIndex/coordinator.js' import { readSessionEntriesByLocator } from './localIndex/sessionEntries.js' @@ -103,6 +103,7 @@ export type SessionListItem = { } export type SubagentTranscriptFragment = { + historyComplete?: boolean agentId: string messages: MessageEntry[] taskNotifications: SessionTaskNotification[] @@ -110,6 +111,7 @@ export type SubagentTranscriptFragment = { } export type SubagentTranscript = { + historyComplete?: boolean messages: MessageEntry[] taskNotifications: SessionTaskNotification[] } @@ -433,6 +435,7 @@ function claimUsageRecord(entry: RawEntry, countedKeys: Set): boolean { const key = usageRecordKey(identity) if (key === null) return true if (countedKeys.has(key)) return false + if (countedKeys.size >= 50_000 || key.length > 4096) throw new ApiError(413, 'Usage inspection exceeds its record budget', 'HISTORY_INSPECTION_LIMIT') countedKeys.add(key) return true } @@ -610,15 +613,6 @@ function providerModelLooksRelated( )) } -function safeJsonLength(value: unknown): number { - if (value === undefined) return 0 - try { - return JSON.stringify(value)?.length ?? 0 - } catch { - return 0 - } -} - // ============================================================================ // Service // ============================================================================ @@ -652,6 +646,9 @@ export function hasMalformedFileHistoryBefore(snapshot: FileHistorySnapshot): bo } export class SessionService { + private readonly uiFileHistoryReads = new Map>() + private readonly inspectionSnapshots = new Map>() + private providerService = new ProviderService() // Keep launch state available when retention disables or removes transcripts. // Scope keys by config directory so test/embedded server instances cannot mix state. @@ -682,6 +679,16 @@ export class SessionService { !this.privateTitles.get(this.memorySessionKey(sessionId))?.has(title) } + private readonly subagentLookupCache = new Map() + private readonly historyRecoveryCache = new Map() + private readonly metadataProjectionCache = new Map() + private readonly metadataProjectionRequests = new Map>() + + private readonly sessionHistoryRequests = new Map>() + private readonly pendingTaskNotificationWrites = new Map< string, Set<{ @@ -1067,28 +1074,30 @@ export class SessionService { exists: boolean parseComplete: boolean }> { - let content: string + const entries: RawEntry[] = [] + let parseComplete = true + const stream = createReadStream(filePath, { encoding: 'utf8' }) + const lines = createInterface({ input: stream, crlfDelay: Infinity }) try { - content = await fs.readFile(filePath, 'utf-8') + for await (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + try { + entries.push(JSON.parse(trimmed) as RawEntry) + } catch { + parseComplete = false + } + } + return { entries, exists: true, parseComplete } } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return { entries: [], exists: false, parseComplete: false } } throw err + } finally { + lines.close() + stream.destroy() } - - const entries: RawEntry[] = [] - let parseComplete = true - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (!trimmed) continue - try { - entries.push(JSON.parse(trimmed) as RawEntry) - } catch { - parseComplete = false - } - } - return { entries, exists: true, parseComplete } } private async readJsonlFile(filePath: string): Promise { @@ -1199,120 +1208,121 @@ export class SessionService { filePath: string, onEntry: (entry: RawEntry) => void, ): Promise { - const stream = createReadStream(filePath, { encoding: 'utf8' }) - const lines = createInterface({ - input: stream, - crlfDelay: Infinity, - }) - - try { - for await (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - try { - onEntry(JSON.parse(trimmed) as RawEntry) - } catch { - // skip malformed lines - } - } - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw err - } - } finally { - lines.close() - stream.destroy() - } + await withHistoryReadBudget(undefined, async () => { + const result = await streamBoundedHistory(filePath, entry => onEntry(entry as RawEntry)) + if (result.omittedRecords > 0) throw new ApiError(413, 'Transcript inspection contains records above the viewing limit', 'HISTORY_INSPECTION_LIMIT') + }, 'recovery') } private async scanSessionListSummary( filePath: string, projectDir: string, - stat: { birthtime: Date; mtime: Date }, + _stat: { birthtime: Date; mtime: Date }, ): Promise { - let projection: TranscriptProjection = { - summary: { - title: 'Untitled Session', - createdAt: stat.birthtime.toISOString(), - modifiedAt: stat.mtime.toISOString(), - messageCount: 0, - workDir: this.desanitizePath(projectDir), - }, - indexedBytes: 0, - pendingTailBytes: 0, - malformedLineCount: 0, + return (await this.getMetadataProjection(filePath, projectDir)).summary + } + + private async getMetadataProjection(filePath: string, projectDir: string): Promise<{ + summary: SessionListSummary + launchInfo: SessionLaunchInfo + customTitle: string | null + complete: boolean + }> { + const stat = await fs.stat(filePath, { bigint: true }) + const signature = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}` + const key = `${this.getConfigDir()}\0${filePath}` + const cached = this.metadataProjectionCache.get(key) + if (cached?.signature === signature) { + this.metadataProjectionCache.delete(key) + this.metadataProjectionCache.set(key, cached) + return cached } - const stream = createReadStream(filePath) - let lineSegments: Buffer[] = [] - let lineSegmentsLength = 0 - let lineByteStart = 0 - let bytesRead = 0 - let chunks: TranscriptChunk[] = [] - - const flushChunks = () => { - if (chunks.length === 0) return - projection = reduceTranscript(chunks, projection) - chunks = [] - } - - try { - for await (const data of stream) { - const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) - const bufferStart = bytesRead - bytesRead += buffer.length - let segmentStart = 0 - - while (segmentStart < buffer.length) { - const newlineIndex = buffer.indexOf(0x0a, segmentStart) - if (newlineIndex === -1) { - if (lineSegments.length === 0) { - lineByteStart = bufferStart + segmentStart - } - const segment = buffer.subarray(segmentStart) - lineSegments.push(segment) - lineSegmentsLength += segment.length - break - } - - const finalSegment = buffer.subarray(segmentStart, newlineIndex + 1) - let line: Buffer - if (lineSegments.length === 0) { - line = finalSegment - } else { - lineSegments.push(finalSegment) - lineSegmentsLength += finalSegment.length - line = Buffer.concat(lineSegments, lineSegmentsLength) - } - chunks.push({ - text: line.toString('utf8'), - byteStart: lineByteStart, - completeLine: true, - }) - if (chunks.length >= 256) flushChunks() - - lineSegments = [] - lineSegmentsLength = 0 - segmentStart = newlineIndex + 1 - lineByteStart = bufferStart + segmentStart + const requestKey = `${key}\0${signature}` + const pending = this.metadataProjectionRequests.get(requestKey) + if (pending) return pending + const request = withHistoryReadBudget(undefined, async () => { + const makeState = () => ({ + workDir: undefined as string | undefined, cwd: undefined as string | undefined, + repository: undefined as PreparedSessionWorkspace['repository'] | undefined, + worktreeSession: undefined as PersistedWorktreeSession | null | undefined, + permissionMode: undefined as string | undefined, + runtimeProviderId: undefined as string | null | undefined, + runtimeModelId: undefined as string | undefined, effortLevel: undefined as string | undefined, + customTitle: null as string | null, nonemptyCustomTitle: null as string | null, + goalTitle: null as string | null, aiTitle: null as string | null, firstUserTitle: null as string | null, + createdAt: null as string | null, modifiedAt: null as string | null, + count: 0, launchCount: 0, + }) + const launch = makeState() + const summary = makeState() + const apply = (state: ReturnType, entry: RawEntry) => { + if (!state.createdAt && entry.timestamp) state.createdAt = entry.timestamp + if ((entry.type === 'user' || entry.type === 'assistant') && entry.message?.role) { + state.count++ + if (!entry.isMeta) state.modifiedAt = this.latestTimestamp(state.modifiedAt, entry.timestamp) } + state.launchCount += this.countTranscriptMessages([entry]) + if (typeof entry.cwd === 'string' && entry.cwd.trim()) state.cwd = normalizeDriveRootPathForPlatform(entry.cwd) + const record = entry as Record + if (entry.type === 'session-meta') { + if (typeof record.workDir === 'string') state.workDir = normalizeDriveRootPathForPlatform(record.workDir) + state.permissionMode = this.resolvePermissionModeFromEntries([entry]) ?? state.permissionMode + if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') state.runtimeProviderId = record.runtimeProviderId as string | null + if (typeof record.runtimeModelId === 'string') state.runtimeModelId = record.runtimeModelId + if (typeof record.effortLevel === 'string' && VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel)) state.effortLevel = record.effortLevel + } + state.repository = this.resolveRepositoryFromEntries([entry]) ?? state.repository + const worktree = this.resolveWorktreeSessionFromEntries([entry]) + if (worktree !== undefined) state.worktreeSession = worktree + if (entry.type === 'custom-title') { + if (typeof entry.customTitle === 'string') state.customTitle = entry.customTitle + if (typeof entry.customTitle === 'string' && entry.customTitle.trim()) state.nonemptyCustomTitle = entry.customTitle + } + state.goalTitle ??= extractGoalCreationTitle(entry) + if (entry.type === 'ai-title' && entry.aiTitle) state.aiTitle = cleanSessionTitleSource(String(entry.aiTitle)) || state.aiTitle + if (!state.firstUserTitle && entry.type === 'user' && !entry.isMeta && entry.message?.role === 'user') state.firstUserTitle = extractTranscriptUserTitle(entry.message.content) + // Metadata has a separate fixed bound even when a file contains only + // a handful of maliciously large scalar values or repository fields. + if (Buffer.byteLength(JSON.stringify(state)) > 128 * 1024) throw new ApiError(413, 'Session metadata exceeds its resource budget', 'SESSION_METADATA_TOO_LARGE') } - - flushChunks() - if (lineSegmentsLength > 0) { - const pending = lineSegments.length === 1 - ? lineSegments[0]! - : Buffer.concat(lineSegments, lineSegmentsLength) - projection = reduceTranscript([{ - text: pending.toString('utf8'), - byteStart: lineByteStart, - completeLine: false, - }], projection) + const scan = await streamBoundedHistory(filePath, (entry, completeLine) => { + apply(launch, entry as RawEntry) + if (completeLine) apply(summary, entry as RawEntry) + }) + const shared = (state: typeof summary) => ({ + ...(state.permissionMode ? { permissionMode: state.permissionMode } : {}), + ...(state.runtimeProviderId !== undefined ? { runtimeProviderId: state.runtimeProviderId } : {}), + ...(state.runtimeModelId ? { runtimeModelId: state.runtimeModelId } : {}), + ...(state.effortLevel ? { effortLevel: state.effortLevel } : {}), + ...(state.repository ? { repository: state.repository } : {}), + ...(state.worktreeSession !== undefined ? { worktreeSession: state.worktreeSession } : {}), + }) + const result = { + summary: { + title: summary.customTitle || summary.goalTitle || summary.aiTitle || summary.firstUserTitle || 'Untitled Session', + createdAt: summary.createdAt ?? stat.birthtime.toISOString(), + modifiedAt: summary.modifiedAt ?? stat.mtime.toISOString(), + messageCount: summary.count, + workDir: summary.workDir || summary.cwd || this.desanitizePath(projectDir), + ...shared(summary), + }, + launchInfo: { + filePath, projectDir, + workDir: (launch.workDir !== undefined ? launch.workDir : launch.cwd ?? this.desanitizePath(projectDir)) || process.cwd(), + customTitle: launch.customTitle, + transcriptMessageCount: launch.launchCount, + ...shared(launch), + }, + customTitle: launch.nonemptyCustomTitle, + complete: scan.oversizedRecords === 0, } - } finally { - stream.destroy() - } - - return projection.summary + this.metadataProjectionCache.delete(key) + this.metadataProjectionCache.set(key, { signature: scan.sourceVersion, ...result }) + while (this.metadataProjectionCache.size > 32) this.metadataProjectionCache.delete(this.metadataProjectionCache.keys().next().value!) + return result + }, 'metadata') + this.metadataProjectionRequests.set(requestKey, request) + try { return await request } finally { this.metadataProjectionRequests.delete(requestKey) } } /** @@ -1338,9 +1348,12 @@ export class SessionService { projectPath: indexedMeta.projectPath, } } + this.syncSharedMutationEpoch() + const scope = this.getConfigDir() + this.prepareSessionListCaches(scope) const stat = await fs.stat(filePath) const projectPath = path.basename(path.dirname(filePath)) - const summary = await this.scanSessionListSummary(filePath, projectPath, stat) + const summary = await this.getCachedSessionListSummary(filePath, projectPath, stat, scope) return { title: summary.title, modifiedAt: summary.modifiedAt, @@ -2826,27 +2839,7 @@ export class SessionService { } async getTranscriptMetadata(sessionId: string): Promise { - const found = await this.findSessionFile(sessionId) - if (!found) return null - - const entries = await this.readJsonlFile(found.filePath) - const metadata: TranscriptMetadataSnapshot = {} - - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]! - if (!metadata.model && typeof entry.message?.model === 'string') { - metadata.model = entry.message.model - } - if (!metadata.cwd && typeof entry.cwd === 'string') { - metadata.cwd = entry.cwd - } - if (!metadata.version && typeof entry.version === 'string') { - metadata.version = entry.version - } - if (metadata.model && metadata.cwd && metadata.version) break - } - - return metadata + return (await this.getInspectionTranscriptSnapshot(sessionId))?.metadata ?? null } private async buildTranscriptContextEstimate( @@ -2945,158 +2938,59 @@ export class SessionService { } async getTranscriptContextEstimate(sessionId: string): Promise { - const found = await this.findSessionFile(sessionId) - if (!found) return null - - const entries = await this.readJsonlFile(found.filePath) - const contextState = createTranscriptContextAccumulator() - - for (const entry of entries) { - accumulateTranscriptContext(contextState, entry) - } - - const latest = resolveTranscriptContextUsage(contextState) - if (!latest) return null - - return await this.buildTranscriptContextEstimate( - sessionId, - latest, - contextState.estimatedTokensFromMessages, - contextState.estimatedTokensAfterUsage, - contextState.transcriptHasMediaInput, - this.resolveRuntimeContextMetadataFromEntries(entries), - ) + return (await this.getInspectionTranscriptSnapshot(sessionId))?.contextEstimate ?? null } async getTranscriptUsage(sessionId: string): Promise { - const found = await this.findSessionFile(sessionId) - if (!found) return null - - const entries = await this.readJsonlFile(found.filePath) - let currentRuntimeHint: ProviderContextWindowHint = {} - const models = new Map() - let totalCostUSD = 0 - let totalInputTokens = 0 - let totalOutputTokens = 0 - let totalCacheReadInputTokens = 0 - let totalCacheCreationInputTokens = 0 - let totalWebSearchRequests = 0 - let hasUnknownModelCost = false - let firstUsageAt: number | null = null - let lastUsageAt: number | null = null - - const countedUsageKeys = new Set() - - for (const entry of entries) { - currentRuntimeHint = this.applyRuntimeContextMetadata(currentRuntimeHint, entry) - // Fork-inherited lines and the repeated usage objects of a multi-block reply are the - // same class of over-count; `claimUsageRecord` rejects both. - if (!claimUsageRecord(entry, countedUsageKeys)) continue - const usage = entry.message?.usage - const model = entry.message?.model - if (!usage || typeof model !== 'string') continue - - const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 - const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 - const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 - const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 - const webSearchRequests = typeof usage.server_tool_use?.web_search_requests === 'number' - ? usage.server_tool_use.web_search_requests - : 0 - - if ( - inputTokens === 0 && - outputTokens === 0 && - cacheReadInputTokens === 0 && - cacheCreationInputTokens === 0 && - webSearchRequests === 0 - ) { - continue - } - - const canonical = getCanonicalName(model) - if (!Object.prototype.hasOwnProperty.call(MODEL_COSTS, canonical)) { - hasUnknownModelCost = true - } - - const costUsage = { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_read_input_tokens: cacheReadInputTokens, - cache_creation_input_tokens: cacheCreationInputTokens, - server_tool_use: { web_search_requests: webSearchRequests }, - speed: usage.speed, - } as Parameters[1] - const costUSD = calculateUSDCost(model, costUsage) - - let modelUsage = models.get(model) - if (!modelUsage) { - modelUsage = { - model, - displayName: canonical, - inputTokens: 0, - outputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - webSearchRequests: 0, - costUSD: 0, - costDisplay: '$0.0000', - contextWindow: await this.getTranscriptContextWindow(sessionId, model, currentRuntimeHint), - maxOutputTokens: getModelMaxOutputTokens(model).default, - } - models.set(model, modelUsage) - } - - modelUsage.inputTokens += inputTokens - modelUsage.outputTokens += outputTokens - modelUsage.cacheReadInputTokens += cacheReadInputTokens - modelUsage.cacheCreationInputTokens += cacheCreationInputTokens - modelUsage.webSearchRequests += webSearchRequests - modelUsage.costUSD += costUSD - modelUsage.costDisplay = this.formatCost(modelUsage.costUSD) - - totalCostUSD += costUSD - totalInputTokens += inputTokens - totalOutputTokens += outputTokens - totalCacheReadInputTokens += cacheReadInputTokens - totalCacheCreationInputTokens += cacheCreationInputTokens - totalWebSearchRequests += webSearchRequests - - if (entry.timestamp) { - const time = Date.parse(entry.timestamp) - if (!Number.isNaN(time)) { - firstUsageAt = firstUsageAt === null ? time : Math.min(firstUsageAt, time) - lastUsageAt = lastUsageAt === null ? time : Math.max(lastUsageAt, time) - } - } - } - - if (models.size === 0) return null - - return { - source: 'transcript', - totalCostUSD, - costDisplay: this.formatCost(totalCostUSD), - hasUnknownModelCost, - totalAPIDuration: 0, - totalDuration: - firstUsageAt !== null && lastUsageAt !== null - ? Math.max(0, Math.round((lastUsageAt - firstUsageAt) / 1000)) - : 0, - totalLinesAdded: 0, - totalLinesRemoved: 0, - totalInputTokens, - totalOutputTokens, - totalCacheReadInputTokens, - totalCacheCreationInputTokens, - totalWebSearchRequests, - models: Array.from(models.values()), - } + return (await this.getInspectionTranscriptSnapshot(sessionId))?.usage ?? null } async getInspectionTranscriptSnapshot(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null + const stat = await fs.stat(found.filePath, { bigint: true }) + // The transcript is immutable between polls, but its interpreted token budget + // also depends on editable provider settings and process-level overrides. + const providers = await this.providerService.listProviders().catch(() => null) + const contextRevision = createHash('sha256').update(JSON.stringify({ + activeId: providers?.activeId, + providers: providers?.providers.map(provider => ({ + id: provider.id, + models: provider.models, + modelContextWindows: provider.modelContextWindows, + model1mSupport: provider.model1mSupport, + autoCompactWindow: provider.autoCompactWindow, + })), + env: [ + MODEL_CONTEXT_WINDOWS_ENV_KEY, 'CLAUDE_CODE_DISABLE_1M_CONTEXT', + 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'USER_TYPE', 'ANTHROPIC_BASE_URL', + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_AZURE_OPENAI', + ].map(name => process.env[name] ?? null), + })).digest('hex') + const key = `${found.filePath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${contextRevision}` + const existing = this.inspectionSnapshots.get(key) + if (existing) return existing + const request = (async () => { + const snapshot = await this.readInspectionTranscriptSnapshot(sessionId) + const after = await fs.stat(found.filePath, { bigint: true }) + if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size || after.mtimeNs !== stat.mtimeNs || after.ctimeNs !== stat.ctimeNs) { + throw ApiError.conflict('Transcript changed during inspection; retry the request') + } + if (snapshot && Buffer.byteLength(JSON.stringify(snapshot)) > 1024 * 1024) { + throw new ApiError(413, 'Transcript inspection exceeds its display budget', 'HISTORY_INSPECTION_LIMIT') + } + return snapshot + })() + this.inspectionSnapshots.set(key, request) + while (this.inspectionSnapshots.size > 8) this.inspectionSnapshots.delete(this.inspectionSnapshots.keys().next().value!) + try { return await request } + catch (error) { this.inspectionSnapshots.delete(key); throw error } + } + + private async readInspectionTranscriptSnapshot(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null let latestWorkDir: string | null = null let latestCwd: string | null = null @@ -3111,6 +3005,7 @@ export class SessionService { const metadata: TranscriptMetadataSnapshot = {} const models = new Map() + const modelRuntimeHints = new Map() let totalCostUSD = 0 let totalInputTokens = 0 let totalOutputTokens = 0 @@ -3236,6 +3131,8 @@ export class SessionService { let modelUsage = models.get(model) if (!modelUsage) { + modelRuntimeHints.set(model, { runtimeProviderId, runtimeModelId }) + if (models.size >= 64 || model.length > 512) throw new ApiError(413, 'Usage inspection exceeds its model budget', 'HISTORY_INSPECTION_LIMIT') modelUsage = { model, displayName: canonical, @@ -3295,7 +3192,7 @@ export class SessionService { modelUsage.contextWindow = await this.getTranscriptContextWindow( sessionId, modelUsage.model, - launchInfo, + modelRuntimeHints.get(modelUsage.model), ) } @@ -3897,8 +3794,123 @@ export class SessionService { } /** - * Get only the messages for a session (lighter than full detail). + * Read HTTP history and notifications together without merging child payloads. */ + async getSessionHistoryRecovery(sessionId: string, options: { signal?: AbortSignal } = {}): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) { + const key = this.memorySessionKey(sessionId) + if (this.memoryLaunchInfo.has(key) || this.knownSessionKeys.has(key)) { + return { sourceVersion: 'memory', status: 'ready', messages: [], taskNotifications: [], tokenUsage: null, omittedRecords: 0 } + } + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + const stat = await fs.stat(found.filePath, { bigint: true }) + const version = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}` + const cacheKey = this.memorySessionKey(sessionId) + const cached = this.historyRecoveryCache.get(cacheKey) + if (cached?.sourceVersion === version) { + this.historyRecoveryCache.delete(cacheKey) + this.historyRecoveryCache.set(cacheKey, cached) + return cached + } + const recovery = await recoverBoundedSessionHistory({ + filePath: found.filePath, + signal: options.signal, + toMessage: (entry, owner) => { + const raw = entry as RawEntry + const goal = this.goalLocalCommandEntryToMessage(raw) + if (goal) return goal + if (!this.isVisibleTranscriptMessageEntry(raw)) return null + return this.entryToMessage(raw, owner) + }, + notifications: entry => this.taskNotificationsFromEntries([entry as RawEntry]), + }) + this.historyRecoveryCache.delete(cacheKey) + this.historyRecoveryCache.set(cacheKey, recovery) + while (this.historyRecoveryCache.size > 2) this.historyRecoveryCache.delete(this.historyRecoveryCache.keys().next().value!) + return recovery + } + + private async projectHistoryPageEntries(filePath: string, result: Awaited>, signal?: AbortSignal): Promise<{ entries: RawEntry[]; contextScanBytes: number }> { + const context = await readHistoryContexts({ + filePath, + sourceVersion: result.page.sourceVersion, + offsets: result.entries.map(item => item.byteStart), + signal, + classify: raw => { + const entry = raw as RawEntry + const user = entry.message?.role === 'user' && !entry.isMeta + return { + notification: user && this.isTaskNotificationContent(entry.message?.content), + reset: user && !this.isToolResultContent(entry.message?.content), + agentToolId: this.extractAgentToolUseId(entry), + } + }, + }) + const visibleEntries = result.entries.flatMap(item => { + const state = context.contexts.get(item.byteStart)! + if (state.suppressed && !this.isGoalLocalCommandEntry(item.entry as RawEntry)) return [] + return [{ ...item.entry, ...(state.owner ? { parent_tool_use_id: state.owner } : {}) } as RawEntry] + }) + return { entries: visibleEntries, contextScanBytes: context.scannedBytes } + } + + async getSessionHistoryPage(sessionId: string, options: { cursor?: string; limit?: number; signal?: AbortSignal } = {}): Promise<{ + messages: MessageEntry[] + taskNotifications: SessionTaskNotification[] + page: HistoryPageInfo + }> { + const found = await this.findSessionFile(sessionId) + if (!found) { + const key = this.memorySessionKey(sessionId) + if (this.memoryLaunchInfo.has(key) || this.knownSessionKeys.has(key)) { + return { messages: [], taskNotifications: [], page: { nextCursor: null, hasMore: false, historyComplete: true, sourceVersion: 'memory', scannedBytes: 0, omittedOversizedEntries: 0 } } + } + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + const result = await readBoundedHistoryPage(found.filePath, options) + const projection = await this.projectHistoryPageEntries(found.filePath, result, options.signal) + const entries = result.entries.map(item => item.entry as RawEntry) + const response = { messages: this.entriesToMessages(projection.entries), taskNotifications: this.taskNotificationsFromEntries(entries), page: { ...result.page, contextScanBytes: projection.contextScanBytes } } + if (Buffer.byteLength(JSON.stringify(response)) > 2 * 1024 * 1024) { + throw new ApiError(413, 'History page exceeded its response budget', 'HISTORY_PAGE_TOO_LARGE') + } + return response + } + + async getSessionHistory(sessionId: string): Promise<{ + messages: MessageEntry[] + taskNotifications: SessionTaskNotification[] + }> { + const key = this.memorySessionKey(sessionId) + const existing = this.sessionHistoryRequests.get(key) + if (existing) return existing + const request = (async () => { + const found = await this.findSessionFile(sessionId) + if (!found) { + if (this.memoryLaunchInfo.has(key) || this.knownSessionKeys.has(key)) { + return { messages: [], taskNotifications: [] } + } + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + const entries = await this.readJsonlFile(found.filePath) + return { + messages: this.entriesToMessages(entries), + taskNotifications: this.taskNotificationsFromEntries(entries), + } + })() + this.sessionHistoryRequests.set(key, request) + try { + return await request + } finally { + // Coalesce overlapping requests only; retaining histories as a cache + // would pin hundreds of MB per session after callers close their views. + this.sessionHistoryRequests.delete(key) + } + } + async getSessionMessages( sessionId: string, options?: SessionMessagesOptions, @@ -3947,22 +3959,99 @@ export class SessionService { return (await this.getSubagentTranscript(sessionId, agentId)).messages } + /** Read only the addressed Agent call and result. Ordinary transcript bodies + * are parsed one bounded record at a time and never retained by card lookup. */ + async getSubagentRunLookup(sessionId: string, toolRef: string, agentId?: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) throw ApiError.notFound(`Session not found: ${sessionId}`) + const filePath = agentId ? this.subagentTranscriptPath(found.projectDir, sessionId, agentId) : found.filePath + const leaf = toolRef.slice(toolRef.lastIndexOf('/') + 1) + const ids = new Set([toolRef, leaf]) + const stat = await fs.stat(filePath, { bigint: true }).catch(error => { + if (error.code === 'ENOENT') return null + throw error + }) + if (!stat) return { messages: [], taskNotifications: [], historyComplete: true } + const version = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}` + const key = JSON.stringify([filePath, toolRef]) + const cached = this.subagentLookupCache.get(key) + if (cached?.version === version) return cached.transcript + return withHistoryReadBudget(undefined, async () => { + const entries: RawEntry[] = [] + const taskNotifications: SessionTaskNotification[] = [] + let bytes = 0 + let suppressTaskNotificationResponse = false + const scan = await streamBoundedHistory(filePath, raw => { + const entry = raw as RawEntry + const message = raw.message as { role?: string; content?: unknown } | undefined + if (!entry.isMeta && message?.role === 'user') { + if (this.isTaskNotificationContent(message.content)) suppressTaskNotificationResponse = true + else if (!this.isToolResultContent(message.content)) suppressTaskNotificationResponse = false + } + const content = Array.isArray(message?.content) ? message.content.filter((block: any) => + block?.type === 'tool_use' ? ids.has(block.id) : block?.type === 'tool_result' && ids.has(block.tool_use_id)) : [] + const notices = this.taskNotificationsFromEntries([entry]).filter(notice => ids.has(notice.toolUseId)) + if (content.length && !suppressTaskNotificationResponse) { + const selected = { ...entry, message: { ...message, content } } as RawEntry + bytes += Buffer.byteLength(JSON.stringify(selected)) + entries.push(selected) + } + if (notices.length) bytes += Buffer.byteLength(JSON.stringify(notices)) + taskNotifications.push(...notices) + if (bytes > 2 * 1024 * 1024 || entries.length + taskNotifications.length > 2048) { + throw new ApiError(413, 'Agent lookup exceeds its viewing budget', 'SUBAGENT_LOOKUP_LIMIT') + } + }) + if (scan.omittedRecords) throw new ApiError(413, 'Agent lookup contains records above the viewing limit', 'SUBAGENT_LOOKUP_INCOMPLETE') + const transcript = { messages: this.entriesToMessages(entries), taskNotifications, historyComplete: true } + this.subagentLookupCache.delete(key) + this.subagentLookupCache.set(key, { version, transcript }) + while (this.subagentLookupCache.size > 4) this.subagentLookupCache.delete(this.subagentLookupCache.keys().next().value!) + return transcript + }, 'metadata') + } + async getSubagentTranscript( sessionId: string, agentId: string, + options: { bounded?: boolean; toolUseId?: string } = {}, ): Promise { + if (options.toolUseId) return this.getSubagentRunLookup(sessionId, options.toolUseId, agentId) const found = await this.findSessionFile(sessionId) - if (!found) { - throw ApiError.notFound(`Session not found: ${sessionId}`) - } - - const entries = await this.readJsonlFile( - this.subagentTranscriptPath(found.projectDir, sessionId, agentId), - ) - return { - messages: this.entriesToMessages(entries), - taskNotifications: this.taskNotificationsFromEntries(entries), + if (!found) throw ApiError.notFound(`Session not found: ${sessionId}`) + const filePath = this.subagentTranscriptPath(found.projectDir, sessionId, agentId) + if (options.bounded) { + try { + // A byte-bounded small transcript can preserve the full Activity view, + // including more than 1000 tiny records. Large files use the tail page. + if ((await fs.stat(filePath)).size <= 1536 * 1024) { + return await withHistoryReadBudget(undefined, async () => { + const entries: RawEntry[] = [] + let retainedBytes = 0 + const scan = await streamBoundedHistory(filePath, entry => { + retainedBytes += Buffer.byteLength(JSON.stringify(entry)) + if (retainedBytes > 2 * 1024 * 1024) throw new ApiError(413, 'Agent transcript changed beyond its viewing budget', 'SUBAGENT_RECORD_LIMIT') + if (entries.length >= 10_000) throw new ApiError(413, 'Agent transcript exceeds its record budget', 'SUBAGENT_RECORD_LIMIT') + entries.push(entry as RawEntry) + }) + return { messages: this.entriesToMessages(entries), taskNotifications: this.taskNotificationsFromEntries(entries), historyComplete: scan.omittedRecords === 0 } + }) + } + const result = await readBoundedHistoryPage(filePath) + const projection = await this.projectHistoryPageEntries(filePath, result) + const entries = result.entries.map(item => item.entry as RawEntry) + return { + messages: this.entriesToMessages(projection.entries), + taskNotifications: this.taskNotificationsFromEntries(entries), + historyComplete: result.page.historyComplete, + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { messages: [], taskNotifications: [], historyComplete: true } + throw error + } } + const entries = await this.readJsonlFile(filePath) + return { messages: this.entriesToMessages(entries), taskNotifications: this.taskNotificationsFromEntries(entries) } } /** @@ -3975,6 +4064,21 @@ export class SessionService { * `expectedOwnerAgentId` is the physical parent transcript id; null denotes * a root-owned tool call. */ + private async readSubagentMetadata(filePath: string): Promise> { + const handle = await fs.open(filePath, 'r') + try { + const buffer = Buffer.alloc(64 * 1024 + 1) + let length = 0 + while (length < buffer.length) { + const read = await handle.read(buffer, length, buffer.length - length, length) + if (!read.bytesRead) break + length += read.bytesRead + } + if (length > 64 * 1024) throw new ApiError(413, 'Agent metadata exceeds its viewing budget', 'SUBAGENT_METADATA_LIMIT') + return JSON.parse(buffer.subarray(0, length).toString('utf8')) as Record + } finally { await handle.close() } + } + async findSubagentAgentIdByToolUseId( sessionId: string, toolUseId: string, @@ -3992,14 +4096,13 @@ export class SessionService { 'subagents', ) const files = await fs.readdir(subagentsDir).catch(() => []) + if (files.length > 4096) throw new ApiError(413, 'Agent directory exceeds its viewing budget', 'SUBAGENT_METADATA_LIMIT') const candidates: Array<{ agentId: string; ownerAgentId?: string }> = [] let metadataComplete = true for (const metadataFile of files.filter((file) => file.endsWith('.meta.json'))) { try { - const metadata = JSON.parse( - await fs.readFile(path.join(subagentsDir, metadataFile), 'utf8'), - ) as Record + const metadata = await this.readSubagentMetadata(path.join(subagentsDir, metadataFile)) if (metadata.toolUseId !== toolUseId) continue const ownerAgentId = typeof metadata.ownerAgentId === 'string' && metadata.ownerAgentId ? metadata.ownerAgentId @@ -4008,7 +4111,8 @@ export class SessionService { agentId: metadataFile.replace(/^agent-/, '').replace(/\.meta\.json$/, ''), ...(ownerAgentId ? { ownerAgentId } : {}), }) - } catch { + } catch (error) { + if (error instanceof ApiError) throw error // A half-written sidecar must not hide the other candidates. metadataComplete = false } @@ -4039,6 +4143,7 @@ export class SessionService { async getSubagentTranscriptFragmentsByAgentType( sessionId: string, agentType: string, + options: { bounded?: boolean; toolUseId?: string } = {}, ): Promise { const found = await this.findSessionFile(sessionId) if (!found) { @@ -4052,28 +4157,28 @@ export class SessionService { 'subagents', ) const files = await fs.readdir(subagentsDir).catch(() => []) + if (files.length > 4096) throw new ApiError(413, 'Agent directory exceeds its viewing budget', 'SUBAGENT_METADATA_LIMIT') const fragments: SubagentTranscriptFragment[] = [] for (const metadataFile of files.filter((file) => file.endsWith('.meta.json'))) { try { - const metadata = JSON.parse( - await fs.readFile(path.join(subagentsDir, metadataFile), 'utf8'), - ) as Record + const metadata = await this.readSubagentMetadata(path.join(subagentsDir, metadataFile)) if (metadata.agentType !== agentType) continue const transcriptFile = metadataFile.replace(/\.meta\.json$/, '.jsonl') const transcriptPath = path.join(subagentsDir, transcriptFile) - const [entries, stat] = await Promise.all([ - this.readJsonlFile(transcriptPath), + const [transcript, stat] = await Promise.all([ + this.getSubagentTranscript(sessionId, transcriptFile.replace(/^agent-/, '').replace(/\.jsonl$/, ''), options), fs.stat(transcriptPath), ]) fragments.push({ agentId: transcriptFile.replace(/^agent-/, '').replace(/\.jsonl$/, ''), - messages: this.entriesToMessages(entries), - taskNotifications: this.taskNotificationsFromEntries(entries), + ...transcript, modifiedAt: stat.mtimeMs, }) - } catch { + if (options.bounded && (fragments.length > 16 || Buffer.byteLength(JSON.stringify(fragments)) > 4 * 1024 * 1024)) throw new ApiError(413, 'Agent fragments exceed their viewing budget', 'SUBAGENT_FRAGMENTS_LIMIT') + } catch (error) { + if (error instanceof ApiError) throw error // A partially persisted fragment must not hide the other resumable runs. } } @@ -4087,69 +4192,37 @@ export class SessionService { const found = await this.findSessionFile(sessionId) if (!found) return null - let count = 0 - let last = '' - const agentToolUseIds = new Set() - const resultLinks = new Map() - await this.streamJsonlFile(found.filePath, (entry) => { - const agentToolUseId = this.extractAgentToolUseId(entry) - if (agentToolUseId) { - agentToolUseIds.add(agentToolUseId) + // This is an invalidation token, not a digest of message content. Trace + // polling must not parse hundreds of MB merely to decide whether to reload. + // Include every child transcript so in-flight agents (before their result + // links are persisted) also invalidate; metadata writes may also invalidate. + const hash = createHash('sha256') + const addFile = async (filePath: string): Promise => { + try { + const stat = await fs.stat(filePath, { bigint: true }) + hash.update(JSON.stringify([ + filePath, String(stat.dev), String(stat.ino), String(stat.size), + String(stat.mtimeNs), String(stat.ctimeNs), + ])) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + hash.update(JSON.stringify([filePath, 'missing'])) } - if (entry.message?.role === 'user' && Array.isArray(entry.message.content)) { - for (const block of entry.message.content as ContentBlock[]) { - if ( - block.type !== 'tool_result' || - typeof block.tool_use_id !== 'string' || - !agentToolUseIds.has(block.tool_use_id) - ) { - continue - } - const agentId = this.extractAgentIdFromResultText( - this.extractTextFromContent(block.content), - ) - if (agentId) { - resultLinks.set(block.tool_use_id, agentId) - } - } - } - if (!this.isVisibleTranscriptMessageEntry(entry)) return - count += 1 - const contentLength = safeJsonLength(entry.content) + safeJsonLength(entry.message?.content) - last = [ - entry.uuid ?? entry.messageId ?? '', - entry.type ?? '', - entry.timestamp ?? '', - entry.parentUuid ?? '', - entry.parent_tool_use_id ?? '', - contentLength, - ].join(':') - }) - - const subagentSignatures = await Promise.all( - [...resultLinks.entries()].map(async ([parentToolUseId, agentId]) => { - let childCount = 0 - let childLast = '' - await this.streamJsonlFile(this.subagentTranscriptPath(found.projectDir, sessionId, agentId), (entry) => { - if (!this.isVisibleTranscriptMessageEntry(entry)) return - childCount += 1 - const contentLength = safeJsonLength(entry.content) + safeJsonLength(entry.message?.content) - childLast = [ - parentToolUseId, - agentId, - entry.uuid ?? entry.messageId ?? '', - entry.type ?? '', - entry.timestamp ?? '', - entry.parentUuid ?? '', - entry.parent_tool_use_id ?? '', - contentLength, - ].join(':') - }) - return `${parentToolUseId}:${agentId}:${childCount}:${childLast}` - }), - ) - - return `${count}:${last}:${subagentSignatures.join('|')}` + } + await addFile(found.filePath) + const subagentsDir = path.join(path.dirname(found.filePath), sessionId, 'subagents') + let children: string[] + try { + children = await fs.readdir(subagentsDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + children = [] + } + // Sequential stats bound concurrent I/O even for sessions with many agents. + for (const name of children.filter(name => /^agent-.*\.jsonl$/.test(name)).sort()) { + await addFile(path.join(subagentsDir, name)) + } + return hash.digest('hex') } /** @@ -4340,14 +4413,7 @@ export class SessionService { const found = await this.findSessionFile(sessionId) if (!found) return null - const entries = await this.readJsonlFile(found.filePath) - let customTitle: string | null = null - for (const entry of entries) { - if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle.trim()) { - customTitle = entry.customTitle - } - } - return customTitle + return (await this.getMetadataProjection(found.filePath, found.projectDir)).customTitle } /** @@ -4360,8 +4426,9 @@ export class SessionService { const found = await this.findSessionFile(sessionId) if (!found) return null - const entries = await this.readJsonlFile(found.filePath) - return this.resolveWorkDirFromEntries(entries, found.projectDir) + const projection = await this.getMetadataProjection(found.filePath, found.projectDir) + if (!projection.complete) throw new ApiError(413, 'Session metadata contains oversized records', 'SESSION_METADATA_INCOMPLETE') + return projection.launchInfo.workDir } async getSessionMessageCwd( @@ -4385,52 +4452,10 @@ export class SessionService { const found = await this.findSessionFile(sessionId) if (!found) return memory ? { ...memory, transcriptMessageCount: 0 } : null - const entries = await this.readJsonlFile(found.filePath) - const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() - const repository = this.resolveRepositoryFromEntries(entries) - const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) - const permissionMode = this.resolvePermissionModeFromEntries(entries) - let customTitle: string | null = null - let runtimeProviderId: string | null | undefined - let runtimeModelId: string | undefined - let effortLevel: string | undefined - - for (const entry of entries) { - if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { - customTitle = entry.customTitle - } - if (entry.type === 'session-meta') { - const record = entry as Record - if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { - runtimeProviderId = record.runtimeProviderId as string | null - } - if (typeof record.runtimeModelId === 'string') { - runtimeModelId = record.runtimeModelId - } - if ( - typeof record.effortLevel === 'string' && - VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) - ) { - effortLevel = record.effortLevel - } - } - } - const transcriptMessageCount = this.countTranscriptMessages(entries) - - return { - filePath: found.filePath, - projectDir: found.projectDir, - workDir, - repository, - worktreeSession, - customTitle, - permissionMode, - ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), - ...(runtimeModelId ? { runtimeModelId } : {}), - ...(effortLevel ? { effortLevel } : {}), - ...memory, - transcriptMessageCount, - } + const projection = await this.getMetadataProjection(found.filePath, found.projectDir) + if (!projection.complete) throw new ApiError(413, 'Session metadata contains oversized records', 'SESSION_METADATA_INCOMPLETE') + const projected = projection.launchInfo + return { ...projected, ...memory, transcriptMessageCount: projected.transcriptMessageCount } } async deleteSessionFile(sessionId: string): Promise { @@ -4592,7 +4617,7 @@ export class SessionService { let repository = metadata.repository if (!repository) { for (const match of matches) { - const candidate = this.resolveRepositoryFromEntries(await this.readJsonlFile(match.filePath)) + const candidate = (await this.getMetadataProjection(match.filePath, match.projectDir)).launchInfo.repository if (candidate) { repository = candidate break @@ -4747,16 +4772,59 @@ export class SessionService { async getSessionFileHistorySnapshots( sessionId: string, + options: { bounded?: boolean } = {}, ): Promise { const found = await this.findSessionFile(sessionId) if (!found) { throw ApiError.notFound(`Session not found: ${sessionId}`) } - const entries = await this.readTargetedJsonlEntries( - found, - ['file-history-snapshot'], - ) ?? await this.readJsonlFile(found.filePath) + let entries: RawEntry[] + if (options.bounded) { + const stat = await fs.stat(found.filePath, { bigint: true }) + // The transcript is immutable between polls, but its interpreted token budget + // also depends on editable provider settings and process-level overrides. + const providers = await this.providerService.listProviders().catch(() => null) + const contextRevision = createHash('sha256').update(JSON.stringify({ + activeId: providers?.activeId, + providers: providers?.providers.map(provider => ({ + id: provider.id, + models: provider.models, + modelContextWindows: provider.modelContextWindows, + model1mSupport: provider.model1mSupport, + autoCompactWindow: provider.autoCompactWindow, + })), + env: [ + MODEL_CONTEXT_WINDOWS_ENV_KEY, 'CLAUDE_CODE_DISABLE_1M_CONTEXT', + 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'USER_TYPE', 'ANTHROPIC_BASE_URL', + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_AZURE_OPENAI', + ].map(name => process.env[name] ?? null), + })).digest('hex') + const key = `${found.filePath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${contextRevision}` + let request = this.uiFileHistoryReads.get(key) + if (!request) { + request = (async () => { + const selected: RawEntry[] = [] + let bytes = 0 + await this.streamJsonlFile(found.filePath, entry => { + if (entry.type !== 'file-history-snapshot') return + bytes += Buffer.byteLength(JSON.stringify(entry)) + if (bytes > 2 * 1024 * 1024 || selected.length >= 1000) { + throw new ApiError(413, 'File history exceeds its viewing budget', 'HISTORY_WORKSPACE_LIMIT') + } + selected.push(entry) + }) + return selected + })() + this.uiFileHistoryReads.set(key, request) + while (this.uiFileHistoryReads.size > 8) this.uiFileHistoryReads.delete(this.uiFileHistoryReads.keys().next().value!) + } + try { entries = await request } + catch (error) { this.uiFileHistoryReads.delete(key); throw error } + } else { + entries = await this.readTargetedJsonlEntries(found, ['file-history-snapshot']) ?? await this.readJsonlFile(found.filePath) + } const snapshotsByMessageId = new Map() for (const entry of entries) { diff --git a/src/server/services/subagentRunService.test.ts b/src/server/services/subagentRunService.test.ts index aee9cb19..dc16bac9 100644 --- a/src/server/services/subagentRunService.test.ts +++ b/src/server/services/subagentRunService.test.ts @@ -11,7 +11,7 @@ import { resolveSubagentRunFromMessages, truncateSubagentMessages, } from './subagentRunService.js' -import type { MessageEntry } from './sessionService.js' +import { sessionService, type MessageEntry } from './sessionService.js' let tmpDir: string | null = null @@ -311,6 +311,36 @@ describe('getSubagentRunByTool', () => { delete process.env.CLAUDE_CONFIG_DIR }) + it('bounds parent lookup and large child display without claiming complete activity or partial token totals', async () => { + await setupTmpConfigDir() + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-subagent-large' + const body = Array.from({ length: 24 }, (_, index) => ({ type: 'assistant', uuid: `body-${index}`, timestamp: '2026-01-01T00:00:05.000Z', message: { role: 'assistant', content: 'x'.repeat(256 * 1024) } })) + await writeSessionFile(projectDir, sessionId, [makeAgentToolUseEntry('tool-1'), ...body, makeAgentToolResultEntry('tool-1', 'abc123')]) + await writeSubagentTranscriptFile(projectDir, sessionId, 'abc123', body) + const original = (sessionService as any).readJsonlFile + ;(sessionService as any).readJsonlFile = () => { throw new Error('unbounded transcript read') } + try { + const result = await getSubagentRunByTool(sessionId, 'tool-1') + expect(result).toMatchObject({ agentId: 'abc123', truncated: true, historyComplete: false, activityComplete: false }) + expect(result!.messages.length).toBeGreaterThan(0) + expect(result!.messages.length).toBeLessThan(24) + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(2 * 1024 * 1024) + expect(result!.messages.at(-1)!.id).toBe('body-23') + } finally { + ;(sessionService as any).readJsonlFile = original + } + }) + + it('rejects oversized launch sidecars before parsing them', async () => { + await setupTmpConfigDir() + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-subagent-sidecar' + await writeSessionFile(projectDir, sessionId, [makeAgentToolUseEntry('tool-1')]) + await writeSubagentLaunchMetadata(projectDir, sessionId, 'abc123', { agentType: 'general', toolUseId: 'tool-1', description: 'x'.repeat(128 * 1024) }) + await expect(getSubagentRunByTool(sessionId, 'tool-1')).rejects.toMatchObject({ statusCode: 413, code: 'SUBAGENT_METADATA_LIMIT' }) + }) + it('returns parent metadata and visible persisted subagent transcript messages', async () => { await setupTmpConfigDir() const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' diff --git a/src/server/services/subagentRunService.ts b/src/server/services/subagentRunService.ts index 53628f25..139537f9 100644 --- a/src/server/services/subagentRunService.ts +++ b/src/server/services/subagentRunService.ts @@ -42,6 +42,8 @@ export type SubagentRunResponse = { /** Notifications whose nested Agent ids match `activityMessages`. */ activityTaskNotifications: SessionTaskNotification[] truncated: boolean + historyComplete?: boolean + activityComplete?: boolean updatedAt?: string source: SubagentRunSource /** @@ -518,13 +520,14 @@ async function resolveTranscript( agentId: string | null messages: MessageEntry[] taskNotifications: SessionTaskNotification[] + historyComplete?: boolean }> { const seen = new Set() for (const candidate of candidates) { const agentId = normalizeAgentIdHint(candidate ?? undefined) if (!agentId || seen.has(agentId)) continue seen.add(agentId) - const transcript = await sessionService.getSubagentTranscript(sessionId, agentId) + const transcript = await sessionService.getSubagentTranscript(sessionId, agentId, { bounded: true }) if (transcript.messages.length > 0 || transcript.taskNotifications.length > 0) { return { agentId, ...transcript } } @@ -564,6 +567,7 @@ async function resolveRunFromToolRef( const parentTranscript = await sessionService.getSubagentTranscript( sessionId, strictParentAgentId, + { bounded: true, toolUseId: nestedRef.leafToolUseId }, ) const nestedResolution = resolveSubagentRunFromMessages( parentTranscript.messages, @@ -590,6 +594,7 @@ async function resolveRunFromToolRef( const fragments = await sessionService.getSubagentTranscriptFragmentsByAgentType( sessionId, teammateName, + { bounded: true, toolUseId: nestedRef.leafToolUseId }, ) for (let index = fragments.length - 1; index >= 0; index -= 1) { const fragment = fragments[index]! @@ -659,7 +664,7 @@ export async function getSubagentRunByAgentId( taskNotifications: transcript.taskNotifications, }) const truncated = truncateSubagentMessages(activity.messages) - const usage = usageFromTranscriptMessages(messages) + const usage = transcript.historyComplete === false ? undefined : usageFromTranscriptMessages(messages) const updatedAt = latestTimestamp( ...messages.map(message => isRecord(message) && typeof message.timestamp === 'string' @@ -693,7 +698,9 @@ export async function getSubagentRunByAgentId( ...(truncated.truncated ? { activityMessages: activity.messages } : {}), taskNotifications: transcript.taskNotifications, activityTaskNotifications: activity.taskNotifications, - truncated: truncated.truncated, + truncated: truncated.truncated || transcript.historyComplete === false, + historyComplete: transcript.historyComplete !== false, + activityComplete: transcript.historyComplete !== false, ...(updatedAt ? { updatedAt } : {}), source: 'subagent-jsonl', // A workflow agent answers once into its script and is gone; there is no @@ -710,10 +717,7 @@ export async function getSubagentRunByTool( // Only the root-level `Agent` tool call is resolved here — its child // transcript is read separately below. Pulling the merged view instead would // re-materialize every linked subagent on each card open. - const [parentMessages, taskNotifications] = await Promise.all([ - sessionService.getSessionMessages(sessionId, { includeSubagents: false }), - sessionService.getSessionTaskNotifications(sessionId), - ]) + const { messages: parentMessages, taskNotifications } = await sessionService.getSubagentRunLookup(sessionId, toolUseId) const resolvedToolRef = await resolveRunFromToolRef(sessionId, parentMessages, toolUseId) if (!resolvedToolRef) return null const { resolution, lookupToolUseId, expectedOwnerAgentId } = resolvedToolRef @@ -728,7 +732,7 @@ export async function getSubagentRunByTool( ? resolution.agentId.split('@')[0] : undefined const teammateFragments = teammateName - ? await sessionService.getSubagentTranscriptFragmentsByAgentType(sessionId, teammateName) + ? await sessionService.getSubagentTranscriptFragmentsByAgentType(sessionId, teammateName, { bounded: true }) : [] // Sidecar metadata is written before the agent starts, so it is the only // hint that exists while the run is still streaming. The other candidates @@ -742,6 +746,7 @@ export async function getSubagentRunByTool( ) const transcript = teammateFragments.length > 0 ? { + historyComplete: teammateFragments.every(fragment => fragment.historyComplete !== false), agentId: teammateFragments[teammateFragments.length - 1]!.agentId, messages: mergeTeammateTranscriptFragments(teammateFragments), taskNotifications: mergeTeammateTranscriptTaskNotifications(teammateFragments), @@ -778,7 +783,7 @@ export async function getSubagentRunByTool( ) const transcriptMessages = transcript.messages const truncated = truncateSubagentMessages(activity.messages) - const transcriptUsage = usageFromTranscriptMessages(transcriptMessages) + const transcriptUsage = transcript.historyComplete === false ? undefined : usageFromTranscriptMessages(transcriptMessages) const usage = mergeUsage(resolution.usage, transcriptUsage) const latestTranscriptTimestamp = latestTimestamp( ...transcriptMessages.map((message) => ( @@ -809,7 +814,9 @@ export async function getSubagentRunByTool( ...(truncated.truncated ? { activityMessages: activity.messages } : {}), taskNotifications: transcript.taskNotifications, activityTaskNotifications: activity.taskNotifications, - truncated: truncated.truncated, + truncated: truncated.truncated || transcript.historyComplete === false, + historyComplete: transcript.historyComplete !== false, + activityComplete: transcript.historyComplete !== false, ...(latestTimestamp(resolution.updatedAt, notification?.timestamp, latestTranscriptTimestamp) ? { updatedAt: latestTimestamp(resolution.updatedAt, notification?.timestamp, latestTranscriptTimestamp) } : {}), diff --git a/src/server/services/teamService.ts b/src/server/services/teamService.ts index 10cbb27f..f4f3bb90 100644 --- a/src/server/services/teamService.ts +++ b/src/server/services/teamService.ts @@ -1,3 +1,4 @@ +import { readLegacyTranscriptFiles } from './legacyTranscriptBudget.js' /** * TeamService — 读取 CLI 生成的 Agent Teams 配置 * @@ -13,6 +14,7 @@ import * as path from 'path' import * as os from 'os' import * as crypto from 'node:crypto' import { ApiError } from '../middleware/errorHandler.js' +import { readTeamTranscriptProjection } from './teamTranscriptProjection.js' import { writeToMailbox } from '../../utils/teammateMailbox.js' import { sessionService, @@ -1413,7 +1415,7 @@ type TeamFileRaw = { export class TeamService { private readonly sessionLocator: Pick - private readonly sessionReader: Pick + private readonly sessionReader: Pick | undefined private readonly localIndexGateway: LocalIndexGateway private readonly targetedEntryReader: typeof readSessionEntriesByLocator private readonly archiveWriteLocks = new Map>() @@ -1425,11 +1427,26 @@ export class TeamService { targetedEntryReader?: typeof readSessionEntriesByLocator } = {}) { this.sessionLocator = options.sessionLocator ?? sessionService - this.sessionReader = options.sessionReader ?? sessionService + this.sessionReader = options.sessionReader this.localIndexGateway = options.localIndexGateway ?? localIndexCoordinator this.targetedEntryReader = options.targetedEntryReader ?? readSessionEntriesByLocator } + private async readTeamHistory(sessionId: string): Promise { + // Injected fixture readers keep the migration/replay seam. Production never + // routes periodic workbench polling through the canonical full transcript. + if (this.sessionReader) return this.sessionReader.getSessionMessages(sessionId) + const found = await this.sessionLocator.findSessionFile(sessionId) + if (!found) return [] + const projection = await readTeamTranscriptProjection(found.filePath).catch(() => { + throw new ApiError(503, 'Team history is changing; retry shortly', 'TEAM_HISTORY_INCOMPLETE') + }) + if (!projection.complete) { + throw new ApiError(503, 'Team history projection is incomplete; existing workbench state was preserved', 'TEAM_HISTORY_INCOMPLETE') + } + return projection.messages + } + private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } @@ -1889,8 +1906,9 @@ export class TeamService { let messages: SessionMessageEntry[] try { - messages = await this.sessionReader.getSessionMessages(sessionId) - } catch { + messages = await this.readTeamHistory(sessionId) + } catch (error) { + if (error instanceof ApiError && error.code === 'TEAM_HISTORY_INCOMPLETE') throw error return null } const projected = projectTeamWorkbenchesFromTranscript(sessionId, messages) @@ -1996,7 +2014,7 @@ export class TeamService { let messages: SessionMessageEntry[] | undefined let lifecycle: TaskListLifecycleState | undefined try { - messages = await this.sessionReader.getSessionMessages(sessionId) + messages = await this.readTeamHistory(sessionId) } catch { // The archive tombstone must still be written when a legacy or partially // persisted lead transcript cannot be read. @@ -3101,8 +3119,8 @@ export class TeamService { options: TeamTranscriptPageOptions, projection: TranscriptFragmentProjection = {}, ): Promise { - const bytes = await fs.readFile(filePath) - const stat = await fs.stat(filePath) + const source = (await readLegacyTranscriptFiles([filePath]))[0]! + const { bytes, stat } = source return this.parseTranscriptBufferPage( bytes, options, @@ -3116,12 +3134,11 @@ export class TeamService { sources: Array<{ filePath: string; ownerAgentId?: string }>, options: TeamTranscriptPageOptions, ): Promise { - const allFragments = await Promise.all(sources.map(async source => { - const [bytes, stat] = await Promise.all([ - fs.readFile(source.filePath), - fs.stat(source.filePath), - ]) - return { ...source, bytes, ctimeMs: stat.ctimeMs } + const boundedFiles = await readLegacyTranscriptFiles(sources.map(source => source.filePath)) + const allFragments = sources.map((source, index) => ({ + ...source, + bytes: boundedFiles[index]!.bytes, + ctimeMs: boundedFiles[index]!.stat.ctimeMs, })) const fragments = dropSupersededTranscriptFragments(allFragments) const ownerAgentIdByOrdinal: Array = [] diff --git a/src/server/services/teamTranscriptProjection.test.ts b/src/server/services/teamTranscriptProjection.test.ts new file mode 100644 index 00000000..d43e80b0 --- /dev/null +++ b/src/server/services/teamTranscriptProjection.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readTeamTranscriptProjection } from './teamTranscriptProjection.js' + +let dir: string +beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'team-projection-')) }) +afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) +const tool = (id: string, name: string, input: unknown) => JSON.stringify({ uuid: id, timestamp: '2026-01-01T00:00:00Z', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } }) + '\n' + +describe('bounded Team transcript projection', () => { + test('caches empty large histories and only scans subsequently appended bytes', async () => { + const file = join(dir, 'session.jsonl') + const unrelated = JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(8192) }] } }) + '\n' + await writeFile(file, unrelated.repeat(640)) + const [first, concurrent] = await Promise.all([readTeamTranscriptProjection(file), readTeamTranscriptProjection(file)]) + expect(first).toBe(concurrent) + expect(first.complete).toBe(true) + expect(first.messages).toEqual([]) + expect(first.readBytes).toBeGreaterThan(5 * 1024 * 1024) + expect((await readTeamTranscriptProjection(file)).readBytes).toBe(0) + const appended = tool('create', 'TeamCreate', { team_name: 'test' }) + await appendFile(file, appended) + const changed = await readTeamTranscriptProjection(file) + expect(changed.readBytes).toBe(Buffer.byteLength(appended)) + expect(changed.messages).toHaveLength(1) + }) + + test('preserves paired task results and Team lifecycle evidence across appends', async () => { + const file = join(dir, 'session.jsonl') + await writeFile(file, tool('team', 'TeamCreate', { team_name: 'test' }) + tool('task', 'TaskCreate', { subject: 'Keep task' })) + await readTeamTranscriptProjection(file) + await appendFile(file, JSON.stringify({ uuid: 'result', timestamp: '2026-01-01T00:00:01Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'task', content: '{"task":{"id":"1"}}' }] }, toolUseResult: { task: { id: '1' }, taskListMutationRevision: 2 } }) + '\n' + tool('delete', 'TeamDelete', {})) + const projection = await readTeamTranscriptProjection(file) + expect(projection.messages).toHaveLength(4) + expect(projection.messages[2]?.toolUseResult).toEqual({ task: { id: '1' }, taskListMutationRevision: 2 }) + }) + + test('rebuilds after truncation and retries a partial tail after completion', async () => { + const file = join(dir, 'session.jsonl') + const create = tool('team', 'TeamCreate', { team_name: 'first' }) + await writeFile(file, create.slice(0, -2)) + expect((await readTeamTranscriptProjection(file)).complete).toBe(false) + await appendFile(file, create.slice(-2)) + expect((await readTeamTranscriptProjection(file)).messages).toHaveLength(1) + expect((await readTeamTranscriptProjection(file)).complete).toBe(true) + await writeFile(file, '{}\n') + expect((await readTeamTranscriptProjection(file)).messages).toEqual([]) + }) + + test('signals incomplete evidence instead of claiming a missing team for oversized input', async () => { + const file = join(dir, 'session.jsonl') + await writeFile(file, tool('large', 'TeamCreate', { team_name: 'test', description: 'x'.repeat(2 * 1024 * 1024) })) + const projection = await readTeamTranscriptProjection(file) + expect(projection.complete).toBe(false) + expect(projection.messages).toEqual([]) + expect((await readTeamTranscriptProjection(file)).readBytes).toBe(0) + }) +}) diff --git a/src/server/services/teamTranscriptProjection.ts b/src/server/services/teamTranscriptProjection.ts new file mode 100644 index 00000000..0b476e1d --- /dev/null +++ b/src/server/services/teamTranscriptProjection.ts @@ -0,0 +1,152 @@ +import { open, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { withHistoryReadBudget } from './boundedSessionHistory.js' +import type { MessageEntry } from './sessionService.js' + +const MAX_RECORD_BYTES = 1024 * 1024 +const MAX_PROJECTION_BYTES = 3 * 1024 * 1024 +const MAX_PROJECTION_ENTRIES = 10_000 +const WINDOW_BYTES = 4096 +const TEAM_TOOLS = new Set(['TeamCreate', 'TeamDelete', 'Agent', 'TaskCreate', 'TaskUpdate', 'TaskList', 'SendMessage']) +type Projection = { + messages: MessageEntry[] + complete: boolean + sourceVersion: string + readBytes: number +} +type CachedProjection = Projection & { + completeRecords: boolean + teamSeen: boolean + identity: string + size: number + mtime: string + indexedBytes: number + firstHash: string + boundaryHash: string + bytes: number + toolIds: Set +} +const cache = new Map() +const pending = new Map>() + +/** A bounded, incremental source for legacy Team migration and terminal replay. */ +export async function readTeamTranscriptProjection(filePath: string): Promise { + const existing = pending.get(filePath) + if (existing) return existing + const operation = withHistoryReadBudget(undefined, () => readProjection(filePath), 'metadata') + pending.set(filePath, operation) + try { return await operation } finally { if (pending.get(filePath) === operation) pending.delete(filePath) } +} + +async function readProjection(filePath: string): Promise { + const handle = await open(filePath, 'r') + try { + const before = await handle.stat({ bigint: true }) + const size = Number(before.size) + const identity = `${before.dev}:${before.ino}` + const mtime = String(before.mtimeNs) + const previous = cache.get(filePath) + if (previous?.identity === identity && previous.size === size && previous.mtime === mtime) { + cache.delete(filePath) + cache.set(filePath, previous) + return { ...previous, readBytes: 0 } + } + async function windowHash(end: number) { + const length = Math.min(WINDOW_BYTES, end) + const bytes = Buffer.alloc(length) + const result = await handle.read(bytes, 0, length, end - length) + if (result.bytesRead !== length) throw new Error('Team transcript changed during projection') + return createHash('sha256').update(bytes).digest('hex') + } + const canAppend = previous?.identity === identity && size > previous.size && + await windowHash(Math.min(WINDOW_BYTES, previous.size)) === previous.firstHash && + await windowHash(previous.indexedBytes) === previous.boundaryHash + const state: CachedProjection = { + messages: canAppend ? [...previous.messages] : [], + toolIds: new Set(canAppend ? previous.toolIds : []), + complete: canAppend ? previous.completeRecords : true, + completeRecords: canAppend ? previous.completeRecords : true, + teamSeen: canAppend ? previous.teamSeen : false, + bytes: canAppend ? previous.bytes : 0, + indexedBytes: canAppend ? previous.indexedBytes : 0, + sourceVersion: `${identity}:${size}:${mtime}`, + identity, size, mtime, firstHash: '', boundaryHash: '', readBytes: 0, + } + let position = state.indexedBytes + let parts: Buffer[] = [] + let lineBytes = 0 + let skipping = false + const chunk = Buffer.allocUnsafe(128 * 1024) + while (position < size) { + const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - position), position) + if (!bytesRead) throw new Error('Team transcript changed during projection') + state.readBytes += bytesRead + let start = 0 + while (start < bytesRead) { + const newline = chunk.indexOf(10, start) + const end = newline >= 0 && newline < bytesRead ? newline : bytesRead + lineBytes += end - start + if (lineBytes > MAX_RECORD_BYTES) { skipping = true; parts = [] } + if (!skipping) parts.push(Buffer.from(chunk.subarray(start, end))) + if (end < bytesRead) { + if (skipping) state.complete = false + else if (lineBytes) { + let entry: unknown + try { entry = JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8')) } catch { state.complete = false } + if (entry && typeof entry === 'object') collectTeamEvidence(entry as Record, state) + } + state.indexedBytes = position + end + 1 + parts = []; lineBytes = 0; skipping = false + } + start = end + 1 + } + position += bytesRead + await new Promise(resolve => setImmediate(resolve)) + } + // A partial tail is retried from its start on the next append. + state.completeRecords = state.complete + if (lineBytes) state.complete = false + const after = await handle.stat({ bigint: true }) + const current = await stat(filePath, { bigint: true }) + if (after.dev !== before.dev || after.ino !== before.ino || current.dev !== before.dev || current.ino !== before.ino || + after.size < before.size || current.size < before.size || + (after.size === before.size && after.mtimeNs !== before.mtimeNs) || + (current.size === before.size && current.mtimeNs !== before.mtimeNs)) { + throw new Error('Team transcript changed during projection') + } + state.firstHash = await windowHash(Math.min(WINDOW_BYTES, size)) + state.boundaryHash = await windowHash(state.indexedBytes) + cache.delete(filePath) + cache.set(filePath, state) + while (cache.size > 4) cache.delete(cache.keys().next().value!) + return state + } finally { await handle.close() } +} + +function collectTeamEvidence(entry: Record, state: CachedProjection) { + if (state.bytes >= MAX_PROJECTION_BYTES || state.messages.length >= MAX_PROJECTION_ENTRIES) { state.complete = false; return } + const raw = entry.message as { role?: string; content?: unknown } | undefined + if (!raw || typeof raw !== 'object') return + const blocks = Array.isArray(raw.content) ? raw.content : [] + if (blocks.some(block => block?.type === 'tool_use' && block.name === 'TeamCreate')) state.teamSeen = true + const tools = blocks.filter((block): block is Record => Boolean(block && typeof block === 'object' && block.type === 'tool_use' && state.teamSeen && TEAM_TOOLS.has(block.name))) + for (const block of tools) if (typeof block.id === 'string') state.toolIds.add(block.id) + const results = blocks.filter((block): block is Record => Boolean(block && typeof block === 'object' && block.type === 'tool_result' && state.toolIds.has(block.tool_use_id))) + const text = typeof raw.content === 'string' ? raw.content : blocks.filter(block => block?.type === 'text').map(block => block.text ?? '').join('\n') + const teammate = raw.role === 'user' && text.includes(' MAX_PROJECTION_BYTES) { state.complete = false; state.bytes = MAX_PROJECTION_BYTES; return } + state.bytes += bytes + state.messages.push(message) +} diff --git a/src/server/services/teamWatcher.ts b/src/server/services/teamWatcher.ts index d1eacbcd..f5d03ab5 100644 --- a/src/server/services/teamWatcher.ts +++ b/src/server/services/teamWatcher.ts @@ -528,7 +528,11 @@ export class TeamWatcher { private extractSubagentName(filePath: string): string | null { try { - const head = fs.readFileSync(filePath, 'utf-8').slice(0, 8192) + const fd = fs.openSync(filePath, 'r') + const prefix = Buffer.allocUnsafe(8192) + let length: number + try { length = fs.readSync(fd, prefix, 0, prefix.length, 0) } finally { fs.closeSync(fd) } + const head = prefix.toString('utf8', 0, length) const lines = head.split('\n').filter((line) => line.trim().length > 0) for (const line of lines) { diff --git a/src/server/services/traceCaptureService.ts b/src/server/services/traceCaptureService.ts index 49873910..ee59ac59 100644 --- a/src/server/services/traceCaptureService.ts +++ b/src/server/services/traceCaptureService.ts @@ -34,6 +34,8 @@ export type { TraceRequestSemantic, TraceResponseCapture, TraceSession, + TraceOverviewOptions, + TraceSessionWindow, TraceSessionDeleteResult, TraceSessionFileItem, TraceSessionFileList, diff --git a/src/server/services/workflowService.ts b/src/server/services/workflowService.ts index 95db55a8..307f9d66 100644 --- a/src/server/services/workflowService.ts +++ b/src/server/services/workflowService.ts @@ -34,6 +34,7 @@ import type { WorkflowPhaseMeta, WorkflowProgressEvent, } from '../../utils/workflows/types.js' +import { readWorkflowTranscriptProjection } from './workflowTranscriptProjection.js' import { ApiError } from '../middleware/errorHandler.js' import { sessionService, @@ -941,24 +942,25 @@ class WorkflowService { } const seenPaths = new Set() + let evidenceBytes = 0 + let evidenceRecords = 0 for (const file of files.sort((left, right) => left.modifiedAt - right.modifiedAt)) { if (seenPaths.has(file.filePath)) continue seenPaths.add(file.filePath) - let raw: string + let projection try { - raw = await fs.readFile(file.filePath, 'utf8') - } catch { - continue + projection = await readWorkflowTranscriptProjection(file.filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error } - for (const line of raw.split('\n')) { - if (!line.trim()) continue - let entry: Record - try { - const parsed = JSON.parse(line) - if (!isObjectRecord(parsed)) continue - entry = parsed - } catch { - continue + if (!projection.complete) { + throw new ApiError(503, 'Workflow history exceeds the bounded reconstruction budget', 'WORKFLOW_HISTORY_INCOMPLETE') + } + for (const entry of projection.messages) { + evidenceBytes += Buffer.byteLength(JSON.stringify(entry)) + if (++evidenceRecords > 10_000 || evidenceBytes > 3 * 1024 * 1024) { + throw new ApiError(503, 'Workflow history exceeds the bounded reconstruction budget', 'WORKFLOW_HISTORY_INCOMPLETE') } this.collectWorkflowLifecycleEntry(entry, file.ownerAgentId, lifecycle) } diff --git a/src/server/services/workflowTranscriptProjection.test.ts b/src/server/services/workflowTranscriptProjection.test.ts new file mode 100644 index 00000000..7268b97a --- /dev/null +++ b/src/server/services/workflowTranscriptProjection.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, expect, test, spyOn } from 'bun:test' +import { mkdtemp, rm, writeFile, appendFile, rename } from 'node:fs/promises' +import * as fs from 'node:fs/promises' +import { workflowService } from './workflowService.js' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readWorkflowTranscriptProjection } from './workflowTranscriptProjection.js' + +let dir: string +beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'workflow-projection-')) }) +afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) +const launch = JSON.stringify({ toolUseResult: { runId: 'wf_run', taskId: 'task', taskType: 'local_workflow', status: 'async_launched' } }) + '\n' + +test('large histories without workflows retain no bodies and subsequent polls read zero bytes', async () => { + const file = join(dir, 'session.jsonl') + await writeFile(file, (JSON.stringify({ message: { content: 'x'.repeat(8192) } }) + '\n').repeat(640)) + const [first, concurrent] = await Promise.all([readWorkflowTranscriptProjection(file), readWorkflowTranscriptProjection(file)]) + expect(first).toBe(concurrent) + expect(first.complete).toBe(true) + expect(first.messages).toEqual([]) + expect(first.readBytes).toBeGreaterThan(5 * 1024 * 1024) + expect((await readWorkflowTranscriptProjection(file)).readBytes).toBe(0) + await appendFile(file, launch) + const appended = await readWorkflowTranscriptProjection(file) + expect(appended.readBytes).toBe(Buffer.byteLength(launch)) + expect(appended.messages).toHaveLength(1) +}) + +test('retains structured launch, persisted terminal and XML terminal evidence', async () => { + const file = join(dir, 'session.jsonl') + const terminal = { type: 'cc-haha-task-notification', taskNotification: { taskId: 'task', status: 'completed' } } + const xml = { message: { content: [{ type: 'text', text: 'taskcompleted' }] } } + await writeFile(file, launch + JSON.stringify(terminal) + '\n' + JSON.stringify(xml) + '\n') + expect((await readWorkflowTranscriptProjection(file)).messages).toEqual([JSON.parse(launch), terminal, xml]) + await writeFile(join(dir, 'replacement'), '{}\n') + await rename(join(dir, 'replacement'), file) + expect((await readWorkflowTranscriptProjection(file)).messages).toEqual([]) +}) + +test('oversized records and excessive lifecycle evidence explicitly report incomplete', async () => { + const file = join(dir, 'session.jsonl') + await writeFile(file, JSON.stringify({ toolUseResult: { taskType: 'local_workflow', body: 'x'.repeat(2 * 1024 * 1024) } }) + '\n') + expect((await readWorkflowTranscriptProjection(file)).complete).toBe(false) + expect((await readWorkflowTranscriptProjection(file)).readBytes).toBe(0) + await writeFile(file, launch.repeat(10_001)) + const projection = await readWorkflowTranscriptProjection(file) + expect(projection.complete).toBe(false) + expect(projection.messages).toHaveLength(10_000) +}) + + +test('workflow reconstruction consumes projected evidence without a whole-file read fallback', async () => { + const file = join(dir, 'session.jsonl') + await writeFile(file, launch) + const readAll = spyOn(fs, 'readFile').mockImplementation(() => { throw new Error('Unbounded read forbidden') }) + try { + const lifecycle = await (workflowService as unknown as { + readSessionWorkflowLifecycle(id: string, dirs: Array<{ sessionId: string; dir: string }>): Promise<{ launchesByRunId: Map }> + }).readSessionWorkflowLifecycle('session', [{ sessionId: 'session', dir: join(dir, 'session') }]) + expect(lifecycle.launchesByRunId.has('wf_run')).toBe(true) + expect(readAll).not.toHaveBeenCalled() + } finally { readAll.mockRestore() } +}) diff --git a/src/server/services/workflowTranscriptProjection.ts b/src/server/services/workflowTranscriptProjection.ts new file mode 100644 index 00000000..04b1f232 --- /dev/null +++ b/src/server/services/workflowTranscriptProjection.ts @@ -0,0 +1,143 @@ +import { open, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { withHistoryReadBudget } from './boundedSessionHistory.js' + +const MAX_RECORD_BYTES = 1024 * 1024 +const MAX_PROJECTION_BYTES = 3 * 1024 * 1024 +const MAX_PROJECTION_ENTRIES = 10_000 +const WINDOW_BYTES = 4096 +type Projection = { + messages: Record[] + complete: boolean + sourceVersion: string + readBytes: number +} +type CachedProjection = Projection & { + completeRecords: boolean + identity: string + size: number + mtime: string + indexedBytes: number + firstHash: string + boundaryHash: string + bytes: number +} +const cache = new Map() +const pending = new Map>() + +/** A bounded, incremental source for workflow lifecycle reconstruction. */ +export async function readWorkflowTranscriptProjection(filePath: string): Promise { + const existing = pending.get(filePath) + if (existing) return existing + const operation = withHistoryReadBudget(undefined, () => readProjection(filePath), 'metadata') + pending.set(filePath, operation) + try { return await operation } finally { if (pending.get(filePath) === operation) pending.delete(filePath) } +} + +async function readProjection(filePath: string): Promise { + const handle = await open(filePath, 'r') + try { + const before = await handle.stat({ bigint: true }) + const size = Number(before.size) + const identity = `${before.dev}:${before.ino}` + const mtime = String(before.mtimeNs) + const previous = cache.get(filePath) + if (previous?.identity === identity && previous.size === size && previous.mtime === mtime) { + cache.delete(filePath) + cache.set(filePath, previous) + return { ...previous, readBytes: 0 } + } + async function windowHash(end: number) { + const length = Math.min(WINDOW_BYTES, end) + const bytes = Buffer.alloc(length) + const result = await handle.read(bytes, 0, length, end - length) + if (result.bytesRead !== length) throw new Error('Workflow transcript changed during projection') + return createHash('sha256').update(bytes).digest('hex') + } + const canAppend = previous?.identity === identity && size > previous.size && + await windowHash(Math.min(WINDOW_BYTES, previous.size)) === previous.firstHash && + await windowHash(previous.indexedBytes) === previous.boundaryHash + const state: CachedProjection = { + messages: canAppend ? [...previous.messages] : [], + complete: canAppend ? previous.completeRecords : true, + completeRecords: canAppend ? previous.completeRecords : true, + bytes: canAppend ? previous.bytes : 0, + indexedBytes: canAppend ? previous.indexedBytes : 0, + sourceVersion: `${identity}:${size}:${mtime}`, + identity, size, mtime, firstHash: '', boundaryHash: '', readBytes: 0, + } + let position = state.indexedBytes + let parts: Buffer[] = [] + let lineBytes = 0 + let skipping = false + const chunk = Buffer.allocUnsafe(128 * 1024) + while (position < size) { + const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - position), position) + if (!bytesRead) throw new Error('Workflow transcript changed during projection') + state.readBytes += bytesRead + let start = 0 + while (start < bytesRead) { + const newline = chunk.indexOf(10, start) + const end = newline >= 0 && newline < bytesRead ? newline : bytesRead + lineBytes += end - start + if (lineBytes > MAX_RECORD_BYTES) { skipping = true; parts = [] } + if (!skipping) parts.push(Buffer.from(chunk.subarray(start, end))) + if (end < bytesRead) { + if (skipping) state.complete = false + else if (lineBytes) { + let entry: unknown + try { entry = JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8')) } catch { state.complete = false } + if (entry && typeof entry === 'object') collectWorkflowEvidence(entry as Record, state) + } + state.indexedBytes = position + end + 1 + parts = []; lineBytes = 0; skipping = false + } + start = end + 1 + } + position += bytesRead + await new Promise(resolve => setImmediate(resolve)) + } + // A partial tail is retried from its start on the next append. + state.completeRecords = state.complete + if (lineBytes) state.complete = false + const after = await handle.stat({ bigint: true }) + const current = await stat(filePath, { bigint: true }) + if (after.dev !== before.dev || after.ino !== before.ino || current.dev !== before.dev || current.ino !== before.ino || + after.size < before.size || current.size < before.size || + (after.size === before.size && after.mtimeNs !== before.mtimeNs) || + (current.size === before.size && current.mtimeNs !== before.mtimeNs)) { + throw new Error('Workflow transcript changed during projection') + } + state.firstHash = await windowHash(Math.min(WINDOW_BYTES, size)) + state.boundaryHash = await windowHash(state.indexedBytes) + cache.delete(filePath) + cache.set(filePath, state) + while (cache.size > 4) cache.delete(cache.keys().next().value!) + return state + } finally { await handle.close() } +} + +function collectWorkflowEvidence(entry: Record, state: CachedProjection) { + // Parse only bounded records; retain lifecycle evidence, never conversation bodies. + // The existing service remains the structured interpreter for these candidates. + const pending: unknown[] = [entry] + let relevant = entry.type === 'cc-haha-task-notification' + let nodes = 0 + while (pending.length && !relevant && nodes++ < 16_384) { + const value = pending.pop() + if (typeof value === 'string') { + relevant = value.includes('') || value.includes('local_workflow') || value.includes('async_launched') + } else if (value && typeof value === 'object') { + for (const nested of Object.values(value)) pending.push(nested) + } + } + if (pending.length && !relevant) state.complete = false + if (!relevant) return + const bytes = Buffer.byteLength(JSON.stringify(entry)) + if (state.bytes + bytes > MAX_PROJECTION_BYTES || state.messages.length >= MAX_PROJECTION_ENTRIES) { + state.complete = false + return + } + state.bytes += bytes + state.messages.push(entry) +} diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 315c1f7a..09ea6cff 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -1,3 +1,4 @@ +import { ApiError } from '../middleware/errorHandler.js' import * as fs from 'node:fs/promises' import { watch, type FSWatcher } from 'node:fs' import { execFile as execFileCallback } from 'node:child_process' @@ -791,7 +792,8 @@ export class WorkspaceService { let messages: MessageEntry[] try { messages = await this.resolveSessionMessages(sessionId) - } catch { + } catch (error) { + if (error instanceof ApiError && error.statusCode === 413) throw error return [] } @@ -841,7 +843,8 @@ export class WorkspaceService { let snapshots: FileHistorySnapshot[] try { snapshots = await this.resolveSessionFileHistorySnapshots(sessionId) - } catch { + } catch (error) { + if (error instanceof ApiError && error.statusCode === 413) throw error return [] } if (snapshots.length === 0) return [] diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index a621a533..fafdc842 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1735,12 +1735,9 @@ async function handleSetRuntimeConfig( return } - if (conversationService.hasSession(sessionId)) { - await persistSessionRuntimeConfig(sessionId, nextOverride) - await restartSessionWithRuntimeConfig(ws, sessionId) - return - } - + // A spawned process is already registered before its SDK startup settles. + // Wait for that startup before restarting, otherwise stopping it rejects + // the first turn that is still awaiting the same startup promise. const pendingStartup = sessionStartupPromises.get(sessionId) if (pendingStartup) { const startupRuntimeVersion = sessionStartupRuntimeVersions.get(sessionId) ?? 0 @@ -1767,6 +1764,12 @@ async function handleSetRuntimeConfig( return } + if (conversationService.hasSession(sessionId)) { + await persistSessionRuntimeConfig(sessionId, nextOverride) + await restartSessionWithRuntimeConfig(ws, sessionId) + return + } + await persistSessionRuntimeConfig(sessionId, nextOverride) broadcastAppliedRuntimeConfig(sessionId) }) diff --git a/src/services/api/traceCapture.bounds.test.ts b/src/services/api/traceCapture.bounds.test.ts new file mode 100644 index 00000000..29ed6422 --- /dev/null +++ b/src/services/api/traceCapture.bounds.test.ts @@ -0,0 +1,278 @@ +import { expect, spyOn, test } from 'bun:test' +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + clearTraceCaptureStateForTests, + traceCaptureService, + TRACE_RECORD_BYTES_LIMIT, + TRACE_WINDOW_RECORD_LIMIT, + TRACE_WINDOW_BYTES_LIMIT, + captureResponseTraceSnapshot, + TRACE_STREAM_CAPTURE_BYTES, +} from './traceCapture.js' + +async function fixture(run: (filePath: string) => Promise) { + const previousConfig = process.env.CLAUDE_CONFIG_DIR + const previousMode = process.env.CC_HAHA_LOCAL_INDEX + const scope = await fs.mkdtemp(join(tmpdir(), 'trace-hard-bounds-')) + process.env.CLAUDE_CONFIG_DIR = scope + process.env.CC_HAHA_LOCAL_INDEX = 'on' + clearTraceCaptureStateForTests() + try { + const dir = join(scope, 'cc-haha', 'traces') + await fs.mkdir(dir, { recursive: true }) + await run(join(dir, 'fixture.jsonl')) + } finally { + clearTraceCaptureStateForTests() + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfig + if (previousMode === undefined) delete process.env.CC_HAHA_LOCAL_INDEX + else process.env.CC_HAHA_LOCAL_INDEX = previousMode + await fs.rm(scope, { recursive: true, force: true }) + } +} + +function callLine(id: string, preview = '') { + return JSON.stringify({ type: 'call', record: { + id, sessionId: 'fixture', source: 'proxy', status: 'ok', startedAt: '2026-01-01T00:00:00Z', + request: { method: 'POST', url: 'https://example.test', headers: {}, body: { + bytes: preview.length, contentType: 'text', preview, sha256: '', truncated: false, + } }, + } }) + '\n' +} + +test('skips oversized JSONL records before parse and keeps raw bytes available', () => fixture(async filePath => { + await fs.writeFile(filePath, callLine('huge', 'x'.repeat(TRACE_RECORD_BYTES_LIMIT * 3)) + callLine('small')) + const bytesBefore = (await fs.stat(filePath)).size + const parse = JSON.parse.bind(JSON) + let largestParsedString = 0 + const parseSpy = spyOn(JSON, 'parse').mockImplementation((value, reviver) => { + largestParsedString = Math.max(largestParsedString, typeof value === 'string' ? Buffer.byteLength(value) : 0) + return parse(value, reviver) + }) + try { + const overview = await traceCaptureService.getSessionTraceOverview('fixture') + expect(overview.calls.map(call => call.id)).toEqual(['small']) + expect(overview.window).toMatchObject({ state: 'limited', oversizedRecords: 1, recordBytesLimit: TRACE_RECORD_BYTES_LIMIT }) + expect(largestParsedString).toBeLessThanOrEqual(TRACE_RECORD_BYTES_LIMIT) + await expect(traceCaptureService.getSessionTraceCall('fixture', 'huge')).rejects.toMatchObject({ code: 'TRACE_RECORD_TOO_LARGE' }) + expect((await fs.stat(filePath)).size).toBe(bytesBefore) + expect(await traceCaptureService.getSessionTraceFile('fixture')).toEqual({ path: filePath }) + } finally { parseSpy.mockRestore() } +})) + +test('pages stable bounded rows and continues beyond the first index window', () => fixture(async filePath => { + const handle = await fs.open(filePath, 'w') + for (let i = 0; i < TRACE_WINDOW_RECORD_LIMIT + 20; i++) await handle.write(callLine(`call-${i}`)) + await handle.close() + const first = await traceCaptureService.getSessionTraceOverview('fixture', { limit: 7 }) + expect(first.calls).toHaveLength(7) + expect(first.window).toMatchObject({ totalCalls: TRACE_WINDOW_RECORD_LIMIT, hasMore: true, state: 'limited', startByte: 0 }) + expect(first.window?.nextScanCursor).toBeString() + const nextPage = await traceCaptureService.getSessionTraceOverview('fixture', { offset: 7, limit: 7, revisionToken: first.window!.revisionToken }) + expect(nextPage.calls[0]?.id).toBe('call-7') + const nextWindow = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: first.window!.nextScanCursor }) + expect(nextWindow.calls).toHaveLength(20) + expect(nextWindow.calls[0]?.id).toBe(`call-${TRACE_WINDOW_RECORD_LIMIT}`) + expect(nextWindow.window).toMatchObject({ totalCalls: 20, hasMore: false, state: 'ready' }) + expect(nextWindow.window!.startByte).toBeGreaterThan(0) + await traceCaptureService.getSessionTraceRevision('fixture') + expect((await traceCaptureService.getSessionTraceCall('fixture', `call-${TRACE_WINDOW_RECORD_LIMIT}`))?.id).toBe(`call-${TRACE_WINDOW_RECORD_LIMIT}`) + await fs.appendFile(filePath, callLine('new')) + await expect(traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: first.window!.nextScanCursor })).rejects.toMatchObject({ code: 'TRACE_PAGE_STALE' }) + await expect(traceCaptureService.getSessionTraceOverview('fixture', { revisionToken: first.window!.revisionToken, offset: 7 })).rejects.toMatchObject({ code: 'TRACE_PAGE_STALE' }) +})) + +test('bounds a window by bytes as well as record count and permits continuation', () => fixture(async filePath => { + const handle = await fs.open(filePath, 'w') + const line = Buffer.alloc(1024 * 1024, 0x20) + line[line.length - 1] = 0x0a + for (let i = 0; i < 66; i++) await handle.write(line) + await handle.write(callLine('after-byte-window')) + await handle.close() + const first = await traceCaptureService.getSessionTraceOverview('fixture') + expect(first.window?.scannedBytes).toBe(TRACE_WINDOW_BYTES_LIMIT) + expect(first.window?.nextScanCursor).toBeString() + const next = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: first.window!.nextScanCursor }) + expect(next.calls[0]?.id).toBe('after-byte-window') +})) + +test('aborting the last overview consumer stops physical reads and closes its handle', () => fixture(async filePath => { + await fs.writeFile(filePath, callLine('huge', 'x'.repeat(8 * 1024 * 1024))) + const controller = new AbortController() + const originalOpen = fs.open.bind(fs) + let reads = 0 + let closed = false + const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await originalOpen(...args) + if (String(args[0]) !== filePath) return handle + return new Proxy(handle, { + get(target, property) { + if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => { + const result = await target.read(buffer, offset, length, position) + reads += 1 + if (reads === 2) controller.abort() + return result + } + if (property === 'close') return async () => { closed = true; await target.close() } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + try { + await expect(traceCaptureService.getSessionTraceOverview('fixture', { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }) + await new Promise(resolve => setImmediate(resolve)) + expect(reads).toBe(2) + expect(closed).toBe(true) + } finally { openSpy.mockRestore() } +})) + +test('shares cold work and keeps reading while another consumer still needs it', () => fixture(async filePath => { + await fs.writeFile(filePath, callLine('huge', 'x'.repeat(6 * 1024 * 1024)) + callLine('small')) + const fileBytes = (await fs.stat(filePath)).size + const controller = new AbortController() + const originalOpen = fs.open.bind(fs) + let reads = 0 + const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await originalOpen(...args) + if (String(args[0]) !== filePath) return handle + return new Proxy(handle, { + get(target, property) { + if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => { + const result = await target.read(buffer, offset, length, position) + reads += 1 + if (reads === 2) controller.abort() + return result + } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + try { + const abandoned = traceCaptureService.getSessionTraceOverview('fixture', { signal: controller.signal }) + const retained = traceCaptureService.getSessionTraceOverview('fixture') + await expect(abandoned).rejects.toMatchObject({ name: 'AbortError' }) + expect((await retained).calls[0]?.id).toBe('small') + expect(reads).toBe(Math.ceil(fileBytes / (256 * 1024))) + } finally { openSpy.mockRestore() } +})) + +test('bounds queued cold jobs and cancels queued work before opening source files', () => fixture(async filePath => { + for (let i = 0; i < 9; i++) await fs.writeFile(filePath.replace('fixture.jsonl', `queued-${i}.jsonl`), callLine(`call-${i}`)) + const originalOpen = fs.open.bind(fs) + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + let entered!: () => void + const started = new Promise(resolve => { entered = resolve }) + let reads = 0 + const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await originalOpen(...args) + if (!String(args[0]).includes('/queued-')) return handle + return new Proxy(handle, { + get(target, property) { + if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => { + reads += 1 + entered() + await gate + return target.read(buffer, offset, length, position) + } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + const controllers = Array.from({ length: 9 }, () => new AbortController()) + try { + const requests = controllers.map((controller, index) => traceCaptureService.getSessionTraceOverview(`queued-${index}`, { signal: controller.signal }) + .then(value => ({ value, error: null }), error => ({ value: null, error }))) + await started + expect((await Promise.race(requests))?.error?.code).toBe('TRACE_INDEX_BUSY') + controllers.forEach(controller => controller.abort()) + release() + await Promise.all(requests) + await new Promise(resolve => setImmediate(resolve)) + expect(reads).toBe(1) + } finally { + release() + openSpy.mockRestore() + } +})) + + +test('live event metadata is bounded without invoking accessors', () => fixture(async filePath => { + let getterCalls = 0 + await traceCaptureService.recordEvent({ sessionId: 'fixture', phase: 'fixture', metadata: { + get expensive() { getterCalls += 1; return 'x'.repeat(8 * 1024 * 1024) }, + } }) + const raw = await fs.readFile(filePath, 'utf8') + expect(getterCalls).toBe(0) + expect(Buffer.byteLength(raw)).toBeLessThan(TRACE_RECORD_BYTES_LIMIT) + expect(JSON.parse(raw).event.metadata).toMatchObject({ traceCaptureOmitted: { reason: 'accessor' } }) +})) + +test('an oversized response chunk is sliced before decoding and stops trace capture', async () => { + let cancelled = false + const response = new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(8 * 1024 * 1024).fill(0x78)) }, + cancel() { cancelled = true }, + })) + const originalDecode = TextDecoder.prototype.decode + let largestDecoded = 0 + const decodeSpy = spyOn(TextDecoder.prototype, 'decode').mockImplementation(function (input, options) { + largestDecoded = Math.max(largestDecoded, input?.byteLength ?? 0) + return originalDecode.call(this, input, options) + }) + try { + const result = await captureResponseTraceSnapshot(response) + expect(largestDecoded).toBeLessThanOrEqual(TRACE_STREAM_CAPTURE_BYTES) + expect(result.snapshot.truncated).toBe(true) + expect(result.aborted).toBe(false) + expect(cancelled).toBe(true) + } finally { decodeSpy.mockRestore() } +}) + + +test('the legacy full snapshot helper rejects giant files before reading and directs callers to pages', () => fixture(async filePath => { + await fs.writeFile(filePath, '') + await fs.truncate(filePath, 16 * 1024 * 1024) + await expect(traceCaptureService.getSessionTrace('fixture')).rejects.toMatchObject({ code: 'TRACE_RECORD_TOO_LARGE' }) + expect((await fs.stat(filePath)).size).toBe(16 * 1024 * 1024) +})) + +test('a single huge line yields bounded byte windows and resumes skipping until the next record', () => fixture(async filePath => { + const handle = await fs.open(filePath, 'w') + await handle.write('{"padding":"') + const block = Buffer.alloc(1024 * 1024, 0x78) + for (let i = 0; i < 128; i++) await handle.write(block) + await handle.write('"}\n' + callLine('after-huge-line')) + await handle.close() + let page = await traceCaptureService.getSessionTraceOverview('fixture') + expect(page.window?.scannedBytes).toBeLessThanOrEqual(TRACE_WINDOW_BYTES_LIMIT + 256 * 1024) + expect(page.window?.oversizedRecords).toBe(1) + expect(page.window?.nextScanCursor).toBeString() + let windows = 1 + while (page.window?.nextScanCursor) { + page = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: page.window.nextScanCursor }) + expect(page.window?.scannedBytes).toBeLessThanOrEqual(TRACE_WINDOW_BYTES_LIMIT + 256 * 1024) + windows += 1 + expect(windows).toBeLessThanOrEqual(3) + } + expect(windows).toBe(3) + expect(page.calls.map(call => call.id)).toEqual(['after-huge-line']) +})) + +test('appends cannot grow a full metadata window beyond its row budget', () => fixture(async filePath => { + await fs.writeFile(filePath, Array.from({ length: TRACE_WINDOW_RECORD_LIMIT }, (_, i) => callLine(`call-${i}`)).join('')) + const first = await traceCaptureService.getSessionTraceOverview('fixture') + expect(first.window?.totalCalls).toBe(TRACE_WINDOW_RECORD_LIMIT) + expect(first.window?.nextScanCursor).toBeUndefined() + await fs.appendFile(filePath, callLine('new-1') + callLine('new-2')) + const full = await traceCaptureService.getSessionTraceOverview('fixture') + expect(full.window?.totalCalls).toBe(TRACE_WINDOW_RECORD_LIMIT) + expect(full.window?.nextScanCursor).toBeString() + const next = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: full.window?.nextScanCursor }) + expect(next.calls.map(call => call.id)).toEqual(['new-1', 'new-2']) +})) diff --git a/src/services/api/traceCapture.performance.test.ts b/src/services/api/traceCapture.performance.test.ts new file mode 100644 index 00000000..fdaa08bd --- /dev/null +++ b/src/services/api/traceCapture.performance.test.ts @@ -0,0 +1,84 @@ +import { expect, spyOn, test } from 'bun:test' +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { clearTraceCaptureStateForTests, traceCaptureService } from './traceCapture.js' + +for (const mode of ['on', 'off', 'shadow']) { + test(`trace overview streams locator shells with index ${mode}`, async () => { + const previousConfig = process.env.CLAUDE_CONFIG_DIR + const previousMode = process.env.CC_HAHA_LOCAL_INDEX + const scope = await fs.mkdtemp(join(tmpdir(), 'trace-bounded-')) + process.env.CLAUDE_CONFIG_DIR = scope + process.env.CC_HAHA_LOCAL_INDEX = mode + clearTraceCaptureStateForTests() + const dir = join(scope, 'cc-haha', 'traces') + const filePath = join(dir, 'fixture.jsonl') + const line = (id: string) => JSON.stringify({ type: 'call', record: { + id, sessionId: 'fixture', source: 'proxy', status: 'error', + startedAt: '2026-06-09T08:00:00.000Z', + request: { method: 'POST', url: 'https://example.test', headers: {}, + body: { bytes: 900_000, contentType: 'text', preview: '中文'.repeat(150_000), sha256: '', truncated: false } }, + } }) + '\n' + const originalOpen = fs.open.bind(fs) + let fullReads = 0 + let largestRead = 0 + let removeBeforeNextRead = false + const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => { + if (removeBeforeNextRead && String(args[0]) === filePath) { + removeBeforeNextRead = false + await fs.unlink(filePath) + } + const handle = await originalOpen(...args) + if (String(args[0]) !== filePath) return handle + return new Proxy(handle, { + get(target, property) { + if (property === 'readFile') return async () => { fullReads++; return target.readFile() } + if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => { + largestRead = Math.max(largestRead, length) + return target.read(buffer, offset, length, position) + } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + try { + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(filePath, line('first')) + const first = await traceCaptureService.getSessionTraceOverview('fixture') + expect(first.summary.failedCalls).toBe(1) + expect(first.calls[0].request.body.preview).toBe('') + await fs.appendFile(filePath, line('second') + line('third')) + const appended = await traceCaptureService.getSessionTraceOverview('fixture') + expect(appended.calls).toHaveLength(3) + expect(appended.summary.failedCalls).toBe(3) + expect(fullReads).toBe(0) + expect(largestRead).toBeLessThanOrEqual(256 * 1024) + const list = await traceCaptureService.listSessionTraces() + expect(list.traces[0]?.summary.apiCalls).toBe(3) + expect(fullReads).toBe(0) + expect(await traceCaptureService.getSessionTraceCall('fixture', 'missing')).toBeNull() + expect(fullReads).toBe(0) + expect(largestRead).toBeLessThanOrEqual(256 * 1024) + const detail = await traceCaptureService.getSessionTraceCall('fixture', 'second') + expect(detail?.request.body.preview).toBe('中文'.repeat(150_000)) + expect(fullReads).toBe(0) + if (mode === 'off') { + // Deletion between directory discovery and the stream open must not + // turn a trace-list refresh into a failed request. + removeBeforeNextRead = true + const removed = await traceCaptureService.listSessionTraces() + expect(removed.traces[0]?.summary.apiCalls).toBe(0) + } + } finally { + openSpy.mockRestore() + clearTraceCaptureStateForTests() + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfig + if (previousMode === undefined) delete process.env.CC_HAHA_LOCAL_INDEX + else process.env.CC_HAHA_LOCAL_INDEX = previousMode + await fs.rm(scope, { recursive: true, force: true }) + } + }) +} diff --git a/src/services/api/traceCapture.semantic.test.ts b/src/services/api/traceCapture.semantic.test.ts index b2f6159e..a775134f 100644 --- a/src/services/api/traceCapture.semantic.test.ts +++ b/src/services/api/traceCapture.semantic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { createTraceRequestSemantic } from './traceCapture.js' +import { createTraceRequestSemantic, createTraceBodySnapshot, TRACE_CAPTURE_NODE_LIMIT, TRACE_CAPTURE_CHAR_LIMIT, TRACE_CAPTURE_DEPTH_LIMIT } from './traceCapture.js' describe('createTraceRequestSemantic', () => { test('keeps proxy request structure while replacing Computer Use image data with metadata', () => { @@ -49,3 +49,34 @@ describe('createTraceRequestSemantic', () => { expect(JSON.stringify(semantic)).not.toContain('upstream') }) }) + + +test('capture budgets omit oversized trees without evaluating getters or array elements', () => { + let visited = 0 + const hugeMessages = new Proxy(new Array(TRACE_CAPTURE_NODE_LIMIT + 1), { + getOwnPropertyDescriptor(target, key) { + visited += 1 + return Reflect.getOwnPropertyDescriptor(target, key) + }, + }) + const body = { model: 'unchanged', messages: hugeMessages } + const semantic = createTraceRequestSemantic(body, 'anthropic') + expect(semantic?.request).toMatchObject({ traceCaptureOmitted: { reason: 'node-budget' } }) + expect(visited).toBe(0) + expect(body.messages).toBe(hugeMessages) + let getterCalls = 0 + const metadata = { get expensive() { getterCalls += 1; return 'unreachable' } } + expect(createTraceBodySnapshot(metadata).captureOmitted).toBe('accessor') + expect(getterCalls).toBe(0) + let toJsonCalls = 0 + const withSerializer = { toJSON() { toJsonCalls += 1; return 'unreachable' } } + expect(createTraceBodySnapshot(withSerializer).captureOmitted).toBe('unsupported-value') + expect(toJsonCalls).toBe(0) + const wide = createTraceBodySnapshot({ text: 'x'.repeat(TRACE_CAPTURE_CHAR_LIMIT + 1) }) + expect(wide.captureOmitted).toBe('string-budget') + expect(wide.truncated).toBe(true) + expect(wide.preview.length).toBeLessThan(1024) + let nested: unknown = { value: 'end' } + for (let i = 0; i < TRACE_CAPTURE_DEPTH_LIMIT + 2; i++) nested = { nested } + expect(createTraceBodySnapshot(nested).captureOmitted).toBe('depth-budget') +}) diff --git a/src/services/api/traceCapture.ts b/src/services/api/traceCapture.ts index 754e94d6..83d1b8c8 100644 --- a/src/services/api/traceCapture.ts +++ b/src/services/api/traceCapture.ts @@ -30,8 +30,39 @@ const TRACE_PREVIEW_CHARS = 240_000 export const TRACE_STREAM_CAPTURE_BYTES = 1024 * 1024 export const TRACE_LIST_PREVIEW_CHARS = 2048 const TRACE_SETTINGS_KEY = 'traceCapture' -const TRACE_INDEX_PARSER_VERSION = 1 +const TRACE_INDEX_PARSER_VERSION = 2 const TRACE_FINGERPRINT_WINDOW_BYTES = 64 * 1024 +export const TRACE_RECORD_BYTES_LIMIT = 2 * 1024 * 1024 +export const TRACE_WINDOW_RECORD_LIMIT = 10_000 +export const TRACE_WINDOW_BYTES_LIMIT = 64 * 1024 * 1024 +export const TRACE_OVERVIEW_LIMIT = 100 +const TRACE_LEGACY_FULL_BYTES_LIMIT = 8 * 1024 * 1024 +export type TraceOverviewOptions = { + offset?: number + limit?: number + revisionToken?: string + scanCursor?: string + signal?: AbortSignal +} +export type TraceSessionWindow = { + offset: number + limit: number + totalCalls: number + totalEvents: number + hasMore: boolean + revisionToken: string + state: 'ready' | 'indexing' | 'limited' + oversizedRecords: number + startByte: number + scannedBytes: number + fileBytes: number + recordLimit: number + recordBytesLimit: number + nextScanCursor?: string +} +function traceResourceError(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }) +} // `token(?!s)` keeps secret-bearing keys (token, access_token, api_token) redacted while // letting token-count fields (input_tokens, max_tokens, prompt_tokens) through. // `session` covers stable per-conversation identifiers (x-opencode-session, @@ -57,6 +88,7 @@ export type TraceBodySnapshot = { sha256: string preview: string truncated: boolean + captureOmitted?: string } export type TraceCallStatus = 'pending' | 'ok' | 'error' @@ -135,6 +167,7 @@ export type TraceSessionSummary = { } export type TraceSession = { + window?: TraceSessionWindow sessionId: string summary: TraceSessionSummary calls: TraceCallRecord[] @@ -142,6 +175,7 @@ export type TraceSession = { } export type TraceSessionListItem = { + window?: TraceSessionWindow sessionId: string summary: TraceSessionSummary fileSize: number @@ -259,9 +293,9 @@ const traceReadCache = new Map() const canonicalTraceRevisions = new Map() const traceBackfillScheduled = new Set() let traceBackfillQueue: Promise = Promise.resolve() -// One page of list rows is the most we ever schedule; the queue is serialized -// so a directory full of unindexed GB-scale files rebuilds one at a time. -const TRACE_BACKFILL_MAX_PENDING = 200 +// Only a small bounded number of small list sources backfill without an +// overview consumer. Large files require a cancellable overview request. +const TRACE_BACKFILL_MAX_PENDING = 8 type TraceIndexState = { path: string database: TraceIndexDatabase @@ -347,12 +381,111 @@ export async function updateTraceCaptureSettings(input: Partial { + chars += text.length + if (chars > TRACE_CAPTURE_CHAR_LIMIT) throw new Error('string-budget') + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + bytes += code < 0x20 ? 6 : code === 0x22 || code === 0x5c ? 2 : code < 0x80 ? 1 : code < 0x800 ? 2 : code >= 0xd800 && code <= 0xdfff ? 6 : 3 + if (bytes > TRACE_RECORD_BYTES_LIMIT - 128 * 1024) throw new Error('byte-budget') + } + } + const seen = new WeakSet() + const visit = (value: unknown, depth: number): unknown => { + if (++nodes > TRACE_CAPTURE_NODE_LIMIT) throw new Error('node-budget') + bytes += 16 + if (depth > TRACE_CAPTURE_DEPTH_LIMIT) throw new Error('depth-budget') + if (typeof value === 'string') { + countText(value) + return value + } + if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') throw new Error('unsupported-value') + if (value === null || typeof value !== 'object') return value + if (seen.has(value)) throw new Error('cycle') + seen.add(value) + if (Array.isArray(value)) { + if (value.length > TRACE_CAPTURE_NODE_LIMIT - nodes) throw new Error('node-budget') + const result: unknown[] = [] + for (let i = 0; i < value.length; i++) { + const property = Object.getOwnPropertyDescriptor(value, String(i)) + if (property?.get || property?.set) throw new Error('accessor') + result.push(visit(property?.value, depth + 1)) + } + seen.delete(value) + return result + } + const result: Record = {} + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue + if (nodes >= TRACE_CAPTURE_NODE_LIMIT) throw new Error('node-budget') + countText(key) + const property = Object.getOwnPropertyDescriptor(value, key) + if (property?.get || property?.set) throw new Error('accessor') + Object.defineProperty(result, key, { value: visit(property?.value, depth + 1), enumerable: true, configurable: true, writable: true }) + } + seen.delete(value) + return result + } + try { return { value: visit(input, 0) } } + catch (error) { + const omitted = (error instanceof Error ? error.message : 'unsupported-value').slice(0, 128) + return { omitted, value: { traceCaptureOmitted: { reason: omitted, nodeLimit: TRACE_CAPTURE_NODE_LIMIT, depthLimit: TRACE_CAPTURE_DEPTH_LIMIT, charLimit: TRACE_CAPTURE_CHAR_LIMIT } } } + } +} + +function boundedTraceEntry(entry: TraceFileEntry): TraceFileEntry { + const bounded = boundedTraceValue(entry) + if (!bounded.omitted) return bounded.value as TraceFileEntry + const metadata = { traceCaptureOmitted: { reason: bounded.omitted } } + if ('type' in entry && entry.type === 'event') { + const event = entry.event + return { type: 'event', event: { + id: event.id.slice(0, 512), sessionId: event.sessionId.slice(0, 512), + timestamp: event.timestamp.slice(0, 128), phase: event.phase.slice(0, 128), severity: event.severity, + ...(event.callId ? { callId: event.callId.slice(0, 512) } : {}), + title: event.title?.slice(0, 256), message: '[trace event details omitted: resource budget]', metadata, + } } + } + const record = 'type' in entry && entry.type === 'call' ? entry.record : entry as TraceCallRecord + return { type: 'call', record: { + id: record.id.slice(0, 512), sessionId: record.sessionId.slice(0, 512), + source: record.source.slice(0, 128) as TraceCallRecord['source'], startedAt: record.startedAt.slice(0, 128), + completedAt: record.completedAt?.slice(0, 128), status: record.status, + model: record.model?.slice(0, 128), durationMs: record.durationMs, metadata, + request: { method: record.request.method.slice(0, 32), url: record.request.url.slice(0, 8192), headers: {}, + body: { ...emptyTraceBodySnapshot(record.request.body.bytes), truncated: true, captureOmitted: bounded.omitted } }, + ...(record.response ? { response: { status: record.response.status, headers: {}, + body: { ...emptyTraceBodySnapshot(record.response.body.bytes), truncated: true, captureOmitted: bounded.omitted } } } : {}), + } } +} + +function boundedBodySnapshot(snapshot: TraceBodySnapshot): TraceBodySnapshot { + return { + contentType: snapshot.contentType, + bytes: snapshot.bytes, + sha256: snapshot.sha256.slice(0, 128), + preview: snapshot.preview.slice(0, TRACE_PREVIEW_CHARS), + truncated: snapshot.truncated || snapshot.preview.length > TRACE_PREVIEW_CHARS, + ...(snapshot.captureOmitted ? { captureOmitted: snapshot.captureOmitted.slice(0, 128) } : {}), + } +} + export function createTraceBodySnapshot( body: unknown, options?: { maxPreviewChars?: number; alreadyTruncated?: boolean }, ): TraceBodySnapshot { const maxPreviewChars = options?.maxPreviewChars ?? TRACE_PREVIEW_CHARS - const { serialized, contentType } = serializeTraceBody(body) + const bounded = boundedTraceValue(body) + const { serialized, contentType } = serializeTraceBody(bounded.value) const bytes = Buffer.byteLength(serialized) const preview = serialized.length > maxPreviewChars ? serialized.slice(0, maxPreviewChars) @@ -363,7 +496,8 @@ export function createTraceBodySnapshot( bytes, sha256: createHash('sha256').update(serialized).digest('hex'), preview, - truncated: Boolean(options?.alreadyTruncated) || serialized.length > maxPreviewChars, + truncated: Boolean(bounded.omitted) || Boolean(options?.alreadyTruncated) || serialized.length > maxPreviewChars, + ...(bounded.omitted ? { captureOmitted: bounded.omitted } : {}), } } @@ -423,7 +557,9 @@ export function createTraceRequestSemantic( body: unknown, source: TraceCallRecord['source'], ): TraceRequestSemantic | null { - const parsed = parseTraceRequestValue(body) + const bounded = boundedTraceValue(body) + if (bounded.omitted) return { version: 1, request: bounded.value as TraceJsonRecord } + const parsed = parseTraceRequestValue(bounded.value) if (!parsed) return null const request = source === 'proxy' && isTraceRecord(parsed.anthropic) ? parsed.anthropic @@ -438,7 +574,7 @@ export function createTraceRequestSemantic( } return { version: 1, - request: compactTraceSemanticValue(request) as TraceJsonRecord, + request: compactTraceSemanticValue(boundedTraceValue(request).value) as TraceJsonRecord, } } @@ -503,7 +639,7 @@ function isTraceRecord(value: unknown): value is TraceJsonRecord { */ export async function drainTraceCaptureForTests(): Promise { for (let attempt = 0; attempt < 200; attempt++) { - const pending = [...traceWriteQueues.values()] + const pending = [...traceWriteQueues.values(), ...[...projectionJobs.values()].map(job => job.promise)] if (pending.length === 0 && traceBackfillScheduled.size === 0) return await Promise.allSettled([...pending, traceBackfillQueue]) } @@ -513,6 +649,9 @@ export function clearTraceCaptureStateForTests(): void { traceReadCache.clear() canonicalTraceRevisions.clear() traceBackfillScheduled.clear() + for (const job of projectionJobs.values()) job.controller.abort() + projectionJobs.clear() + projectionWorkQueues.clear() traceBackfillQueue = Promise.resolve() for (const state of traceIndexStates.values()) state.database.close() traceIndexStates.clear() @@ -557,7 +696,7 @@ export function createTraceCallId(): string { class TraceCaptureService { async recordCall(input: RecordTraceCallInput): Promise { - if (!input.sessionId.trim()) return null + if (input.sessionId.length > 512 || !input.sessionId.trim()) return null if (!isTraceCaptureEnabled()) return null const startedAt = input.startedAt ?? new Date().toISOString() @@ -578,7 +717,7 @@ class TraceCaptureService { method: input.request.method ?? 'POST', url: sanitizeUrl(input.request.url ?? ''), headers: sanitizeHeaders(input.request.headers), - body: input.request.bodySnapshot ?? createTraceBodySnapshot(input.request.body ?? null), + body: input.request.bodySnapshot ? boundedBodySnapshot(input.request.bodySnapshot) : createTraceBodySnapshot(input.request.body ?? null), ...createRequestSemanticField(input.request.body, input.source), }, ...(input.response @@ -586,7 +725,7 @@ class TraceCaptureService { response: { status: input.response.status, headers: sanitizeHeaders(input.response.headers), - body: input.response.bodySnapshot ?? createTraceBodySnapshot(input.response.body ?? null), + body: input.response.bodySnapshot ? boundedBodySnapshot(input.response.bodySnapshot) : createTraceBodySnapshot(input.response.body ?? null), }, } : {}), @@ -598,7 +737,7 @@ class TraceCaptureService { } async recordEvent(input: RecordTraceEventInput): Promise { - if (!input.sessionId.trim()) return null + if (input.sessionId.length > 512 || !input.sessionId.trim()) return null if (!isTraceCaptureEnabled()) return null const event: TraceEventRecord = { @@ -611,7 +750,7 @@ class TraceCaptureService { ...(input.source ? { source: input.source } : {}), ...(input.provider ? { provider: input.provider } : {}), ...(input.model ? { model: input.model } : {}), - ...(input.title ? { title: input.title } : {}), + ...(input.title ? { title: input.title.slice(0, 256) } : {}), ...(input.message ? { message: redactSecretsInText(input.message) } : {}), ...(input.metadata ? { metadata: sanitizeMetadata(input.metadata) } : {}), } @@ -633,36 +772,70 @@ class TraceCaptureService { } /** - * Trace page read path. Unlike `getSessionTrace`, this never reads the whole - * JSONL: calls come back as locator shells (identity/timing/status/body + * Trace page read path. Unlike `getSessionTrace`, this does not hydrate the + * complete JSONL: calls come back as locator shells (identity/timing/status/body * sizes only) and the detail pane fetches one full call at a time through - * `getSessionTraceCall`. Falls back to the canonical full read when the - * index is off or the projection cannot be served. + * `getSessionTraceCall`. + * When the index is off or the projection cannot be served, a streaming scan returns + * the same lightweight shape without retaining full call bodies. */ - async getSessionTraceOverview(sessionId: string): Promise { - const mode = syncTraceIndexMode() - const context = currentTraceScopeContext() - if (mode === 'on') { - const projected = await readProjectedSessionTrace(sessionId, context) - if (projected) return projected + async getSessionTraceFile(sessionId: string): Promise<{ path: string } | null> { + const filePath = getTraceFilePath(sanitizeTraceFileName(sessionId), currentTraceScopeContext()) + try { await fs.stat(filePath); return { path: filePath } } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error } - return this.getSessionTrace(sessionId) } - async getSessionTraceCall(sessionId: string, callId: string): Promise { + async getSessionTraceOverview(sessionId: string, options: TraceOverviewOptions = {}): Promise { + options.signal?.throwIfAborted() const mode = syncTraceIndexMode() const context = currentTraceScopeContext() - if (mode === 'off') return readCanonicalTraceCall(sessionId, callId, context) + const normalizedSessionId = sanitizeTraceFileName(sessionId) + const filePath = getTraceFilePath(normalizedSessionId, context) + const scan = await resolveTraceScan(filePath, options) + try { + if (mode === 'on') { + const projected = await readProjectedSessionTrace(sessionId, context, options, scan) + if (projected) return projected + } + const snapshot = await readStableTraceProjection(filePath, undefined, scan) + if (!snapshot) throw new Error('Trace changed while loading; retry the request') + const window = traceWindowMetadata(snapshot, snapshot.calls.length, snapshot.events.length, options) + const calls = snapshot.calls.map(locator => shellTraceCallFromLocator(normalizedSessionId, locator)) + const summary = summarizeCalls(calls) + return { + sessionId: normalizedSessionId, window, + summary: { ...summary, models: summary.models.slice(0, 64), failedCalls: snapshot.calls.filter(call => call.failed).length }, + calls: calls.slice(window.offset, window.offset + window.limit), + events: snapshot.events.slice(window.offset, window.offset + window.limit).map(event => traceEventShell(normalizedSessionId, event)), + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { sessionId: normalizedSessionId, summary: emptyTraceSummary(), calls: [], events: [] } + } + throw error + } + } + + async getSessionTraceCall(sessionId: string, callId: string, options: Pick = {}): Promise { + const mode = syncTraceIndexMode() + const context = currentTraceScopeContext() + const scan = options.scanCursor + ? await resolveTraceScan(getTraceFilePath(sanitizeTraceFileName(sessionId), context), options) + : { signal: options.signal } + if (mode === 'off') return readCanonicalTraceCall(sessionId, callId, context, scan) if (mode === 'shadow') { - const canonical = await readCanonicalTraceCall(sessionId, callId, context) - const projected = await readProjectedTraceCall(sessionId, callId, context) + const canonical = await readCanonicalTraceCall(sessionId, callId, context, scan) + const projected = await readProjectedTraceCall(sessionId, callId, context, scan) recordTraceShadowComparison(traceCallMatches(canonical, projected)) return canonical } - return await readProjectedTraceCall(sessionId, callId, context) - ?? await readCanonicalTraceCall(sessionId, callId, context) + return await readProjectedTraceCall(sessionId, callId, context, scan) + ?? await readCanonicalTraceCall(sessionId, callId, context, scan) } async getSessionTraceRevision( @@ -719,12 +892,16 @@ class TraceCaptureService { ) } + // Polling a continuation window must not silently replace it with the + // first window and force the UI into rebuilding both on every poll. + const currentWindow = getTraceIndex(target)?.getSource(normalizedSessionId)?.windowStartByte ?? 0 const projection = await ensureTraceProjection( normalizedSessionId, filePath, stat, 0, target, + { byteStart: currentWindow }, ) if (projection) { return traceRevisionResult( @@ -812,10 +989,9 @@ class TraceCaptureService { for (const file of pageFiles) { const sessionId = file.name.replace(/\.jsonl$/, '') - let trace: Pick + let trace: Pick if (mode === 'off') { - const canonical = await readTraceEntries(sessionId, context) - trace = { sessionId, summary: summarizeCalls(canonical.calls) } + trace = await readCanonicalTraceSummary(sessionId, file.path) } else if (mode === 'shadow') { const projection = await ensureTraceProjection( sessionId, @@ -824,11 +1000,7 @@ class TraceCaptureService { 0, target, ) - const entries = await readTraceEntries(sessionId, context) - const canonical = { - sessionId, - summary: summarizeCalls(entries.calls), - } + const canonical = await readCanonicalTraceSummary(sessionId, file.path) recordTraceShadowComparison(Boolean( projection && traceSummaryMatches(canonical.summary, projection.summary), )) @@ -844,7 +1016,10 @@ class TraceCaptureService { const source = index?.getSource(sessionId) ?? null const projection = source && index ? index.getSummary(sessionId) : null if (projection) { - trace = { sessionId, summary: projection.summary } + const fingerprint = storedTraceFingerprint(projection) + trace = { sessionId, summary: projection.summary, + ...(fingerprint ? { window: traceWindowMetadata({ ...projection, fingerprint }, projection.summary.apiCalls, 0, {}) } : {}), + } if ( source.state !== 'ready' || source.size !== file.size || @@ -860,6 +1035,7 @@ class TraceCaptureService { const updatedAt = trace.summary.updatedAt ?? file.updatedAt items.push({ sessionId: trace.sessionId || sessionId, + ...(trace.window ? { window: trace.window } : {}), summary: trace.summary.updatedAt ? trace.summary : { ...trace.summary, updatedAt }, @@ -999,16 +1175,13 @@ export async function captureResponseTraceSnapshot( completed = true break } - bytes += value.byteLength - const decoded = decoder.decode(value, { stream: true }) - if (text.length < TRACE_STREAM_CAPTURE_BYTES) { - text += decoded - } else { - truncated = true - } - if (bytes > TRACE_STREAM_CAPTURE_BYTES) { - truncated = true - } + const remaining = TRACE_STREAM_CAPTURE_BYTES - bytes + const captured = value.subarray(0, Math.max(0, remaining)) + bytes += captured.byteLength + const decoded = decoder.decode(captured, { stream: true }) + text += decoded + const budgetReached = value.byteLength > remaining || bytes >= TRACE_STREAM_CAPTURE_BYTES + if (budgetReached) truncated = true // OpenAI Responses defines its own terminal event. Treat that as the // request's one-shot terminal state instead of waiting for HTTP EOF: // callers commonly cancel/drop the body immediately after completed, @@ -1018,6 +1191,11 @@ export async function captureResponseTraceSnapshot( void reader.cancel('Responses terminal event captured').catch(() => {}) break } + if (budgetReached) { + completed = true + void reader.cancel('Trace capture byte budget reached').catch(() => {}) + break + } } return 'done' } @@ -1108,13 +1286,17 @@ function parseJsonOrText(text: string): unknown { } function redactSensitiveValue(value: unknown, key = ''): unknown { + return redactBoundedValue(boundedTraceValue(value).value, key) +} + +function redactBoundedValue(value: unknown, key = ''): unknown { if (SENSITIVE_KEY_RE.test(key)) return '[redacted]' - if (Array.isArray(value)) return value.map((entry) => redactSensitiveValue(entry)) + if (Array.isArray(value)) return value.map((entry) => redactBoundedValue(entry)) if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([entryKey, entryValue]) => [ entryKey, - redactSensitiveValue(entryValue, entryKey), + redactBoundedValue(entryValue, entryKey), ]), ) } @@ -1123,6 +1305,7 @@ function redactSensitiveValue(value: unknown, key = ''): unknown { } function redactSecretsInText(value: string): string { + if (value.length > TRACE_CAPTURE_CHAR_LIMIT) return '[trace capture omitted: string-budget]' return value .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') .replace(/\bsk-[A-Za-z0-9._-]{8,}\b/g, 'sk-[redacted]') @@ -1132,7 +1315,7 @@ function sanitizeHeaders(headers: Headers | Record | null | unde if (!headers) return {} const entries = headers instanceof Headers ? Array.from(headers.entries()) - : Object.entries(headers) + : Object.entries(boundedTraceValue(headers).value as Record) return Object.fromEntries( entries.map(([key, value]) => [ @@ -1144,6 +1327,7 @@ function sanitizeHeaders(headers: Headers | Record | null | unde function sanitizeUrl(url: string): string { if (!url) return '' + if (url.length > 8192) return '[trace URL omitted: string-budget]' try { const parsed = new URL(url) for (const key of Array.from(parsed.searchParams.keys())) { @@ -1462,6 +1646,12 @@ function parseTraceBuffer( let nextOrdinal = ordinal const parseLine = (end: number, complete: boolean) => { + if (ordinal - (options?.ordinal ?? 0) >= TRACE_WINDOW_RECORD_LIMIT) { + throw traceResourceError('TRACE_RECORD_TOO_LARGE', 'Full trace hydration exceeds the record budget; use overview pages or raw download') + } + if (end - lineStart > TRACE_RECORD_BYTES_LIMIT) { + throw traceResourceError('TRACE_RECORD_TOO_LARGE', 'Trace record exceeds 2 MiB; use raw download') + } const line = raw.subarray(lineStart, end).toString('utf-8') if (line.trim()) { let entry: TraceFileEntry | undefined @@ -1634,6 +1824,9 @@ async function readStableFullTraceSnapshot( let closed = false try { const before = await handle.stat() + if (before.size > TRACE_LEGACY_FULL_BYTES_LIMIT) { + throw traceResourceError('TRACE_RECORD_TOO_LARGE', 'Full trace hydration is limited to 8 MiB; use overview pages or raw download') + } const raw = await handle.readFile() traceCaptureDiagnostics.fullJsonlBytesRead += raw.byteLength await traceFullSnapshotAfterReadHookForTests?.() @@ -1680,90 +1873,6 @@ function fingerprintMatchesFullBuffer( fingerprint.boundaryWindowHash === hashBufferWindow(raw, fingerprint.indexedBytes) } -function hashBufferedTraceWindow( - end: number, - rangeStart: number, - prefix: Buffer, - range: Buffer, -): string | null { - const contextStart = rangeStart - prefix.byteLength - const contextEnd = rangeStart + range.byteLength - const length = Math.min(TRACE_FINGERPRINT_WINDOW_BYTES, end) - const windowStart = end - length - if (windowStart < contextStart || end > contextEnd) return null - - const hash = createHash('sha256') - if (windowStart < rangeStart) { - hash.update(prefix.subarray( - windowStart - contextStart, - Math.min(end, rangeStart) - contextStart, - )) - } - if (end > rangeStart) { - hash.update(range.subarray( - Math.max(windowStart, rangeStart) - rangeStart, - end - rangeStart, - )) - } - return hash.digest('hex') -} - -function fingerprintMatchesAppendBuffers( - previous: SourceFingerprint, - fingerprint: SourceFingerprint, - rangeStart: number, - prefix: Buffer, - range: Buffer, -): boolean { - const previousFirstWindowHash = rangeStart <= TRACE_FINGERPRINT_WINDOW_BYTES - ? hashBufferedTraceWindow( - Math.min(TRACE_FINGERPRINT_WINDOW_BYTES, previous.size), - rangeStart, - prefix, - range, - ) - : previous.firstWindowHash - const firstWindowHash = rangeStart <= TRACE_FINGERPRINT_WINDOW_BYTES - ? hashBufferedTraceWindow( - Math.min(TRACE_FINGERPRINT_WINDOW_BYTES, fingerprint.size), - rangeStart, - prefix, - range, - ) - : previous.firstWindowHash - return previousFirstWindowHash === previous.firstWindowHash && - hashBufferedTraceWindow( - previous.size, - rangeStart, - prefix, - range, - ) === previous.lastWindowHash && - hashBufferedTraceWindow( - previous.indexedBytes, - rangeStart, - prefix, - range, - ) === previous.boundaryWindowHash && - firstWindowHash === fingerprint.firstWindowHash && - hashBufferedTraceWindow( - fingerprint.size, - rangeStart, - prefix, - range, - ) === fingerprint.lastWindowHash && - hashBufferedTraceWindow( - fingerprint.indexedBytes, - rangeStart, - prefix, - range, - ) === fingerprint.boundaryWindowHash && - ( - previous.fileIdentity === null || - fingerprint.fileIdentity === null || - previous.fileIdentity === fingerprint.fileIdentity - ) -} - async function readTraceRange( filePath: string, start: number, @@ -1794,13 +1903,119 @@ async function readTraceRange( } } +async function resolveTraceScan(filePath: string, options: TraceOverviewOptions): Promise { + if (!options.scanCursor) return { byteStart: 0, ordinal: 0, signal: options.signal } + try { + if (options.scanCursor.length > 4096) throw new Error('cursor too large') + const cursor = JSON.parse(Buffer.from(options.scanCursor, 'base64url').toString('utf8')) + const fingerprint = deserializeSourceFingerprint(cursor.fingerprint) + if (!fingerprint || !Number.isSafeInteger(cursor.byteStart) || cursor.byteStart < 0 || + !Number.isSafeInteger(cursor.ordinal) || cursor.ordinal < 0 || cursor.byteStart !== fingerprint.indexedBytes || + (await detectTraceSourceChange(filePath, fingerprint)).kind !== 'unchanged') throw new Error('stale cursor') + return { byteStart: cursor.byteStart, ordinal: cursor.ordinal, skipLine: cursor.skipLine === true, signal: options.signal } + } catch { + throw traceResourceError('TRACE_PAGE_STALE', 'Trace file changed or cursor is invalid; restart from the first window') + } +} + +function traceWindowMetadata( + snapshot: { fingerprint: SourceFingerprint; nextOrdinal: number; windowStartByte: number; oversizedRecords: number; scanTruncated: boolean; oversizedContinuation?: boolean; lastErrorCode?: string | null }, + totalCalls: number, + totalEvents: number, + options: TraceOverviewOptions, +): TraceSessionWindow { + const offset = Math.max(0, Math.trunc(options.offset ?? 0)) + const limit = Math.max(1, Math.min(TRACE_OVERVIEW_LIMIT, Math.trunc(options.limit ?? TRACE_OVERVIEW_LIMIT))) + const fingerprint = serializeSourceFingerprint(snapshot.fingerprint) + const revisionToken = createHash('sha256').update(`${snapshot.windowStartByte}:${fingerprint}`).digest('hex') + if (options.revisionToken && options.revisionToken !== revisionToken) { + throw traceResourceError('TRACE_PAGE_STALE', 'Trace file changed; reload this trace window before paging') + } + return { + offset, limit, totalCalls, totalEvents, + hasMore: offset + limit < Math.max(totalCalls, totalEvents), + revisionToken, + state: snapshot.scanTruncated || snapshot.oversizedRecords > 0 ? 'limited' : 'ready', + oversizedRecords: snapshot.oversizedRecords, + startByte: snapshot.windowStartByte, + scannedBytes: snapshot.fingerprint.indexedBytes - snapshot.windowStartByte, + fileBytes: snapshot.fingerprint.size, + recordLimit: TRACE_WINDOW_RECORD_LIMIT, + recordBytesLimit: TRACE_RECORD_BYTES_LIMIT, + ...(snapshot.scanTruncated ? { nextScanCursor: Buffer.from(JSON.stringify({ + byteStart: snapshot.fingerprint.indexedBytes, + ordinal: snapshot.nextOrdinal, + skipLine: snapshot.oversizedContinuation ?? snapshot.lastErrorCode === 'TRACE_OVERSIZED_RECORD_CONTINUATION', + fingerprint, + })).toString('base64url') } : {}), + } +} + +function traceEventShell(sessionId: string, event: TraceEventLocator): TraceEventRecord { + return { + id: event.id, sessionId, timestamp: event.timestamp, phase: event.phase, + severity: event.severity as TraceEventSeverity, + ...(event.callId ? { callId: event.callId } : {}), + ...(event.source ? { source: event.source as TraceCallRecord['source'] } : {}), + ...(event.model ? { model: event.model } : {}), + ...(event.title ? { title: event.title } : {}), + ...(event.message ? { message: event.message } : {}), + metadata: { traceDetailsOmitted: true, recordBytes: event.byteLength }, + } +} + +async function readCanonicalTraceSummary( + sessionId: string, + filePath: string, +): Promise> { + const snapshot = await readStableTraceProjection(filePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + }) + if (snapshot === undefined) return { sessionId, summary: emptyTraceSummary() } + if (!snapshot) throw new Error('Trace changed while loading; retry the request') + const calls = snapshot.calls.map(locator => shellTraceCallFromLocator(sessionId, locator)) + const summary = summarizeCalls(calls) + return { + sessionId, + window: traceWindowMetadata(snapshot, snapshot.calls.length, snapshot.events.length, {}), + summary: { ...summary, models: summary.models.slice(0, 64), failedCalls: snapshot.calls.filter(call => call.failed).length }, + } +} + async function readCanonicalTraceCall( sessionId: string, callId: string, context = currentTraceScopeContext(), + scan?: TraceScanOptions, ): Promise { - const { calls } = await readTraceEntries(sessionId, context) - return calls.find(call => call.id === callId) ?? null + const normalizedSessionId = sanitizeTraceFileName(sessionId) + const filePath = getTraceFilePath(normalizedSessionId, context) + try { + // A missing/corrupt index (or a missing call ID) must not turn a detail + // request into full-session body hydration and cache retention. + for (let attempt = 0; attempt < 2; attempt += 1) { + const snapshot = await readStableTraceProjection(filePath, undefined, scan) + if (!snapshot) continue + const locator = snapshot.calls.find(call => call.id === callId) + if (!locator) { + if (snapshot.oversizedRecords > 0) throw traceResourceError('TRACE_RECORD_TOO_LARGE', 'Call is unavailable in the bounded trace window; oversized records are preserved in the raw download') + return null + } + if (locator.byteLength > TRACE_RECORD_BYTES_LIMIT) throw traceResourceError('TRACE_RECORD_TOO_LARGE', 'Trace record exceeds 2 MiB; download the raw trace') + const raw = await readTraceRange(filePath, locator.byteStart, locator.byteStart + locator.byteLength) + const parsed = parseTraceBuffer(raw, { byteStart: locator.byteStart, ordinal: locator.ordinal }) + const record = parsed.calls.find(call => call.id === callId) + if ( + record && traceCallMatchesLocator(record, locator) && + (await detectTraceSourceChange(filePath, snapshot.fingerprint)).kind === 'unchanged' + ) return record + } + return null + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } } function traceCallMatchesLocator( @@ -1829,21 +2044,20 @@ async function readProjectedTraceCall( sessionId: string, callId: string, context = currentTraceScopeContext(), + scan?: TraceScanOptions, ): Promise { const normalizedSessionId = sanitizeTraceFileName(sessionId) const { target } = context const filePath = getTraceFilePath(normalizedSessionId, context) try { - const stat = await fs.stat(filePath) - const projection = await ensureTraceProjection( - normalizedSessionId, - filePath, - stat, - 0, - target, - ) const index = getTraceIndex(target) - if (!projection || !index) return null + if (!index) return null + const cached = index.getCallLocator(normalizedSessionId, callId) + if (!cached || cached.source.state !== 'ready' || + (scan?.byteStart !== undefined && cached.source.windowStartByte !== scan.byteStart)) { + const projection = await ensureTraceProjection(normalizedSessionId, filePath, await fs.stat(filePath), 0, target, scan) + if (!projection) return null + } const located = index.getCallLocator(normalizedSessionId, callId) if (!located) return null const { source, call } = located @@ -1863,6 +2077,8 @@ async function readProjectedTraceCall( return null } + if (call.byteLength > TRACE_RECORD_BYTES_LIMIT) throw traceResourceError('TRACE_RECORD_TOO_LARGE', 'Trace record exceeds 2 MiB; download the raw trace') + scan?.signal?.throwIfAborted() const raw = await readTraceRange(filePath, call.byteStart, end) const parsed = parseTraceBuffer(raw, { byteStart: call.byteStart, @@ -1887,25 +2103,284 @@ async function readProjectedTraceCall( } return hydrated } catch (error) { + if (scan?.signal?.aborted) throw error + if ((error as { code?: string }).code === 'TRACE_RECORD_TOO_LARGE') throw error if (isTraceIndexSqliteFailure(error)) quarantineTraceIndexFailure(target, error) return null } } +function traceLocatorStringsFit(locator: TraceCallLocator | TraceEventLocator): boolean { + return Object.entries(locator).every(([key, value]) => + key === 'title' || key === 'message' || typeof value !== 'string' || value.length <= (key === 'id' || key === 'callId' ? 512 : 128), + ) +} + +// Projection rebuilds must not hydrate the complete trace on the server event +// loop. Keep only locators across records and yield between bounded I/O chunks. +let activeTraceScans = 0 +const traceScanWaiters: Array<() => void> = [] +async function readStableTraceProjection( + filePath: string, + append?: { source: NonNullable>; fingerprint: SourceFingerprint }, + scan?: TraceScanOptions, +): ReturnType { + scan?.signal?.throwIfAborted() + let reserved = false + if (activeTraceScans >= 1) { + if (traceScanWaiters.length >= 8) throw traceResourceError('TRACE_INDEX_BUSY', 'Trace reader queue is full; retry shortly') + await new Promise((resolve, reject) => { + const ready = () => { scan?.signal?.removeEventListener('abort', cancelled); resolve() } + const cancelled = () => { + const at = traceScanWaiters.indexOf(ready) + if (at !== -1) traceScanWaiters.splice(at, 1) + reject(scan?.signal?.reason ?? new Error('Trace scan aborted')) + } + traceScanWaiters.push(ready) + scan?.signal?.addEventListener('abort', cancelled, { once: true }) + }) + reserved = true + } + if (!reserved) activeTraceScans += 1 + try { + scan?.signal?.throwIfAborted() + return await readStableTraceProjectionNow(filePath, append, scan) + } finally { + const next = traceScanWaiters.shift() + if (next) next() + else activeTraceScans -= 1 + } +} + +async function readStableTraceProjectionNow( + filePath: string, + append?: { source: NonNullable>; fingerprint: SourceFingerprint }, + scan?: TraceScanOptions, +): Promise<{ + calls: TraceCallLocator[] + events: TraceEventLocator[] + nextOrdinal: number + fingerprint: SourceFingerprint + oversizedRecords: number + scanTruncated: boolean + windowStartByte: number + oversizedContinuation: boolean +} | null> { + const handle = await fs.open(filePath, 'r') + let closed = false + try { + const before = await handle.stat() + const calls = new Map() + const events: TraceEventLocator[] = [] + scan?.signal?.throwIfAborted() + let position = append?.source.indexedBytes ?? scan?.byteStart ?? 0 + if (position > before.size) throw traceResourceError('TRACE_PAGE_STALE', 'Trace file changed; restart from the first window') + const windowStartByte = append?.source.windowStartByte ?? position + const startOrdinal = scan?.ordinal ?? 0 + let oversizedRecords = append?.source.oversizedRecords ?? 0 + let scanTruncated = false + let skippingOversizedLine = scan?.skipLine === true + let oversizedContinuation = false + let recordsScanned = 0 + const recordBudget = scan?.remainingRecords ?? TRACE_WINDOW_RECORD_LIMIT + let indexedBytes = position + let ordinal = append?.source.nextOrdinal ?? startOrdinal + let fragments: Buffer[] = [] + let fragmentBytes = 0 + let firstWindow = Buffer.alloc(0) + let lastWindow = Buffer.alloc(0) + let boundaryWindow = Buffer.alloc(0) + if (append) { + const identity = traceFileIdentity(before) + if ( + append.fingerprint.fileIdentity !== null && + identity !== null && + identity !== append.fingerprint.fileIdentity + ) return null + if (before.size < append.fingerprint.size) return null + const readWindow = (end: number) => readTraceRange( + filePath, Math.max(0, end - TRACE_FINGERPRINT_WINDOW_BYTES), end, 'fingerprint', + ) + const oldFirst = await readWindow(Math.min(append.fingerprint.size, TRACE_FINGERPRINT_WINDOW_BYTES)) + const oldLast = await readWindow(append.fingerprint.size) + boundaryWindow = await readWindow(position) + if ( + hashBufferWindow(oldFirst, oldFirst.length) !== append.fingerprint.firstWindowHash || + hashBufferWindow(oldLast, oldLast.length) !== append.fingerprint.lastWindowHash || + hashBufferWindow(boundaryWindow, boundaryWindow.length) !== append.fingerprint.boundaryWindowHash + ) return null + firstWindow = await readWindow(Math.min(before.size, TRACE_FINGERPRINT_WINDOW_BYTES)) + lastWindow = boundaryWindow + } + if (!append && position > 0) { + firstWindow = await readTraceRange(filePath, 0, Math.min(before.size, TRACE_FINGERPRINT_WINDOW_BYTES), 'fingerprint') + boundaryWindow = await readTraceRange(filePath, Math.max(0, position - TRACE_FINGERPRINT_WINDOW_BYTES), position, 'fingerprint') + lastWindow = boundaryWindow + } + scanChunks: while (position < before.size) { + scan?.signal?.throwIfAborted() + if (recordsScanned >= recordBudget) { scanTruncated = true; break } + const chunk = Buffer.allocUnsafe(Math.min(256 * 1024, before.size - position)) + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position) + if (bytesRead === 0) return null + const bytes = chunk.subarray(0, bytesRead) + if (append) traceCaptureDiagnostics.incrementalJsonlBytesRead += bytesRead + else traceCaptureDiagnostics.fullJsonlBytesRead += bytesRead + if (!append && windowStartByte === 0 && firstWindow.length < TRACE_FINGERPRINT_WINDOW_BYTES) { + firstWindow = Buffer.concat([firstWindow, bytes.subarray( + 0, TRACE_FINGERPRINT_WINDOW_BYTES - firstWindow.length, + )]) + } + const window = Buffer.concat([lastWindow, bytes]) + let start = 0 + let end = bytes.indexOf(0x0a) + while (end !== -1) { + const part = bytes.subarray(start, end + 1) + if (skippingOversizedLine || fragmentBytes + part.length > TRACE_RECORD_BYTES_LIMIT) { + oversizedRecords += 1 + ordinal += 1 + } else { + const line = fragments.length + ? Buffer.concat([...fragments, part], fragmentBytes + part.length) + : part + const parsed = parseTraceBuffer(line, { byteStart: indexedBytes, ordinal }) + for (const call of parsed.callLocators) { + if (!traceLocatorStringsFit(call)) { oversizedRecords += 1; continue } + call.firstOrdinal = calls.get(call.id)?.firstOrdinal ?? call.firstOrdinal + calls.set(call.id, call) + } + for (const event of parsed.eventLocators) { + if (!traceLocatorStringsFit(event)) { oversizedRecords += 1; continue } + events.push({ ...event, title: event.title?.slice(0, 256), message: event.message?.slice(0, 512) }) + } + ordinal = parsed.nextOrdinal + } + skippingOversizedLine = false + indexedBytes = position + end + 1 + const windowEnd = lastWindow.length + end + 1 + boundaryWindow = Buffer.from(window.subarray( + Math.max(0, windowEnd - TRACE_FINGERPRINT_WINDOW_BYTES), windowEnd, + )) + fragments = [] + fragmentBytes = 0 + start = end + 1 + recordsScanned += 1 + if (recordsScanned >= recordBudget || indexedBytes - windowStartByte >= TRACE_WINDOW_BYTES_LIMIT) { + scanTruncated = indexedBytes < before.size + break scanChunks + } + end = bytes.indexOf(0x0a, start) + } + if (start < bytes.length) { + fragmentBytes += bytes.length - start + if (fragmentBytes > TRACE_RECORD_BYTES_LIMIT) { + skippingOversizedLine = true + fragments = [] + } else if (!skippingOversizedLine) { + fragments.push(Buffer.from(bytes.subarray(start))) + } + } + lastWindow = Buffer.from(window.subarray(-TRACE_FINGERPRINT_WINDOW_BYTES)) + position += bytesRead + if (skippingOversizedLine && position - windowStartByte >= TRACE_WINDOW_BYTES_LIMIT) { + // Oversized lines do not require an unbounded read to reach newline. + // Persist byte progress plus skip state; continuation discards the + // rest of this physical line before considering another record. + oversizedRecords += 1 + indexedBytes = position + boundaryWindow = lastWindow + scanTruncated = position < before.size + oversizedContinuation = scanTruncated + if (!scanTruncated) ordinal += 1 + skippingOversizedLine = false + break + } + await new Promise(resolve => setImmediate(resolve)) + } + scan?.signal?.throwIfAborted() + if (!scanTruncated && skippingOversizedLine) { + oversizedRecords += 1 + indexedBytes = before.size + ordinal += 1 + } + if (scanTruncated) { + lastWindow = await readTraceRange(filePath, Math.max(0, before.size - TRACE_FINGERPRINT_WINDOW_BYTES), before.size, 'fingerprint') + } + await traceFullSnapshotAfterReadHookForTests?.() + const after = await handle.stat() + await handle.close() + closed = true + const current = await fs.stat(filePath) + if (!sameTraceFileSnapshot(before, after) || !sameTraceFileSnapshot(after, current)) { + return null + } + return { + calls: [...calls.values()].sort((a, b) => + a.startedAt.localeCompare(b.startedAt) || + (a.firstOrdinal ?? a.ordinal) - (b.firstOrdinal ?? b.ordinal)), + events: events.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.ordinal - b.ordinal), + nextOrdinal: ordinal, + oversizedRecords, + scanTruncated, + windowStartByte, + oversizedContinuation, + fingerprint: { + size: before.size, + mtimeMs: before.mtimeMs, + ctimeMs: before.ctimeMs, + fileIdentity: traceFileIdentity(before), + firstWindowHash: createHash('sha256').update(firstWindow).digest('hex'), + lastWindowHash: createHash('sha256').update(lastWindow).digest('hex'), + boundaryWindowHash: createHash('sha256').update(boundaryWindow).digest('hex'), + indexedBytes, + parserVersion: TRACE_INDEX_PARSER_VERSION, + }, + } + } finally { + if (!closed) await handle.close() + } +} + +type TraceScanOptions = { byteStart?: number; ordinal?: number; signal?: AbortSignal; remainingRecords?: number; skipLine?: boolean } +type TraceScanSnapshot = NonNullable>> + +async function commitTraceProjection( + index: TraceIndex, + sessionId: string, + filePath: string, + snapshot: TraceScanSnapshot, + append: boolean, + signal?: AbortSignal, +): Promise { + const source = { + ...traceSourceInput(sessionId, filePath, snapshot.fingerprint, snapshot.nextOrdinal), + oversizedRecords: snapshot.oversizedRecords, + scanTruncated: snapshot.scanTruncated, + windowStartByte: snapshot.windowStartByte, + oversizedContinuation: snapshot.oversizedContinuation, + } + const batches = Math.max(1, Math.ceil(Math.max(snapshot.calls.length, snapshot.events.length) / 256)) + for (let batch = 0; batch < batches; batch += 1) { + signal?.throwIfAborted() + const input = { source, calls: snapshot.calls.slice(batch * 256, (batch + 1) * 256), events: snapshot.events.slice(batch * 256, (batch + 1) * 256) } + if (batch === 0 && !append) index.replaceSession(input) + else index.appendEntries(input) + if (batch < batches - 1) { + index.markDegraded(sessionId, 'TRACE_INDEX_BUILDING') + await new Promise(resolve => setImmediate(resolve)) + } + } + return index.getSummary(sessionId) +} + async function rebuildTraceProjection( index: TraceIndex, sessionId: string, filePath: string, + scan?: TraceScanOptions, ): Promise { - const snapshot = await readStableFullTraceSnapshot(filePath) - const { parsed, fingerprint } = snapshot - if (!fingerprint) return null - index.replaceSession({ - source: traceSourceInput(sessionId, filePath, fingerprint, parsed.nextOrdinal), - calls: parsed.callLocators, - events: parsed.eventLocators, - }) - return index.getSummary(sessionId) + const snapshot = await readStableTraceProjection(filePath, undefined, scan) + return snapshot ? commitTraceProjection(index, sessionId, filePath, snapshot, false, scan?.signal) : null } async function appendTraceProjection( @@ -1913,49 +2388,73 @@ async function appendTraceProjection( source: NonNullable>, previousFingerprint: SourceFingerprint, filePath: string, + scan?: TraceScanOptions, ): Promise { - const target = await fs.stat(filePath) - if (target.size < source.indexedBytes) return null - const prefixStart = Math.max(0, source.indexedBytes - TRACE_FINGERPRINT_WINDOW_BYTES) - const prefix = await readTraceRange( - filePath, - prefixStart, - source.indexedBytes, - 'fingerprint', - ) - const raw = await readTraceRange(filePath, source.indexedBytes, target.size) - const parsed = parseTraceBuffer(raw, { - byteStart: source.indexedBytes, - ordinal: source.nextOrdinal, - }) - const fingerprint = await captureTraceFingerprint(filePath, parsed.indexedBytes) - if ( - fingerprint.size !== target.size || - fingerprint.mtimeMs !== target.mtimeMs || - !fingerprintMatchesAppendBuffers( - previousFingerprint, - fingerprint, - source.indexedBytes, - prefix, - raw, - ) - ) { - return null - } - index.appendEntries({ - source: traceSourceInput(source.sessionId, filePath, fingerprint, parsed.nextOrdinal), - calls: parsed.callLocators, - events: parsed.eventLocators, - }) - return index.getSummary(source.sessionId) + const page = index.getSessionPage(source.sessionId, 0, 1) + const remainingRecords = Math.max(0, TRACE_WINDOW_RECORD_LIMIT - (page?.totalCalls ?? 0) - (page?.totalEvents ?? 0) - source.oversizedRecords) + const snapshot = await readStableTraceProjection(filePath, { source, fingerprint: previousFingerprint }, { ...scan, remainingRecords }) + return snapshot ? commitTraceProjection(index, source.sessionId, filePath, snapshot, true, scan?.signal) : null } +const projectionJobs = new Map + consumers: number +}>() +const projectionWorkQueues = new Map>() + async function ensureTraceProjection( + sessionId: string, + filePath: string, + stat: Stats, + attempt = 0, + target?: TraceIndexTarget, + scan?: TraceScanOptions, +): Promise { + scan?.signal?.throwIfAborted() + const key = `${target?.path ?? ''}\0${filePath}\0${scan?.byteStart ?? 0}` + let job = projectionJobs.get(key) + if (!job) { + if (projectionJobs.size >= 8) throw traceResourceError('TRACE_INDEX_BUSY', 'Trace indexing queue is full; retry shortly') + const controller = new AbortController() + const scopeKey = target?.path ?? currentTraceIndexTarget().path + const promise = (projectionWorkQueues.get(scopeKey) ?? Promise.resolve()).catch(() => {}).then(async () => { + controller.signal.throwIfAborted() + return ensureTraceProjectionNow(sessionId, filePath, stat, attempt, target, { ...scan, signal: controller.signal }) + }) + job = { controller, promise, consumers: 0 } + projectionJobs.set(key, job) + const queueTail = promise.catch(() => {}) + projectionWorkQueues.set(scopeKey, queueTail) + void queueTail.finally(() => { if (projectionWorkQueues.get(scopeKey) === queueTail) projectionWorkQueues.delete(scopeKey) }) + void promise.finally(() => { if (projectionJobs.get(key)?.promise === promise) projectionJobs.delete(key) }).catch(() => {}) + } + const activeJob = job + activeJob.consumers += 1 + return new Promise((resolve, reject) => { + let finished = false + const finish = (value: TraceSessionOverview | null, error?: unknown) => { + if (finished) return + finished = true + scan?.signal?.removeEventListener('abort', onAbort) + activeJob.consumers -= 1 + if (activeJob.consumers === 0) activeJob.controller.abort() + if (error) reject(error) + else resolve(value) + } + const onAbort = () => finish(null, scan?.signal?.reason ?? new Error('Trace request aborted')) + scan?.signal?.addEventListener('abort', onAbort, { once: true }) + activeJob.promise.then(value => finish(value), error => finish(null, error)) + }) +} + +async function ensureTraceProjectionNow( sessionId: string, filePath: string, _stat: Stats, attempt = 0, target?: TraceIndexTarget, + scan?: TraceScanOptions, ): Promise { const index = getTraceIndex(target) if (!index) return null @@ -1963,20 +2462,22 @@ async function ensureTraceProjection( await traceProjectionAfterIndexHookForTests?.( target ?? currentTraceIndexTarget(), ) + scan?.signal?.throwIfAborted() const source = index.getSource(sessionId) - const fingerprint = source?.state === 'ready' && source.filePath === filePath + const fingerprint = source?.state === 'ready' && source.filePath === filePath && source.windowStartByte === (scan?.byteStart ?? 0) ? storedTraceFingerprint(source) : null if (!source || !fingerprint) { traceReadCache.delete(filePath) - const rebuilt = await rebuildTraceProjection(index, sessionId, filePath) + const rebuilt = await rebuildTraceProjection(index, sessionId, filePath, scan) if (!rebuilt && attempt < 1) { - return ensureTraceProjection( + return ensureTraceProjectionNow( sessionId, filePath, await fs.stat(filePath), attempt + 1, target, + scan, ) } return rebuilt @@ -1989,34 +2490,37 @@ async function ensureTraceProjection( return null } traceReadCache.delete(filePath) - if (change.kind === 'append') { - const appended = await appendTraceProjection(index, source, fingerprint, filePath) + if (change.kind === 'append' && !source.scanTruncated) { + const appended = await appendTraceProjection(index, source, fingerprint, filePath, scan) if (!appended && attempt < 1) { - return ensureTraceProjection( + return ensureTraceProjectionNow( sessionId, filePath, await fs.stat(filePath), attempt + 1, target, + scan, ) } return appended } - if (change.kind === 'rebuild') { - const rebuilt = await rebuildTraceProjection(index, sessionId, filePath) + if (change.kind === 'rebuild' || (change.kind === 'append' && source.scanTruncated)) { + const rebuilt = await rebuildTraceProjection(index, sessionId, filePath, scan) if (!rebuilt && attempt < 1) { - return ensureTraceProjection( + return ensureTraceProjectionNow( sessionId, filePath, await fs.stat(filePath), attempt + 1, target, + scan, ) } return rebuilt } return null } catch (error) { + if (scan?.signal?.aborted) throw error if ((error as NodeJS.ErrnoException).code === 'ENOENT') { withTraceIndex(activeIndex => activeIndex.deleteSession(sessionId), target) return null @@ -2083,7 +2587,7 @@ async function appendTraceEntry(sessionId: string, entry: TraceFileEntry): Promi .then(async () => { await traceAppendBeforeWriteHookForTests?.() await fs.mkdir(dirname(filePath), { recursive: true }) - const line = Buffer.from(`${JSON.stringify(entry)}\n`, 'utf-8') + const line = Buffer.from(`${JSON.stringify(boundedTraceEntry(entry))}\n`, 'utf-8') await fs.appendFile(filePath, line) const after = await fs.stat(filePath) traceReadCache.delete(filePath) @@ -2188,6 +2692,9 @@ async function readTraceEntries( events: parsed.eventLocators, }) }, target) + // This legacy helper is not a UI transport. Even explicit callers may + // retain only one bounded full snapshot; raw export always streams disk. + traceReadCache.clear() traceReadCache.set(filePath, { mtimeMs: fingerprint.mtimeMs, size: fingerprint.size, @@ -2267,59 +2774,14 @@ function shellTraceCallFromLocator( } /** - * Events are small (no request/response bodies), but the detail pane shows - * their message and metadata, which the locator does not store. Read just the - * event byte ranges through one handle instead of the whole JSONL. - */ -async function readTraceEventRecords( - filePath: string, - locators: TraceEventLocator[], -): Promise { - if (locators.length === 0) return [] - const handle = await fs.open(filePath, 'r') - try { - const events: TraceEventRecord[] = [] - for (const locator of locators) { - const buffer = Buffer.allocUnsafe(locator.byteLength) - let offset = 0 - while (offset < locator.byteLength) { - const { bytesRead } = await handle.read( - buffer, - offset, - locator.byteLength - offset, - locator.byteStart + offset, - ) - if (bytesRead < 1) return null - offset += bytesRead - } - let entry: unknown - try { - entry = JSON.parse(buffer.toString('utf-8')) - } catch { - return null - } - const event = entry && - typeof entry === 'object' && - (entry as { type?: unknown }).type === 'event' - ? (entry as { event?: unknown }).event - : entry - if (!isTraceEventRecordLike(event) || event.id !== locator.id) return null - events.push(event) - } - return events - } finally { - await handle.close() - } -} - -/** - * Index-backed session trace: SQLite summary + shell calls + event records - * read by byte range. Never reads the full JSONL; returns null when the - * projection is unavailable so callers can fall back to the canonical read. + * Index-backed trace pages fetch scalar metadata with SQL LIMIT. Calls and + * events are lightweight shells; raw export is the lossless detail surface. */ async function readProjectedSessionTrace( sessionId: string, context = currentTraceScopeContext(), + options: TraceOverviewOptions = {}, + scan?: TraceScanOptions, ): Promise { const { target } = context const normalizedSessionId = sanitizeTraceFileName(sessionId) @@ -2345,10 +2807,11 @@ async function readProjectedSessionTrace( stat, 0, target, + scan, ) const index = getTraceIndex(target) if (!projection || !index) return null - const projected = index.getSession(normalizedSessionId) + const projected = index.getSessionPage(normalizedSessionId, options.offset ?? 0, options.limit ?? TRACE_OVERVIEW_LIMIT) if (!projected) return null const fingerprint = storedTraceFingerprint(projected) if (!fingerprint) return null @@ -2356,21 +2819,16 @@ async function readProjectedSessionTrace( return null } - const events = await readTraceEventRecords(filePath, projected.events) - if (!events) return null - // The file is append-only, so event ranges stayed valid; a rewrite between - // the projection check and the reads is caught here before serving shells. - if ((await detectTraceSourceChange(filePath, fingerprint)).kind !== 'unchanged') { - return null - } + const window = traceWindowMetadata({ ...projected, fingerprint }, projected.totalCalls, projected.totalEvents, options) return { sessionId: normalizedSessionId, + window, summary: projected.summary, calls: projected.calls.map((locator) => shellTraceCallFromLocator(normalizedSessionId, locator) ), - events, + events: projected.events.map(event => traceEventShell(normalizedSessionId, event)), } } @@ -2380,6 +2838,9 @@ function scheduleTraceProjectionBackfill( stat: Stats, target: TraceIndexTarget, ): void { + // Large cold sources are indexed only while an overview request consumes + // the work; navigating away can then cancel actual reads and database work. + if (stat.size > TRACE_WINDOW_BYTES_LIMIT) return const key = `${target.path}\0${sessionId}` if (traceBackfillScheduled.has(key)) return if (traceBackfillScheduled.size >= TRACE_BACKFILL_MAX_PENDING) return