From c7fdef7b115cef747b9428673848e846a438343d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:21:47 -0700 Subject: [PATCH] Add live-tail streaming for local AI Vault View Log tabs - Extend the fs:readFile snapshot path with byte-stable file identity so a read-only tab can resume appending exactly where the snapshot left off. - Add a ranged local log tail reader plus IPC (read/start/stop watch) that streams only newly appended bytes, detects truncation/rotation, and cleans up watchers on tab close or renderer destruction. - Add a renderer-side UTF-8/line-boundary decoder and useLocalLogTail hook that appends completed lines into the existing Monaco model, falling back to a full reload on reset/rotation, keeping snapshot behavior unchanged when liveTail is not opted in. - Thread the new `liveTail` flag through OpenFile/PersistedOpenFile, workspace session persistence/restore, and the AI Vault "View Log" open path so live tail survives restarts and stays read-only-safe. --- AI-VAULT-VIEW-LOG-LIVE-TAIL.md | 99 +++++++ .../ai-vault/local-log-tail-reader.test.ts | 72 +++++ src/main/ai-vault/local-log-tail-reader.ts | 78 ++++++ src/main/ipc/filesystem.test.ts | 29 ++ src/main/ipc/filesystem.ts | 49 +++- src/main/ipc/local-log-tail.test.ts | 127 +++++++++ src/main/ipc/local-log-tail.ts | 109 ++++++++ src/preload/api-types.ts | 19 +- src/preload/index.ts | 32 ++- .../editor/editor-panel-content-types.ts | 1 + .../editor/local-log-tail-decoder.test.ts | 60 +++++ .../editor/local-log-tail-decoder.ts | 62 +++++ .../editor/useEditorPanelContentState.ts | 7 +- .../editor/useLocalLogTail.test.tsx | 187 +++++++++++++ .../src/components/editor/useLocalLogTail.ts | 250 ++++++++++++++++++ .../ai-vault-session-log-open.test.ts | 3 +- .../ai-vault-session-log-open.ts | 3 +- .../workspace-session-editor-drafts.test.ts | 9 +- src/renderer/src/lib/workspace-session.ts | 1 + .../src/runtime/runtime-file-client.ts | 9 +- src/renderer/src/store/slices/editor.test.ts | 5 +- src/renderer/src/store/slices/editor.ts | 4 + src/renderer/src/web/web-preload-api.ts | 8 + src/shared/local-log-tail-types.ts | 26 ++ src/shared/types.ts | 2 + src/shared/workspace-session-schema.ts | 3 +- 26 files changed, 1239 insertions(+), 15 deletions(-) create mode 100644 AI-VAULT-VIEW-LOG-LIVE-TAIL.md create mode 100644 src/main/ai-vault/local-log-tail-reader.test.ts create mode 100644 src/main/ai-vault/local-log-tail-reader.ts create mode 100644 src/main/ipc/local-log-tail.test.ts create mode 100644 src/main/ipc/local-log-tail.ts create mode 100644 src/renderer/src/components/editor/local-log-tail-decoder.test.ts create mode 100644 src/renderer/src/components/editor/local-log-tail-decoder.ts create mode 100644 src/renderer/src/components/editor/useLocalLogTail.test.tsx create mode 100644 src/renderer/src/components/editor/useLocalLogTail.ts create mode 100644 src/shared/local-log-tail-types.ts diff --git a/AI-VAULT-VIEW-LOG-LIVE-TAIL.md b/AI-VAULT-VIEW-LOG-LIVE-TAIL.md new file mode 100644 index 00000000000..48e93da688f --- /dev/null +++ b/AI-VAULT-VIEW-LOG-LIVE-TAIL.md @@ -0,0 +1,99 @@ +# AI Vault View Log — Live tail (streaming append) + +> Pick-up brief for a follow-up to the shipped **AI Vault Session Log Viewer** (v1). +> Design doc: `docs/ai-vault-session-log-viewer.md` (see "Use snapshot and explicit +> reload semantics" and the "Implement live tailing now" rejected-alternative). + +## Why this exists + +v1 is **snapshot only**. Opening a View Log tab performs one bounded read; an +active agent that keeps appending makes the buffer stale with no notice beyond +documented behavior. The only refresh path today is re-invoking **View Log** +(which forces a fresh generation-safe read on non-dirty tabs). The design doc +deliberately rejected live-tail for v1 because a naive poll / whole-file-reload +loop violates the performance budget. + +This track builds a **correct** live tail so a running agent's log updates in +place without re-clicking. + +## Goal + +For a read-only View Log tab backed by an appendable local (and later remote) +log, stream newly appended content into the existing Monaco model as it is +written — bounded, cancellable, and without re-reading the whole file. + +## Why it's hard (what the doc calls out) + +A correct live tail needs all of: + +- **Byte-offset range reads** — read only the appended tail, not the whole file. +- **Streaming UTF-8 decode with incomplete-line carry state** — a multi-byte + char or a partial final JSONL record can straddle a read boundary; carry the + partial bytes/line to the next chunk. +- **Append vs. truncate/rotate detection** — if the file shrinks or its inode/ + identity changes (log rotation), fall back to a full re-read rather than + appending garbage. +- **Remote framing limits + cancellation** — over the relay/runtime, tail reads + must respect framing/size limits and cancel cleanly when the tab closes. +- **No watcher outside the worktree root** (v1 rule) — needs an explicit, + scoped, provider-aware tail mechanism, not a broad filesystem watcher. + +## Design sketch (starting point — refine) + +- Main-process (or runtime) **read-only** ranged reader: `readLogTail({ resource, + fromByteOffset }) -> { bytes, nextOffset, truncated }`. Keep it read-only; it + must never share write capability. +- Renderer tail controller bound to the read-only tab lifecycle: + - Track last applied byte offset + carried partial-line bytes. + - On append, decode the new range, split complete lines, append to the Monaco + model via a read-only-safe edit (bypassing the normal dirty/draft path — the + tab stays read-only and non-dirty). + - On `truncated`/identity change, drop state and do a full bounded re-read. + - Cancel + dispose on tab close (respect the perf budget: release model refs). +- Snapshot remains the default; live mode is opt-in per tab (small affordance) or + auto for sessions known to be active. **Do not** add a poll loop or reload the + whole file on a timer. +- Preserve v1 integrity: appended content is display-only; typing/save/rename + stay hard no-ops; the tab never becomes dirty. + +## Interaction with the `logResource` track + +Live tail composes with the provider-owned `logResource` contract (separate +brief). Do local file tail first; remote tail depends on the runtime read-only +ranged-read path landing in that track. Coordinate so both use the same +read-only resource abstraction rather than two parallel readers. + +## Files to study first + +- `docs/ai-vault-session-log-viewer.md` ("Use snapshot and explicit reload + semantics", "Implement live tailing now" rejected alternative, "Performance + Budget"). +- v1 read path: `src/renderer/src/components/editor/useEditorPanelContentState.ts` + (bounded read + `reloadContent`, read-generation guard). +- Read-only tab plumbing: `OpenFile.readOnly` and mutation gates in + `src/renderer/src/store/slices/editor.ts`, `editor-autosave*.ts`. +- Main-process file read + caps: `fs:readFile` path, `src/main/ipc/filesystem-auth.ts`. +- Remote reads: `src/renderer/src/runtime/runtime-file-client.ts`. + +## Acceptance / test plan + +- Open a local active JSONL log; append records externally; the same tab shows + appended lines without a full re-read and without re-clicking View Log. +- A record split across a read boundary renders correctly once complete. +- Truncation/rotation triggers a clean full re-read, not corrupted appends. +- Closing the tab cancels the tail and releases the model/reader (no leak, no + long tasks / jank on multi-MB logs). +- Integrity: live-appended read-only tab cannot be dirtied, saved, or renamed. +- Snapshot behavior unchanged when live mode is off. + +## Non-goals + +- Normalized conversation rendering. +- Raising the size cap or unbounded buffering of huge logs. +- Multipart/SQLite resolution (that's the `logResource` track). + +## Suggested branch / PR + +Branch base: `origin/main`. Local-file live tail is a self-contained first PR; +remote tail is a follow-up gated on the runtime read-only ranged-read from the +`logResource` track. diff --git a/src/main/ai-vault/local-log-tail-reader.test.ts b/src/main/ai-vault/local-log-tail-reader.test.ts new file mode 100644 index 00000000000..cc0c1889e40 --- /dev/null +++ b/src/main/ai-vault/local-log-tail-reader.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { LOCAL_LOG_TAIL_CHUNK_BYTES } from '../../shared/local-log-tail-types' +import { readLocalLogTailRange } from './local-log-tail-reader' + +const tempPaths: string[] = [] + +async function makeLog(content: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-log-tail-')) + tempPaths.push(directory) + const filePath = join(directory, 'session.jsonl') + await writeFile(filePath, content) + return filePath +} + +afterEach(async () => { + await Promise.all(tempPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('readLocalLogTailRange', () => { + it('reads only the requested appended range', async () => { + const filePath = await makeLog('first\nsecond\n') + const result = await readLocalLogTailRange(filePath, Buffer.byteLength('first\n')) + + expect(Buffer.from(result.contentBase64, 'base64').toString('utf8')).toBe('second\n') + expect(result.nextByteOffset).toBe(Buffer.byteLength('first\nsecond\n')) + expect(result.hasMore).toBe(false) + expect(result.reset).toBe(false) + }) + + it('caps each response and reports that more bytes remain', async () => { + const filePath = await makeLog('x'.repeat(LOCAL_LOG_TAIL_CHUNK_BYTES + 17)) + const result = await readLocalLogTailRange(filePath, 0) + + expect(Buffer.from(result.contentBase64, 'base64')).toHaveLength(LOCAL_LOG_TAIL_CHUNK_BYTES) + expect(result.nextByteOffset).toBe(LOCAL_LOG_TAIL_CHUNK_BYTES) + expect(result.hasMore).toBe(true) + }) + + it('requests a reset after truncation', async () => { + const filePath = await makeLog('first\nsecond\n') + const initial = await readLocalLogTailRange(filePath, 0) + await truncate(filePath, 2) + + const result = await readLocalLogTailRange( + filePath, + initial.nextByteOffset, + initial.fileIdentity + ) + expect(result.reset).toBe(true) + expect(result.nextByteOffset).toBe(0) + expect(result.fileSize).toBe(2) + }) + + it('requests a reset when the file is atomically replaced', async () => { + const filePath = await makeLog('old\n') + const initial = await readLocalLogTailRange(filePath, 0) + const replacement = `${filePath}.next` + await writeFile(replacement, 'new and longer\n') + await rename(replacement, filePath) + + const result = await readLocalLogTailRange( + filePath, + initial.nextByteOffset, + initial.fileIdentity + ) + expect(result.reset).toBe(true) + expect(result.fileIdentity).not.toBe(initial.fileIdentity) + }) +}) diff --git a/src/main/ai-vault/local-log-tail-reader.ts b/src/main/ai-vault/local-log-tail-reader.ts new file mode 100644 index 00000000000..3045981e5c5 --- /dev/null +++ b/src/main/ai-vault/local-log-tail-reader.ts @@ -0,0 +1,78 @@ +import { open } from 'node:fs/promises' +import { + LOCAL_LOG_TAIL_CHUNK_BYTES, + type LocalLogTailReadResult +} from '../../shared/local-log-tail-types' + +export function localLogFileIdentity(stats: { + dev: number + ino: number + birthtimeMs: number +}): string { + // Why: inode catches atomic replacement on POSIX while birthtime covers + // platforms/filesystems that report an unusable inode value. + return `${stats.dev}:${stats.ino}:${stats.birthtimeMs}` +} + +export async function readLocalLogTailRange( + filePath: string, + fromByteOffset: number, + expectedIdentity?: string +): Promise { + if (!Number.isSafeInteger(fromByteOffset) || fromByteOffset < 0) { + throw new Error('Invalid local log tail byte offset') + } + + const handle = await open(filePath, 'r') + try { + const initialStats = await handle.stat() + if (!initialStats.isFile()) { + throw new Error('Local log tail target is not a file') + } + const fileIdentity = localLogFileIdentity(initialStats) + if ( + fromByteOffset > initialStats.size || + (expectedIdentity !== undefined && expectedIdentity !== fileIdentity) + ) { + return { + contentBase64: '', + nextByteOffset: 0, + fileSize: initialStats.size, + fileIdentity, + hasMore: initialStats.size > 0, + reset: true + } + } + + const bytesToRead = Math.min(LOCAL_LOG_TAIL_CHUNK_BYTES, initialStats.size - fromByteOffset) + const buffer = Buffer.allocUnsafe(bytesToRead) + const { bytesRead } = + bytesToRead > 0 ? await handle.read(buffer, 0, bytesToRead, fromByteOffset) : { bytesRead: 0 } + const nextByteOffset = fromByteOffset + bytesRead + const finalStats = await handle.stat() + + // Why: truncation can race the ranged read on the same open handle. Do not + // let an offset beyond the new EOF become the baseline for later appends. + if (nextByteOffset > finalStats.size) { + return { + contentBase64: '', + nextByteOffset: 0, + fileSize: finalStats.size, + fileIdentity, + hasMore: finalStats.size > 0, + reset: true + } + } + + return { + contentBase64: buffer.subarray(0, bytesRead).toString('base64'), + nextByteOffset, + fileSize: finalStats.size, + fileIdentity, + hasMore: nextByteOffset < finalStats.size, + reset: false + } + } finally { + await handle.close() + } +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 072f8239dcb..2e9d1843988 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -907,6 +907,35 @@ describe('registerFilesystemHandlers', () => { }) }) + it('returns stable byte metadata only for opted-in local log snapshots', async () => { + const content = Buffer.from('first\npartial') + const close = vi.fn() + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: content.byteLength, + dev: 1, + ino: 2, + birthtimeMs: 3 + }), + readFile: vi.fn().mockResolvedValue(content), + close + }) + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { + filePath: path.resolve('/workspace/repo/session.jsonl'), + includeLocalLogMetadata: true + }) + ).resolves.toEqual({ + content: 'first\npartial', + isBinary: false, + fileIdentity: '1:2:3' + }) + expect(close).toHaveBeenCalledTimes(1) + expect(readFileMock).not.toHaveBeenCalled() + }) + it('rejects text files beyond the editor read budget', async () => { statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 100245dca8b..3918f8d3ea4 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -120,6 +120,8 @@ import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-er import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { registerLocalLogTailHandlers } from './local-log-tail' +import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -148,6 +150,38 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) +async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } function validateRequiredString(value: unknown, label: string): string { @@ -534,13 +568,22 @@ export function registerFilesystemHandlers( 'fs:readFile', async ( _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => { + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } const stats = await stat(filePath) const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE @@ -2216,4 +2259,6 @@ export function registerFilesystemHandlers( return getRemoteCommitUrl(worktreePath, sha) } ) + + registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts new file mode 100644 index 00000000000..beac464f4a4 --- /dev/null +++ b/src/main/ipc/local-log-tail.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, watchMock, resolveAuthorizedPathMock, readRangeMock } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + watchMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + readRangeMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('node:fs', () => ({ watch: watchMock })) + +vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) + +vi.mock('../ai-vault/local-log-tail-reader', () => ({ + readLocalLogTailRange: readRangeMock +})) + +import { + closeAllLocalLogTailWatchers, + getActiveLocalLogTailWatcherCount, + registerLocalLogTailHandlers +} from './local-log-tail' + +type FakeWatcher = { + close: ReturnType + on: ReturnType + emitError: () => void +} + +function makeWatcher(): FakeWatcher { + let errorListener: (() => void) | undefined + return { + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === 'error') { + errorListener = listener + } + }), + emitError: () => errorListener?.() + } +} + +function makeSender(id: number) { + let destroyedListener: (() => void) | undefined + return { + id, + send: vi.fn(), + isDestroyed: vi.fn(() => false), + once: vi.fn((event: string, listener: () => void) => { + if (event === 'destroyed') { + destroyedListener = listener + } + }), + destroy: () => destroyedListener?.() + } +} + +beforeEach(() => { + handlers.clear() + watchMock.mockReset() + resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) + readRangeMock.mockReset() + registerLocalLogTailHandlers({} as never) +}) + +afterEach(() => { + closeAllLocalLogTailWatchers() +}) + +describe('local log tail IPC', () => { + it('watches only the authorized file and closes on explicit tab cancellation', async () => { + const watcher = makeWatcher() + let emitChange: ((eventType: 'change' | 'rename') => void) | undefined + watchMock.mockImplementation((_path: string, listener: typeof emitChange) => { + emitChange = listener + return watcher + }) + const sender = makeSender(7) + + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/session.jsonl', subscriptionId: 'tail-1' } + ) + emitChange?.('change') + + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything()) + expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function)) + expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', { + subscriptionId: 'tail-1', + eventType: 'change' + }) + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + + handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId: 'tail-1' }) + expect(watcher.close).toHaveBeenCalledTimes(1) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + }) + + it('closes every watcher owned by a destroyed renderer', async () => { + const first = makeWatcher() + const second = makeWatcher() + watchMock.mockReturnValueOnce(first).mockReturnValueOnce(second) + const sender = makeSender(9) + + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/a.jsonl', subscriptionId: 'tail-a' } + ) + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/b.jsonl', subscriptionId: 'tail-b' } + ) + sender.destroy() + + expect(first.close).toHaveBeenCalledTimes(1) + expect(second.close).toHaveBeenCalledTimes(1) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + }) +}) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts new file mode 100644 index 00000000000..430882b4e07 --- /dev/null +++ b/src/main/ipc/local-log-tail.ts @@ -0,0 +1,109 @@ +import { ipcMain, type WebContents } from 'electron' +import { watch, type FSWatcher } from 'node:fs' +import type { Store } from '../persistence' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' +import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' +import { resolveAuthorizedPath } from './filesystem-auth' + +type TailWatch = { + senderId: number + watcher: FSWatcher +} + +const tailWatches = new Map() +const senderCleanupRegistered = new Set() + +function watchKey(senderId: number, subscriptionId: string): string { + return `${senderId}:${subscriptionId}` +} + +function closeWatch(key: string): void { + const subscription = tailWatches.get(key) + if (!subscription) { + return + } + tailWatches.delete(key) + subscription.watcher.close() +} + +function closeSenderWatches(senderId: number): void { + senderCleanupRegistered.delete(senderId) + for (const [key, subscription] of tailWatches) { + if (subscription.senderId === senderId) { + closeWatch(key) + } + } +} + +function validateSubscriptionId(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 200) { + throw new Error('Invalid local log tail subscription id') + } + return value +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + sender.once('destroyed', () => closeSenderWatches(sender.id)) +} + +export function registerLocalLogTailHandlers(store: Store): void { + ipcMain.handle( + 'fs:readLocalLogTail', + async (_event, args: LocalLogTailReadArgs): Promise => { + const filePath = await resolveAuthorizedPath(args.filePath, store) + return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) + } + ) + + ipcMain.handle( + 'fs:startLocalLogTail', + async (event, args: LocalLogTailWatchArgs): Promise => { + const subscriptionId = validateSubscriptionId(args.subscriptionId) + const filePath = await resolveAuthorizedPath(args.filePath, store) + const key = watchKey(event.sender.id, subscriptionId) + closeWatch(key) + + const sendChange = (eventType: 'change' | 'rename'): void => { + if (!tailWatches.has(key) || event.sender.isDestroyed()) { + return + } + const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } + event.sender.send('fs:localLogTailChanged', payload) + } + const watcher = watch(filePath, (eventType) => sendChange(eventType)) + watcher.on('error', () => { + // Why: an error commonly accompanies rotation. Signal one final drain so + // the renderer can detect identity change, then release the dead handle. + sendChange('rename') + closeWatch(key) + }) + tailWatches.set(key, { senderId: event.sender.id, watcher }) + registerSenderCleanup(event.sender) + } + ) + + ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { + closeWatch(watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId))) + }) +} + +export function closeAllLocalLogTailWatchers(): void { + for (const key of Array.from(tailWatches.keys())) { + closeWatch(key) + } + senderCleanupRegistered.clear() +} + +/** Test-only: verifies tab/window teardown does not retain native watchers. */ +export function getActiveLocalLogTailWatcherCount(): number { + return tailWatches.size +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ceb0d6a8d54..b36640c9d8a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,12 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { @@ -2349,7 +2355,18 @@ export type PreloadApi = { readFile: (args: { filePath: string connectionId?: string - }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> + includeLocalLogMetadata?: boolean + }) => Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> + readLocalLogTail: (args: LocalLogTailReadArgs) => Promise + startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise + stopLocalLogTail: (args: { subscriptionId: string }) => Promise + onLocalLogTailChanged: (callback: (payload: LocalLogTailChangedPayload) => void) => () => void downloadFile: (args: { filePath: string connectionId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 2e960b9c461..8b20bcf03b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -195,6 +195,12 @@ import { type NativeFileDropPayload, type NativeFileDropPathEntry } from '../shared/native-file-drop' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../shared/local-log-tail-types' import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' @@ -2719,8 +2725,30 @@ const api = { readFile: (args: { filePath: string connectionId?: string - }): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => - ipcRenderer.invoke('fs:readFile', args), + includeLocalLogMetadata?: boolean + }): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => ipcRenderer.invoke('fs:readFile', args), + readLocalLogTail: (args: LocalLogTailReadArgs): Promise => + ipcRenderer.invoke('fs:readLocalLogTail', args), + startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => + ipcRenderer.invoke('fs:startLocalLogTail', args), + stopLocalLogTail: (args: { subscriptionId: string }): Promise => + ipcRenderer.invoke('fs:stopLocalLogTail', args), + onLocalLogTailChanged: ( + callback: (payload: LocalLogTailChangedPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LocalLogTailChangedPayload + ): void => callback(payload) + ipcRenderer.on('fs:localLogTailChanged', listener) + return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) + }, downloadFile: (args: { filePath: string connectionId: string diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts index 636b910b77d..c44df176150 100644 --- a/src/renderer/src/components/editor/editor-panel-content-types.ts +++ b/src/renderer/src/components/editor/editor-panel-content-types.ts @@ -23,6 +23,7 @@ export type FileContent = { isBinary: boolean isImage?: boolean mimeType?: string + fileIdentity?: string loadError?: string } diff --git a/src/renderer/src/components/editor/local-log-tail-decoder.test.ts b/src/renderer/src/components/editor/local-log-tail-decoder.test.ts new file mode 100644 index 00000000000..33d9b3e5eb3 --- /dev/null +++ b/src/renderer/src/components/editor/local-log-tail-decoder.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { LocalLogTailReadResult } from '../../../../shared/local-log-tail-types' +import { LocalLogTailDecoder } from './local-log-tail-decoder' + +const IDENTITY = '1:2:3' + +function chunk( + content: Uint8Array, + nextByteOffset: number, + overrides: Partial = {} +): LocalLogTailReadResult { + return { + contentBase64: Buffer.from(content).toString('base64'), + nextByteOffset, + fileSize: nextByteOffset, + fileIdentity: IDENTITY, + hasMore: false, + reset: false, + ...overrides + } +} + +describe('LocalLogTailDecoder', () => { + it('rewinds a snapshot to its last complete line', () => { + const decoder = new LocalLogTailDecoder('one\ntwo', IDENTITY) + + expect(decoder.initialVisibleContent).toBe('one\n') + expect(decoder.nextByteOffset).toBe(Buffer.byteLength('one\n')) + }) + + it('carries an incomplete UTF-8 code point and record across reads', () => { + const decoder = new LocalLogTailDecoder('', IDENTITY) + const bytes = Buffer.from('{"text":"雪"}\n', 'utf8') + const split = bytes.indexOf(Buffer.from('雪')) + 1 + + const first = decoder.apply(chunk(bytes.subarray(0, split), split, { hasMore: true })) + const second = decoder.apply(chunk(bytes.subarray(split), bytes.length)) + + expect(first).toEqual({ kind: 'append', content: '', hasMore: true }) + expect(second).toEqual({ kind: 'append', content: '{"text":"雪"}\n', hasMore: false }) + }) + + it('holds a partial final line until a later append completes it', () => { + const decoder = new LocalLogTailDecoder('', IDENTITY) + const firstBytes = Buffer.from('{"partial":') + const secondBytes = Buffer.from('true}\n') + + expect(decoder.apply(chunk(firstBytes, firstBytes.length))).toMatchObject({ content: '' }) + expect(decoder.apply(chunk(secondBytes, firstBytes.length + secondBytes.length))).toMatchObject( + { content: '{"partial":true}\n' } + ) + }) + + it('does not apply bytes when the reader detects truncate or rotation', () => { + const decoder = new LocalLogTailDecoder('old\n', IDENTITY) + const result = decoder.apply(chunk(new Uint8Array(), 0, { reset: true })) + + expect(result).toEqual({ kind: 'reset' }) + }) +}) diff --git a/src/renderer/src/components/editor/local-log-tail-decoder.ts b/src/renderer/src/components/editor/local-log-tail-decoder.ts new file mode 100644 index 00000000000..99c06a080e1 --- /dev/null +++ b/src/renderer/src/components/editor/local-log-tail-decoder.ts @@ -0,0 +1,62 @@ +import type { LocalLogTailReadResult } from '../../../../shared/local-log-tail-types' + +export const LOCAL_LOG_TAIL_MAX_BYTES = 50 * 1024 * 1024 + +export type LocalLogTailDecodeResult = + | { kind: 'append'; content: string; hasMore: boolean } + | { kind: 'reset' } + | { kind: 'limit' } + +function decodeBase64(contentBase64: string): Uint8Array { + const binary = atob(contentBase64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +export class LocalLogTailDecoder { + readonly initialVisibleContent: string + private byteOffset: number + private fileIdentity: string + private readonly decoder = new TextDecoder('utf-8') + private lineCarry = '' + + constructor(snapshotContent: string, fileIdentity: string) { + const lastCompleteLineEnd = snapshotContent.lastIndexOf('\n') + 1 + this.initialVisibleContent = snapshotContent.slice(0, lastCompleteLineEnd) + // Why: rewind to the last complete line, then re-read the trailing record as + // bytes. This preserves a UTF-8 code point split at the snapshot EOF. + this.byteOffset = new TextEncoder().encode(this.initialVisibleContent).byteLength + this.fileIdentity = fileIdentity + } + + get nextByteOffset(): number { + return this.byteOffset + } + + get expectedIdentity(): string { + return this.fileIdentity + } + + apply(result: LocalLogTailReadResult): LocalLogTailDecodeResult { + if (result.reset) { + return { kind: 'reset' } + } + if (result.nextByteOffset > LOCAL_LOG_TAIL_MAX_BYTES) { + return { kind: 'limit' } + } + + this.byteOffset = result.nextByteOffset + this.fileIdentity = result.fileIdentity + this.lineCarry += this.decoder.decode(decodeBase64(result.contentBase64), { stream: true }) + const lastCompleteLineEnd = this.lineCarry.lastIndexOf('\n') + 1 + if (lastCompleteLineEnd === 0) { + return { kind: 'append', content: '', hasMore: result.hasMore } + } + const content = this.lineCarry.slice(0, lastCompleteLineEnd) + this.lineCarry = this.lineCarry.slice(lastCompleteLineEnd) + return { kind: 'append', content, hasMore: result.hasMore } + } +} diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 6e60afbf833..95a85c31c48 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -30,6 +30,7 @@ import { usePruneClosedEditorContent } from './useEditorPanelExternalContentEvents' import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry' +import { useLocalLogTail } from './useLocalLogTail' const inFlightFileReads = new Map>() const inFlightDiffReads = new Map>() @@ -172,7 +173,9 @@ export function useEditorPanelContentState({ filePath, relativePath: restoredOpenFile?.relativePath ?? relativePath, worktreeId, - connectionId + connectionId, + includeLocalLogMetadata: + restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true }) as Promise inFlightFileReads.set(key, pending) queueMicrotask(() => { @@ -354,6 +357,8 @@ export function useEditorPanelContentState({ [loadDiffContent, loadFileContent] ) + useLocalLogTail({ openFiles, fileContents, setFileContents, reloadContent }) + useEffect(() => { if (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile) { const snapshotEntries = activeFile.conflictReview?.entries ?? [] diff --git a/src/renderer/src/components/editor/useLocalLogTail.test.tsx b/src/renderer/src/components/editor/useLocalLogTail.test.tsx new file mode 100644 index 00000000000..270eae768e7 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalLogTail.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { LocalLogTailChangedPayload } from '../../../../shared/local-log-tail-types' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' +import { useLocalLogTail } from './useLocalLogTail' + +const FILE_PATH = '/home/user/.codex/sessions/log.jsonl' +const FILE_IDENTITY = '1:2:3' + +const openFile = { + id: FILE_PATH, + filePath: FILE_PATH, + relativePath: FILE_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + isDirty: false, + runtimeEnvironmentId: null, + mode: 'edit', + readOnly: true, + liveTail: true +} satisfies OpenFile + +function encodedResult(content: string, fromByteOffset: number) { + const bytes = Buffer.from(content) + return { + contentBase64: bytes.toString('base64'), + nextByteOffset: fromByteOffset + bytes.byteLength, + fileSize: fromByteOffset + bytes.byteLength, + fileIdentity: FILE_IDENTITY, + hasMore: false, + reset: false + } +} + +let changedListener: ((payload: LocalLogTailChangedPayload) => void) | undefined +let startMock: ReturnType +let stopMock: ReturnType +let readMock: ReturnType +let reloadMock: Mock<(file: OpenFile) => void> +let warnMock: ReturnType + +beforeEach(() => { + changedListener = undefined + startMock = vi.fn().mockResolvedValue(undefined) + stopMock = vi.fn().mockResolvedValue(undefined) + readMock = vi.fn() + reloadMock = vi.fn<(file: OpenFile) => void>() + warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + fs: { + startLocalLogTail: startMock, + stopLocalLogTail: stopMock, + readLocalLogTail: readMock, + onLocalLogTailChanged: vi.fn((listener: (payload: LocalLogTailChangedPayload) => void) => { + changedListener = listener + return vi.fn() + }) + } + } + }) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'api') + warnMock.mockRestore() + vi.clearAllMocks() +}) + +function useHarness(files: OpenFile[], initialContent: FileContent) { + const [contents, setContents] = useState>({ + [FILE_PATH]: initialContent + }) + useLocalLogTail({ + openFiles: files, + fileContents: contents, + setFileContents: setContents, + reloadContent: reloadMock + }) + return contents +} + +describe('useLocalLogTail', () => { + it('re-reads a partial snapshot line and appends it only after completion', async () => { + const snapshot = 'complete\n{"partial":' + const offset = Buffer.byteLength('complete\n') + readMock.mockResolvedValue(encodedResult('{"partial":true}\n', offset)) + + const { result } = renderHook(() => + useHarness([openFile], { + content: snapshot, + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + + await waitFor(() => { + expect(result.current[FILE_PATH]?.content).toBe('complete\n{"partial":true}\n') + }) + expect(readMock).toHaveBeenCalledWith({ + filePath: FILE_PATH, + fromByteOffset: offset, + expectedIdentity: FILE_IDENTITY + }) + }) + + it('waits for watcher startup before cancelling a tab closed in flight', async () => { + let resolveStart: (() => void) | undefined + startMock.mockImplementation( + () => + new Promise((resolve) => { + resolveStart = resolve + }) + ) + const snapshot: FileContent = { + content: 'complete\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + } + const { rerender } = renderHook(({ files }) => useHarness(files, snapshot), { + initialProps: { files: [openFile] } + }) + + rerender({ files: [] }) + await act(async () => resolveStart?.()) + + await waitFor(() => expect(stopMock).toHaveBeenCalledTimes(1)) + expect(readMock).not.toHaveBeenCalled() + }) + + it('falls back to a full reload when a change reveals truncation', async () => { + readMock.mockResolvedValue({ + ...encodedResult('', 0), + nextByteOffset: 0, + reset: true + }) + renderHook(() => + useHarness([openFile], { + content: 'old\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + + await waitFor(() => expect(reloadMock).toHaveBeenCalledWith(openFile)) + await waitFor(() => expect(stopMock).toHaveBeenCalledTimes(1)) + }) + + it('commits complete lines read before a later transient chunk failure', async () => { + const first = encodedResult('new\n', 4) + readMock + .mockResolvedValueOnce({ ...first, fileSize: first.fileSize + 1, hasMore: true }) + .mockRejectedValueOnce(new Error('temporary read failure')) + const { result } = renderHook(() => + useHarness([openFile], { + content: 'old\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + + await waitFor(() => expect(result.current[FILE_PATH]?.content).toBe('old\nnew\n')) + expect(reloadMock).not.toHaveBeenCalled() + }) + + it('treats a rename event as rotation and reloads without another ranged append', async () => { + readMock.mockResolvedValue(encodedResult('', 4)) + renderHook(() => + useHarness([openFile], { + content: 'old\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + await waitFor(() => expect(readMock).toHaveBeenCalledTimes(1)) + const subscriptionId = startMock.mock.calls[0]?.[0].subscriptionId as string + + act(() => changedListener?.({ subscriptionId, eventType: 'rename' })) + + await waitFor(() => expect(reloadMock).toHaveBeenCalledWith(openFile)) + expect(readMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/useLocalLogTail.ts b/src/renderer/src/components/editor/useLocalLogTail.ts new file mode 100644 index 00000000000..9c8e1624a48 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalLogTail.ts @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' +import type { LocalLogTailChangedPayload } from '../../../../shared/local-log-tail-types' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' +import { LocalLogTailDecoder } from './local-log-tail-decoder' + +type TailSession = { + fileId: string + filePath: string + subscriptionId: string + decoder: LocalLogTailDecoder + closed: boolean + reading: boolean + pendingRead: boolean + limited: boolean + startPromise: Promise +} + +type UseLocalLogTailParams = { + openFiles: OpenFile[] + fileContents: Record + setFileContents: Dispatch>> + reloadContent: (file: OpenFile) => void +} + +let nextSubscriptionId = 0 + +function isLocalLiveLog( + file: OpenFile, + content: FileContent | undefined +): content is FileContent & { + fileIdentity: string +} { + return ( + file.mode === 'edit' && + file.readOnly === true && + file.liveTail === true && + (file.runtimeEnvironmentId ?? null) === null && + file.relativePath === file.filePath && + content !== undefined && + content.isBinary === false && + !content.loadError && + typeof content.fileIdentity === 'string' && + content.fileIdentity.length > 0 + ) +} + +function stopTailSession(session: TailSession): void { + if (session.closed) { + return + } + session.closed = true + // Why: start IPC can still be resolving when a tab closes. Stop only after + // start settles so a late-created main-process watcher cannot escape cleanup. + void session.startPromise + .then(() => window.api.fs.stopLocalLogTail({ subscriptionId: session.subscriptionId })) + .catch(() => {}) +} + +export function useLocalLogTail({ + openFiles, + fileContents, + setFileContents, + reloadContent +}: UseLocalLogTailParams): void { + const sessionsRef = useRef(new Map()) + const openFilesRef = useRef(openFiles) + openFilesRef.current = openFiles + const reloadContentRef = useRef(reloadContent) + reloadContentRef.current = reloadContent + const hasLocalLiveTailFile = openFiles.some( + (file) => file.readOnly === true && file.liveTail === true + ) + + const restartFromSnapshot = useCallback((session: TailSession): void => { + const file = openFilesRef.current.find((candidate) => candidate.id === session.fileId) + stopTailSession(session) + sessionsRef.current.delete(session.fileId) + if (file) { + reloadContentRef.current(file) + } + }, []) + + const drain = useCallback( + async (session: TailSession): Promise => { + if (session.closed || session.limited) { + return + } + if (session.reading) { + session.pendingRead = true + return + } + session.reading = true + let appendedContent = '' + let discardAppends = false + try { + do { + session.pendingRead = false + for (;;) { + const result = await window.api.fs.readLocalLogTail({ + filePath: session.filePath, + fromByteOffset: session.decoder.nextByteOffset, + expectedIdentity: session.decoder.expectedIdentity + }) + if (session.closed) { + return + } + const decoded = session.decoder.apply(result) + if (decoded.kind === 'reset') { + discardAppends = true + restartFromSnapshot(session) + return + } + if (decoded.kind === 'limit') { + session.limited = true + void session.startPromise + .then(() => + window.api.fs.stopLocalLogTail({ subscriptionId: session.subscriptionId }) + ) + .catch(() => {}) + console.warn('[ai-vault] stopped live tail at the editor file-size limit') + return + } + appendedContent += decoded.content + if (!decoded.hasMore) { + break + } + } + } while (session.pendingRead && !session.closed) + } catch (error) { + if (!session.closed) { + console.warn('[ai-vault] local log tail read failed', error) + } + } finally { + session.reading = false + if (appendedContent && !discardAppends && !session.closed) { + setFileContents((previous) => { + const current = previous[session.fileId] + if (!current || current.isBinary || current.loadError) { + return previous + } + return { + ...previous, + [session.fileId]: { ...current, content: current.content + appendedContent } + } + }) + } + } + }, + [restartFromSnapshot, setFileContents] + ) + + useEffect(() => { + if (!hasLocalLiveTailFile) { + return + } + const unsubscribe = window.api.fs.onLocalLogTailChanged( + ({ subscriptionId, eventType }: LocalLogTailChangedPayload) => { + const session = Array.from(sessionsRef.current.values()).find( + (candidate) => candidate.subscriptionId === subscriptionId + ) + if (!session) { + return + } + if (eventType === 'rename') { + restartFromSnapshot(session) + return + } + void drain(session) + } + ) + return unsubscribe + }, [drain, hasLocalLiveTailFile, restartFromSnapshot]) + + useEffect(() => { + const liveFileIds = new Set() + for (const file of openFiles) { + const content = fileContents[file.id] + if (!isLocalLiveLog(file, content)) { + continue + } + liveFileIds.add(file.id) + if (sessionsRef.current.has(file.id)) { + continue + } + + const decoder = new LocalLogTailDecoder(content.content, content.fileIdentity) + const subscriptionId = `local-log-tail-${++nextSubscriptionId}` + const session: TailSession = { + fileId: file.id, + filePath: file.filePath, + subscriptionId, + decoder, + closed: false, + reading: false, + pendingRead: false, + limited: false, + startPromise: Promise.resolve() + } + sessionsRef.current.set(file.id, session) + + if (decoder.initialVisibleContent !== content.content) { + setFileContents((previous) => { + const current = previous[file.id] + return current + ? { + ...previous, + [file.id]: { ...current, content: decoder.initialVisibleContent } + } + : previous + }) + } + + session.startPromise = window.api.fs.startLocalLogTail({ + filePath: file.filePath, + subscriptionId + }) + void session.startPromise + .then(() => { + if (session.closed) { + return + } + // Why: this first drain closes the snapshot/watch installation race. + return drain(session) + }) + .catch((error) => { + if (!session.closed) { + console.warn('[ai-vault] local log tail watch failed', error) + } + }) + } + + for (const [fileId, session] of sessionsRef.current) { + if (!liveFileIds.has(fileId)) { + stopTailSession(session) + sessionsRef.current.delete(fileId) + } + } + }, [drain, fileContents, openFiles, setFileContents]) + + useEffect( + () => () => { + for (const session of sessionsRef.current.values()) { + stopTailSession(session) + } + sessionsRef.current.clear() + }, + [] + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts index 1c3fc642260..bde07f87540 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts @@ -73,7 +73,8 @@ describe('openAiVaultSessionLogInOrca', () => { runtimeEnvironmentId: null, language: 'jsonl', mode: 'edit', - readOnly: true + readOnly: true, + liveTail: true }) expect(options).toEqual({ preview: false, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts index 781dd4574b1..2f4b6ff495c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts @@ -122,7 +122,8 @@ export async function openAiVaultSessionLogInOrca(session: AiVaultLogSession): P runtimeEnvironmentId: null, language: detectLanguage(filePath), mode: 'edit', - readOnly: true + readOnly: true, + liveTail: true }, { preview: false, diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 2c17baa74c7..ad12ccec39c 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -138,7 +138,8 @@ describe('workspace session editor drafts', () => { // Why: even if isDirty is somehow set, a read-only tab must not // persist a draft that a restore could write back to disk. isDirty: true, - readOnly: true + readOnly: true, + liveTail: true } as never ], editorDrafts: { @@ -149,7 +150,11 @@ describe('workspace session editor drafts', () => { const persisted = payload.openFilesByWorktree?.['wt-1']?.[0] expect(persisted).toEqual( - expect.objectContaining({ filePath: '/home/user/.claude/log.jsonl', readOnly: true }) + expect.objectContaining({ + filePath: '/home/user/.claude/log.jsonl', + readOnly: true, + liveTail: true + }) ) expect(persisted).toEqual( expect.not.objectContaining({ dirtyDraftContent: expect.any(String) }) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 7af826af719..21a11eb0eec 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -141,6 +141,7 @@ export function buildEditorSessionData( // Why: persist read-only only when true so pre-existing writable sessions // stay writable on restore (absence is the writable default). ...(f.readOnly === true ? { readOnly: true } : {}), + ...(f.readOnly === true && f.liveTail === true ? { liveTail: true } : {}), ...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}), // Why: the edit baseline travels with the dirty draft so a restore can // re-derive a changed-on-disk conflict before autosave may overwrite an diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index d8738600d69..3265ad343b0 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -37,6 +37,7 @@ export type RuntimeReadableFileContent = { isBinary: boolean isImage?: boolean mimeType?: string + fileIdentity?: string } export type RuntimeFileReadArgs = { @@ -45,6 +46,7 @@ export type RuntimeFileReadArgs = { relativePath?: string worktreeId?: string connectionId?: string + includeLocalLogMetadata?: boolean } export type RuntimeFileOperationArgs = { @@ -147,14 +149,15 @@ export async function readRuntimeFileContent({ filePath, relativePath, worktreeId, - connectionId + connectionId, + includeLocalLogMetadata }: RuntimeFileReadArgs): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment') { - return window.api.fs.readFile({ filePath, connectionId }) + return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) } if (!worktreeId) { - return window.api.fs.readFile({ filePath, connectionId }) + return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) } if (!canReadRelativeRuntimeFile(relativePath)) { throw new Error('Remote file is outside the owning runtime worktree') diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 4c1ead1a14d..cbdd62a728f 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -4561,6 +4561,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => { language: 'jsonl', mode: 'edit', readOnly: true, + liveTail: true, runtimeEnvironmentId: null }, { preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true } @@ -4575,6 +4576,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => { filePath: LOG_PATH, mode: 'edit', readOnly: true, + liveTail: true, isPreview: undefined, runtimeEnvironmentId: null }) @@ -4651,6 +4653,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => { worktreeId: 'wt-1', language: 'jsonl', readOnly: true, + liveTail: true, // Why: a corrupt/legacy session could carry a draft; hydrate must // hard-strip it so the restored log can never come back writable. dirtyDraftContent: 'should be ignored', @@ -4662,7 +4665,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => { const restored = store.getState().openFiles.find((f) => f.filePath === LOG_PATH) expect(restored).toEqual( - expect.objectContaining({ readOnly: true, isDirty: false, mode: 'edit' }) + expect.objectContaining({ readOnly: true, liveTail: true, isDirty: false, mode: 'edit' }) ) expect(restored?.pendingDiskBaselineVerification).toBeUndefined() expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 05c05dcb5f7..07de857fa33 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -287,6 +287,9 @@ export type OpenFile = { * so an agent-owned transcript cannot be mutated through editor write paths. * Persisted only when true; absence is the writable default. */ readOnly?: boolean + /** Why: live tail is explicit and only meaningful for a read-only local log; + * ordinary editor tabs and read-only snapshots keep their existing behavior. */ + liveTail?: boolean mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' | 'check-details' } @@ -4378,6 +4381,7 @@ export const createEditorSlice: StateCreator = (s isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, ...(isReadOnly ? { readOnly: true } : {}), + ...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}), lastKnownDiskSignature: isReadOnly ? undefined : pf.lastKnownDiskSignature, // Why: hard-suspends autosave until the restored-tab conflict scan // verifies disk against the baseline — an async race would let a diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index be642916a6e..640a74dc40d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1478,6 +1478,14 @@ function createFileApi(): NonNullable['fs']> { relativePath: file.relativePath }) }, + readLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + startLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + stopLocalLogTail: async () => {}, + onLocalLogTailChanged: () => noopUnsubscribe, downloadFile: async () => { throw new Error('Remote file download is unavailable in paired web clients.') }, diff --git a/src/shared/local-log-tail-types.ts b/src/shared/local-log-tail-types.ts new file mode 100644 index 00000000000..b589ff96690 --- /dev/null +++ b/src/shared/local-log-tail-types.ts @@ -0,0 +1,26 @@ +export const LOCAL_LOG_TAIL_CHUNK_BYTES = 256 * 1024 + +export type LocalLogTailReadArgs = { + filePath: string + fromByteOffset: number + expectedIdentity?: string +} + +export type LocalLogTailReadResult = { + contentBase64: string + nextByteOffset: number + fileSize: number + fileIdentity: string + hasMore: boolean + reset: boolean +} + +export type LocalLogTailWatchArgs = { + filePath: string + subscriptionId: string +} + +export type LocalLogTailChangedPayload = { + subscriptionId: string + eventType: 'change' | 'rename' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e40184e32cb..6079fa4fe9b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1030,6 +1030,8 @@ export type PersistedOpenFile = { /** Why: a read-only tab (AI Vault View Log) must survive restart still * read-only; persisted only when true so old sessions stay writable. */ readOnly?: boolean + /** Opt-in streaming append for a read-only local log tab. */ + liveTail?: boolean } export type WorkspaceSessionState = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 4f093649da4..ab5d354e017 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -163,7 +163,8 @@ const persistedOpenFileSchema = z.object({ runtimeEnvironmentId: z.string().nullable().optional(), dirtyDraftContent: z.string().optional(), lastKnownDiskSignature: z.string().optional(), - readOnly: z.boolean().optional() + readOnly: z.boolean().optional(), + liveTail: z.boolean().optional() }) // ─── Browser ────────────────────────────────────────────────────────