Merge remote-tracking branch 'origin/master' into feat/read-image-context
# Conflicts: # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # packages/support/llm-replay/src/index.ts # packages/support/llm-replay/tests/llm-replay.spec.ts
This commit is contained in:
@@ -15,6 +15,8 @@ import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
// Bound one non-abortable FileHandle.read so cancellation is observed between chunks.
|
||||
const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
@@ -74,9 +76,8 @@ function versionOf(info: BigIntStats): FsVersion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hook: lets specs pin the atomic-write temp names (to prove
|
||||
* exclusive-open behavior without a name race) and observe the staged temp
|
||||
* file before it is renamed over the target.
|
||||
* Test hook: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without
|
||||
* a name race), override native boundaries, and observe the staged temp file before publication.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override the host platform for native-publication unit coverage. */
|
||||
@@ -680,21 +681,67 @@ export async function readForEdit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still
|
||||
* succeeds and presentation falls back to a whole-file diff.
|
||||
* @param absolutePath - the file to read (typically a target key); it must exist.
|
||||
* @param signal - aborts the read (`FS_ABORTED`).
|
||||
* @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
|
||||
* Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
|
||||
* or a file deleted/made unreadable after the caller's preflight returns `null` so the write
|
||||
* still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
|
||||
* the opened descriptor rather than a prior path stat, so concurrent external replacement or
|
||||
* size changes cannot make this helper buffer more than `maxBytes`.
|
||||
* @param absolutePath - the file to read (typically a target key).
|
||||
* @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
|
||||
* @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
|
||||
* @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
|
||||
* descriptor-size-changed, or unreadable file.
|
||||
*/
|
||||
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
|
||||
const buffer = await readFileAbortable(absolutePath, 'read', signal)
|
||||
if (buffer.includes(0)) return null
|
||||
export async function readTextForDiff(
|
||||
absolutePath: string,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
throwIfAborted(signal, 'read')
|
||||
try {
|
||||
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer))
|
||||
const handle = await open(absolutePath, 'r')
|
||||
let buffer: Buffer
|
||||
let total = 0
|
||||
let openedSize = 0
|
||||
try {
|
||||
throwIfAborted(signal, 'read')
|
||||
const info = await handle.stat()
|
||||
throwIfAborted(signal, 'read')
|
||||
if (!info.isFile()) return null
|
||||
if (info.size >= maxBytes) return null
|
||||
openedSize = info.size
|
||||
// One extra byte detects growth after stat without retaining per-read backing buffers.
|
||||
buffer = Buffer.allocUnsafe(openedSize + 1)
|
||||
while (total < buffer.length) {
|
||||
throwIfAborted(signal, 'read')
|
||||
const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES)
|
||||
const { bytesRead } = await handle.read(buffer, total, length, null)
|
||||
if (bytesRead === 0) break
|
||||
total += bytesRead
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
throwIfAborted(signal, 'read')
|
||||
if (total !== openedSize) return null
|
||||
const basis = buffer.subarray(0, total)
|
||||
if (basis.includes(0)) return null
|
||||
try {
|
||||
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis))
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes;
|
||||
* any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
return null
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
return null
|
||||
// Cancellation is the caller's intent and still propagates.
|
||||
if (error instanceof FsError) throw error
|
||||
// A descriptor-phase errno — deleted or made unreadable after the caller's
|
||||
// preflight, or a faulted read — costs only the optional basis: a committed
|
||||
// write must not fail for a presentation-only pre-read.
|
||||
if (error instanceof Error && 'code' in error) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { constants as bufferConstants } from 'node:buffer'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import z from 'schemastery'
|
||||
@@ -40,9 +41,19 @@ import type { FsIoInternals } from './fsio.ts'
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
|
||||
* runtime's safe allocation/decode maximum. Defaults to 10 MiB.
|
||||
*/
|
||||
diffBasisMaxBytes?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
|
||||
const MAX_DIFF_BASIS_BYTES = Math.min(
|
||||
bufferConstants.MAX_LENGTH,
|
||||
bufferConstants.MAX_STRING_LENGTH,
|
||||
)
|
||||
|
||||
/**
|
||||
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
||||
@@ -53,11 +64,12 @@ type ResolvedConfig = Required<Config>
|
||||
export class LocalFileSystem extends FileSystem {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string().default(process.cwd()),
|
||||
diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
/** Test hook forwarded to fsio (force streaming path, pin temp names). */
|
||||
/** Test hook forwarded to fsio for atomic-publication boundaries. */
|
||||
internals: FsIoInternals = {}
|
||||
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
||||
* window can't interleave, making concurrent writes/edits deterministically
|
||||
@@ -66,7 +78,13 @@ export class LocalFileSystem extends FileSystem {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = config as ResolvedConfig
|
||||
const resolved = config as ResolvedConfig
|
||||
if (!Number.isSafeInteger(resolved.diffBasisMaxBytes)
|
||||
|| resolved.diffBasisMaxBytes <= 0
|
||||
|| resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) {
|
||||
throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`)
|
||||
}
|
||||
this.config = resolved
|
||||
}
|
||||
|
||||
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
||||
@@ -169,9 +187,16 @@ export class LocalFileSystem extends FileSystem {
|
||||
}
|
||||
// No expectation means an unconditional but still atomic write.
|
||||
|
||||
// Preserve prior text for contextual diffs; null falls back to a whole-file diff.
|
||||
// TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
|
||||
const before = existing ? await readTextForDiff(target.targetKey, signal) : null
|
||||
// Capture an optional contextual-diff basis before the write. The bounded
|
||||
// reader checks the opened file itself, so an external replacement after
|
||||
// `probe()` cannot turn this best-effort presentation read into an
|
||||
// unbounded allocation. Either side at/above the configured limit yields
|
||||
// `before: null`; consumers retain their whole-file fallback.
|
||||
const diffable = existing !== null
|
||||
&& Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes
|
||||
const before = diffable
|
||||
? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal)
|
||||
: null
|
||||
await writeFileAtomic(
|
||||
target.targetKey,
|
||||
content,
|
||||
|
||||
Reference in New Issue
Block a user