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.
This commit is contained in:
Jinjing
2026-07-10 20:21:47 -07:00
parent bf803561ff
commit c7fdef7b11
26 changed files with 1239 additions and 15 deletions
+99
View File
@@ -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.
@@ -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<string> {
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)
})
})
@@ -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<LocalLogTailReadResult> {
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()
}
}
+29
View File
@@ -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 })
+47 -2
View File
@@ -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<string, string> = {
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)
}
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, watchMock, resolveAuthorizedPathMock, readRangeMock } = vi.hoisted(() => ({
handlers: new Map<string, (...args: any[]) => 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<typeof vi.fn>
on: ReturnType<typeof vi.fn>
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)
})
})
+109
View File
@@ -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<string, TailWatch>()
const senderCleanupRegistered = new Set<number>()
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<LocalLogTailReadResult> => {
const filePath = await resolveAuthorizedPath(args.filePath, store)
return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity)
}
)
ipcMain.handle(
'fs:startLocalLogTail',
async (event, args: LocalLogTailWatchArgs): Promise<void> => {
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
}
+18 -1
View File
@@ -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<LocalLogTailReadResult>
startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise<void>
stopLocalLogTail: (args: { subscriptionId: string }) => Promise<void>
onLocalLogTailChanged: (callback: (payload: LocalLogTailChangedPayload) => void) => () => void
downloadFile: (args: {
filePath: string
connectionId: string
+30 -2
View File
@@ -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<LocalLogTailReadResult> =>
ipcRenderer.invoke('fs:readLocalLogTail', args),
startLocalLogTail: (args: LocalLogTailWatchArgs): Promise<void> =>
ipcRenderer.invoke('fs:startLocalLogTail', args),
stopLocalLogTail: (args: { subscriptionId: string }): Promise<void> =>
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
@@ -23,6 +23,7 @@ export type FileContent = {
isBinary: boolean
isImage?: boolean
mimeType?: string
fileIdentity?: string
loadError?: string
}
@@ -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> = {}
): 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' })
})
})
@@ -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 }
}
}
@@ -30,6 +30,7 @@ import {
usePruneClosedEditorContent
} from './useEditorPanelExternalContentEvents'
import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry'
import { useLocalLogTail } from './useLocalLogTail'
const inFlightFileReads = new Map<string, Promise<FileContent>>()
const inFlightDiffReads = new Map<string, Promise<DiffContent>>()
@@ -172,7 +173,9 @@ export function useEditorPanelContentState({
filePath,
relativePath: restoredOpenFile?.relativePath ?? relativePath,
worktreeId,
connectionId
connectionId,
includeLocalLogMetadata:
restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true
}) as Promise<FileContent>
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 ?? []
@@ -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<typeof vi.fn>
let stopMock: ReturnType<typeof vi.fn>
let readMock: ReturnType<typeof vi.fn>
let reloadMock: Mock<(file: OpenFile) => void>
let warnMock: ReturnType<typeof vi.spyOn>
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<Record<string, FileContent>>({
[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<void>((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)
})
})
@@ -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<void>
}
type UseLocalLogTailParams = {
openFiles: OpenFile[]
fileContents: Record<string, FileContent>
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>
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<string, TailSession>())
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<void> => {
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<string>()
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()
},
[]
)
}
@@ -73,7 +73,8 @@ describe('openAiVaultSessionLogInOrca', () => {
runtimeEnvironmentId: null,
language: 'jsonl',
mode: 'edit',
readOnly: true
readOnly: true,
liveTail: true
})
expect(options).toEqual({
preview: false,
@@ -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,
@@ -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) })
@@ -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
@@ -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<RuntimeReadableFileContent> {
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')
+4 -1
View File
@@ -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()
+4
View File
@@ -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<AppState, [], [], EditorSlice> = (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
+8
View File
@@ -1478,6 +1478,14 @@ function createFileApi(): NonNullable<Partial<PreloadApi>['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.')
},
+26
View File
@@ -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'
}
+2
View File
@@ -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 = {
+2 -1
View File
@@ -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 ────────────────────────────────────────────────────────