mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 00:02:24 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user