fix: preserve long run activity and large previews
Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches. Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior. Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
isPathInside,
|
||||
listBoundedDirectoryEntries,
|
||||
readBoundedUtf8File,
|
||||
readUtf8FileRange,
|
||||
resolveExistingWorkspacePath
|
||||
} from '../workspace-file-access'
|
||||
import type {
|
||||
@@ -562,6 +563,30 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
'工作区读取上限无效'
|
||||
)
|
||||
const file = await this.resolveExisting(input.path, 'file')
|
||||
const offsetBytes = input.offsetBytes ?? 0
|
||||
if (input.allowTruncated || offsetBytes > 0) {
|
||||
const preview = await readUtf8FileRange(
|
||||
file.canonicalPath,
|
||||
offsetBytes,
|
||||
maximumBytes,
|
||||
input.invalidUtf8Message ??
|
||||
'工作区读取目标不是有效 UTF-8 文本'
|
||||
)
|
||||
if (preview.truncated && !input.allowTruncated) {
|
||||
throw new Error(
|
||||
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
|
||||
)
|
||||
}
|
||||
if (preview.truncated && preview.bytesRead === 0) {
|
||||
throw new Error('工作区读取分页无法继续')
|
||||
}
|
||||
input.signal?.throwIfAborted()
|
||||
return {
|
||||
path: file.path,
|
||||
name: basename(file.canonicalPath),
|
||||
...preview
|
||||
}
|
||||
}
|
||||
const preview = await readBoundedUtf8File(
|
||||
file.canonicalPath,
|
||||
maximumBytes,
|
||||
@@ -573,7 +598,10 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
path: file.path,
|
||||
name: basename(file.canonicalPath),
|
||||
content: preview.content,
|
||||
size: preview.size
|
||||
size: preview.size,
|
||||
offsetBytes: 0,
|
||||
bytesRead: preview.size,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,10 @@ describe('RemoteWorkspaceAccess', () => {
|
||||
path: 'README.md',
|
||||
name: 'README.md',
|
||||
content: 'hello',
|
||||
size: 5
|
||||
size: 5,
|
||||
offsetBytes: 0,
|
||||
bytesRead: 5,
|
||||
truncated: false
|
||||
})
|
||||
await expect(
|
||||
access.search({
|
||||
@@ -365,6 +368,70 @@ describe('RemoteWorkspaceAccess', () => {
|
||||
expect(transport.acquireLease).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns remote file pages and keeps UTF-8 boundaries intact', async () => {
|
||||
const invalidBoundary = Object.assign(
|
||||
new Error('Workspace file is not valid UTF-8'),
|
||||
{ data: { code: 'invalid-utf8' } }
|
||||
)
|
||||
const readWorkspaceText = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(invalidBoundary)
|
||||
.mockResolvedValueOnce({
|
||||
relativePath: 'large.txt',
|
||||
content: '你a',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 4,
|
||||
totalBytes: 7,
|
||||
digest,
|
||||
truncated: true
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
relativePath: 'large.txt',
|
||||
content: '好',
|
||||
offsetBytes: 4,
|
||||
bytesRead: 3,
|
||||
totalBytes: 7,
|
||||
digest,
|
||||
truncated: false
|
||||
})
|
||||
const lease = createLease({ readWorkspaceText })
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
const first = await access.readText({
|
||||
path: 'large.txt',
|
||||
maximumBytes: 5,
|
||||
allowTruncated: true
|
||||
})
|
||||
const second = await access.readText({
|
||||
path: 'large.txt',
|
||||
offsetBytes: first.offsetBytes + first.bytesRead,
|
||||
maximumBytes: 5,
|
||||
allowTruncated: true
|
||||
})
|
||||
|
||||
expect(first).toMatchObject({
|
||||
content: '你a',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 4,
|
||||
truncated: true
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
content: '好',
|
||||
offsetBytes: 4,
|
||||
truncated: false
|
||||
})
|
||||
expect(
|
||||
readWorkspaceText.mock.calls.map(([request]) => ({
|
||||
offsetBytes: request.offsetBytes,
|
||||
maximumBytes: request.maximumBytes
|
||||
}))
|
||||
).toEqual([
|
||||
{ offsetBytes: 0, maximumBytes: 5 },
|
||||
{ offsetBytes: 0, maximumBytes: 4 },
|
||||
{ offsetBytes: 4, maximumBytes: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it('retries one inconsistent Git snapshot and maps status and diff', async () => {
|
||||
const getGitStatus = vi
|
||||
.fn()
|
||||
|
||||
@@ -149,6 +149,20 @@ function boundedInteger(
|
||||
return resolved
|
||||
}
|
||||
|
||||
function isInvalidUtf8RpcError(error: unknown): boolean {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('data' in error) ||
|
||||
!error.data ||
|
||||
typeof error.data !== 'object' ||
|
||||
!('code' in error.data)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return error.data.code === 'invalid-utf8'
|
||||
}
|
||||
|
||||
function assertTransportBinding(
|
||||
expected: RemoteWorkspaceProjectBinding,
|
||||
actual: RemoteWorkspaceTransportBinding
|
||||
@@ -419,16 +433,43 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
'工作区读取上限无效'
|
||||
)
|
||||
const opened = await this.open(input.signal)
|
||||
const request = remoteWorkspaceReadTextRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path,
|
||||
offsetBytes: 0,
|
||||
maximumBytes
|
||||
})
|
||||
const rawResult = await opened.lease.readWorkspaceText(
|
||||
request,
|
||||
input.signal
|
||||
)
|
||||
const offsetBytes = input.offsetBytes ?? 0
|
||||
let rawResult: RemoteWorkspaceReadTextResult | undefined
|
||||
let boundaryError: unknown
|
||||
const maximumTrailingBytes = input.allowTruncated
|
||||
? Math.min(3, maximumBytes - 1)
|
||||
: 0
|
||||
for (
|
||||
let trailingBytes = 0;
|
||||
trailingBytes <= maximumTrailingBytes;
|
||||
trailingBytes += 1
|
||||
) {
|
||||
const request = remoteWorkspaceReadTextRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path,
|
||||
offsetBytes,
|
||||
maximumBytes: maximumBytes - trailingBytes
|
||||
})
|
||||
try {
|
||||
rawResult = await opened.lease.readWorkspaceText(
|
||||
request,
|
||||
input.signal
|
||||
)
|
||||
break
|
||||
} catch (error) {
|
||||
if (
|
||||
!input.allowTruncated ||
|
||||
trailingBytes === maximumTrailingBytes ||
|
||||
!isInvalidUtf8RpcError(error)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
boundaryError = error
|
||||
}
|
||||
}
|
||||
if (!rawResult) {
|
||||
throw boundaryError
|
||||
}
|
||||
let result: RemoteWorkspaceReadTextResult
|
||||
try {
|
||||
result = remoteWorkspaceReadTextResultSchema.parse(rawResult)
|
||||
@@ -444,21 +485,32 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
}
|
||||
if (
|
||||
result.relativePath !== input.path ||
|
||||
result.offsetBytes !== 0
|
||||
result.offsetBytes !== offsetBytes ||
|
||||
result.offsetBytes + result.bytesRead > result.totalBytes ||
|
||||
(!result.truncated &&
|
||||
result.offsetBytes + result.bytesRead !== result.totalBytes) ||
|
||||
(result.truncated &&
|
||||
result.offsetBytes + result.bytesRead >= result.totalBytes)
|
||||
) {
|
||||
throw new Error('远程工作区返回了不匹配的文件')
|
||||
}
|
||||
if (result.truncated || result.bytesRead !== result.totalBytes) {
|
||||
if (result.truncated && !input.allowTruncated) {
|
||||
throw new Error(
|
||||
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
|
||||
)
|
||||
}
|
||||
if (result.truncated && result.bytesRead === 0) {
|
||||
throw new Error('远程工作区读取分页无法继续')
|
||||
}
|
||||
const name = input.path.split('/').at(-1) ?? input.path
|
||||
return {
|
||||
path: input.path,
|
||||
name,
|
||||
content: result.content,
|
||||
size: result.totalBytes
|
||||
size: result.totalBytes,
|
||||
offsetBytes: result.offsetBytes,
|
||||
bytesRead: result.bytesRead,
|
||||
truncated: result.truncated
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ export type WorkspaceEntry = WorkspaceDirectoryEntry & {
|
||||
}
|
||||
|
||||
export type WorkspaceReadInput = WorkspacePathInput & {
|
||||
offsetBytes?: number
|
||||
maximumBytes?: number
|
||||
allowTruncated?: boolean
|
||||
tooLargeMessage?: string
|
||||
invalidUtf8Message?: string
|
||||
}
|
||||
@@ -49,6 +51,9 @@ export type FilePreview = {
|
||||
name: string
|
||||
content: string
|
||||
size: number
|
||||
offsetBytes: number
|
||||
bytesRead: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceWriteInput = WorkspacePathInput & {
|
||||
|
||||
Reference in New Issue
Block a user