fix: bulk windows apply-patch updates

This commit is contained in:
Codex
2026-06-07 00:28:38 +00:00
parent b81585bcab
commit d4b7fc95f9
5 changed files with 173 additions and 13 deletions
+40 -9
View File
@@ -63,6 +63,8 @@ export interface ApplyPatchV2FileSystem {
readBlock(path: string, blockIndex: number, blockBytes: number): Promise<Buffer>;
writeFile(path: string, content: Buffer): Promise<void>;
deleteFile(path: string): Promise<void>;
readFiles?(paths: string[]): Promise<Map<string, string>>;
applyReplacementsBulk?(paths: Iterable<string>, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>): Promise<void>;
}
export interface ApplyPatchV2FileStat {
@@ -114,15 +116,17 @@ export class ApplyPatchV2Error extends Error {
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
type Replacement = [start: number, oldLength: number, newLines: string[]];
interface BulkReplacementWritePlan {
export type ApplyPatchV2Replacement = [start: number, oldLength: number, newLines: string[]];
type Replacement = ApplyPatchV2Replacement;
export interface ApplyPatchV2BulkReplacementWritePlan {
path: string;
originalBytes: number;
originalSha256: string;
finalBytes: number;
finalSha256: string;
replacements: Replacement[];
replacements: ApplyPatchV2Replacement[];
}
type BulkReplacementWritePlan = ApplyPatchV2BulkReplacementWritePlan;
interface ApplyPatchV2Plan {
changed: string[];
@@ -432,12 +436,19 @@ function instrumentApplyPatchV2Executor(executor: ApplyPatchV2Executor, metrics:
}
function instrumentApplyPatchV2FileSystem(fs: ApplyPatchV2FileSystem, metrics: ApplyPatchV2RemoteMetrics): ApplyPatchV2FileSystem {
return {
const instrumented: ApplyPatchV2FileSystem = {
stat: (path) => recordApplyPatchV2FsOperation(metrics, "fs.stat", () => fs.stat(path)),
readBlock: (path, blockIndex, blockBytes) => recordApplyPatchV2FsOperation(metrics, "fs.readBlock", () => fs.readBlock(path, blockIndex, blockBytes)),
writeFile: (path, content) => recordApplyPatchV2FsOperation(metrics, "fs.writeFile", () => fs.writeFile(path, content)),
deleteFile: (path) => recordApplyPatchV2FsOperation(metrics, "fs.deleteFile", () => fs.deleteFile(path)),
};
if (fs.readFiles !== undefined) {
instrumented.readFiles = (paths) => recordApplyPatchV2FsOperation(metrics, "fs.readFiles", () => fs.readFiles!(paths));
}
if (fs.applyReplacementsBulk !== undefined) {
instrumented.applyReplacementsBulk = (paths, plans) => recordApplyPatchV2FsOperation(metrics, "fs.applyReplacementsBulk", () => fs.applyReplacementsBulk!(paths, plans));
}
return instrumented;
}
async function recordApplyPatchV2FsOperation<T>(metrics: ApplyPatchV2RemoteMetrics, operation: string, run: () => Promise<T>): Promise<T> {
@@ -621,7 +632,11 @@ function isBulkReadFailure(error: unknown): boolean {
}
function shouldUseBulkUpdatePath(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): boolean {
if (!executor.run || executor.fs !== undefined) return false;
if (executor.fs !== undefined) {
if (executor.fs.readFiles === undefined || executor.fs.applyReplacementsBulk === undefined) return false;
} else if (!executor.run) {
return false;
}
const paths = new Set<string>();
for (const hunk of hunks) {
if (hunk.kind !== "update" || hunk.movePath !== null) return false;
@@ -926,13 +941,20 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P
async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: string[]): Promise<Map<string, string>> {
if (targets.length === 0) return new Map();
if (executor.fs?.readFiles !== undefined && targets.length > 1) {
const files = await executor.fs.readFiles(targets);
for (const target of targets) {
if (!files.has(target)) throw new ApplyPatchV2Error("remote apply-patch v2 fs bulk read omitted target", { target });
}
return files;
}
if (executor.fs || targets.length === 1) {
const files = new Map<string, string>();
for (const target of targets) files.set(target, await readRemoteText(executor, target));
return files;
}
const result = await checkedRemoteV2(executor, "read-bulk-b64", targets);
return decodeRemoteBulkRead(result.stdout, targets);
return decodeApplyPatchV2BulkRead(result.stdout, targets);
}
function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: number, expectedChunkBytes: number): Buffer {
@@ -979,7 +1001,7 @@ function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: numbe
return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded;
}
function decodeRemoteBulkRead(stdout: string, targets: string[]): Map<string, string> {
export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): Map<string, string> {
const lines = stdout.split(/\r?\n/u);
const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteBulkReadMarker} `));
if (markerIndex < 0) {
@@ -1050,8 +1072,17 @@ async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, c
}
async function writeRemoteReplacementsBulk(executor: ApplyPatchV2Executor, paths: Iterable<string>, plans: Map<string, BulkReplacementWritePlan>): Promise<void> {
const targets = Array.from(paths);
const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(paths, plans);
if (targets.length === 0) return;
if (executor.fs?.applyReplacementsBulk !== undefined) {
await executor.fs.applyReplacementsBulk(targets, plans);
return;
}
await checkedRemoteV2(executor, "apply-replacements-bulk-stdin", [String(targets.length)], payload);
}
export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable<string>, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>): { targets: string[]; payload: string } {
const targets = Array.from(paths);
const records: string[] = [];
for (const target of targets) {
const plan = plans.get(target);
@@ -1073,7 +1104,7 @@ async function writeRemoteReplacementsBulk(executor: ApplyPatchV2Executor, paths
replacementFields.join(";"),
].join(" "));
}
await checkedRemoteV2(executor, "apply-replacements-bulk-stdin", [String(targets.length)], `${records.join("\n")}\n`);
return { targets, payload: `${records.join("\n")}\n` };
}
type RemoteV2Operation =
+1 -1
View File
@@ -202,7 +202,7 @@ export function sshHelp(): unknown {
"When a one-line shell command is easier to type through the script path, `script -- '<command && command>'` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `trans D601:/work script -- sed -n '1,20p' file`.",
"script and shell helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.",
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Plain multi-file Update File patches on POSIX host/k3s routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.",
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Plain multi-file Update File patches on POSIX host/k3s and Windows workspace routes use bulk read/write operations to avoid per-file SSH round trips. Its stdout follows Codex apply_patch text output rather than UniDesk JSON output; stderr keeps Codex-style failure text and appends one `UNIDESK_APPLY_PATCH_TIMING` JSON summary with durationMs, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount, remoteOperationCounts and remoteElapsedMs so slow patch runs can be attributed without changing success stdout.",
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and return `verification.automatic=true`, `verification.verified=true`, and `verification.match.{bytes,sha256}=true`; this JSON is the transfer integrity proof, so callers do not need a separate manual `sha256sum` check. The client falls back from a single stdin payload to bounded chunks before treating provider-gateway limits as a server-side problem.",
"`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
"script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; it is for host/k3s POSIX shell only. Use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
+25 -1
View File
@@ -1,7 +1,15 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { type UniDeskConfig, repoRoot } from "./config";
import { isApplyPatchV2HelpArgs, runApplyPatchV2, type ApplyPatchV2Executor, type ApplyPatchV2FileSystem } from "./apply-patch-v2";
import {
decodeApplyPatchV2BulkRead,
formatApplyPatchV2BulkReplacementPayload,
isApplyPatchV2HelpArgs,
runApplyPatchV2,
type ApplyPatchV2BulkReplacementWritePlan,
type ApplyPatchV2Executor,
type ApplyPatchV2FileSystem,
} from "./apply-patch-v2";
import { isSshFileTransferOperation, runSshFileTransferOperation, type SshRemoteCommandExecutor } from "./ssh-file-transfer";
export interface ParsedSshArgs {
@@ -2409,6 +2417,8 @@ export function remoteCommandForRoute(route: ParsedSshRoute, command: string[],
type WindowsApplyPatchFsOperation =
| "stat"
| "read-b64-block"
| "read-bulk-b64"
| "apply-replacements-bulk-stdin"
| "write-b64-stdin"
| "write-b64-begin"
| "write-b64-append-stdin"
@@ -2465,6 +2475,15 @@ function createWindowsApplyPatchFileSystem(config: UniDeskConfig, invocation: Pa
async deleteFile(filePath) {
await checked("delete", [filePath]);
},
async readFiles(filePaths) {
const result = await checked("read-bulk-b64", [String(filePaths.length)], `${filePaths.join("\n")}\n`);
return decodeApplyPatchV2BulkRead(result.stdout, filePaths);
},
async applyReplacementsBulk(filePaths, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>) {
const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(filePaths, plans);
if (targets.length === 0) return;
await checked("apply-replacements-bulk-stdin", [String(targets.length)], payload);
},
};
}
@@ -2489,6 +2508,9 @@ function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsAp
"function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw)) { Fail 'empty apply-patch path' 2 }; if ([System.IO.Path]::IsPathRooted($Raw)) { return [System.IO.Path]::GetFullPath($Raw) }; if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($basePath, $Raw)) }; return [System.IO.Path]::GetFullPath($Raw) }",
"function Ensure-Parent([string]$Target) { $parent = [System.IO.Path]::GetDirectoryName($Target); if (-not [string]::IsNullOrWhiteSpace($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null } }",
"function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }",
"function Get-Sha256Bytes([byte[]]$Bytes) { $sha = [System.Security.Cryptography.SHA256]::Create(); try { return ([System.BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } }",
"function Decode-Utf8([string]$Encoded) { return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Encoded)) }",
"function Encode-Utf8([string]$Value) { return [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) }",
"function Set-TmpPaths([string]$Target, [string]$Token) { if ($Token -notmatch '^[A-Za-z0-9_.-]+$') { Fail 'invalid apply-patch temp token' 2 }; $dir = [System.IO.Path]::GetDirectoryName($Target); if ([string]::IsNullOrWhiteSpace($dir)) { $dir = (Get-Location).ProviderPath }; $base = [System.IO.Path]::GetFileName($Target); $script:tmp = [System.IO.Path]::Combine($dir, '.' + $base + '.unidesk-v2-' + $Token + '.tmp'); $script:tmpB64 = $script:tmp + '.b64' }",
"function Verify-Temp([string]$Target, [string]$Tmp, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { $actualBytes = ([System.IO.FileInfo]$Tmp).Length; if ($actualBytes -ne $ExpectedBytes) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('apply-patch byte count mismatch for ' + $Target + ': expected=' + $ExpectedBytes + ' actual=' + $actualBytes) 23 }; $actualSha256 = Get-Sha256 $Tmp; if ($actualSha256 -ne $ExpectedSha256) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('apply-patch sha256 mismatch for ' + $Target + ': expected=' + $ExpectedSha256 + ' actual=' + $actualSha256) 24 } }",
"function Decode-ToTarget([string]$Target, [string]$Encoded, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { Ensure-Parent $Target; Set-TmpPaths $Target ([guid]::NewGuid().ToString('N')); try { $bytes = [Convert]::FromBase64String(($Encoded -replace '\\s','')) } catch { Fail ('apply-patch base64 decode failed for ' + $Target + ': ' + $_.Exception.Message) 22 }; [System.IO.File]::WriteAllBytes($script:tmp, $bytes); Verify-Temp $Target $script:tmp $ExpectedBytes $ExpectedSha256; Move-Item -LiteralPath $script:tmp -Destination $Target -Force; $actualSha256 = Get-Sha256 $Target; if ($actualSha256 -ne $ExpectedSha256) { Fail ('apply-patch final sha256 mismatch for ' + $Target) 25 } }",
@@ -2496,6 +2518,8 @@ function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsAp
"switch ($operation) {",
" 'stat' { if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { Fail ('file not found: ' + $target) 1 }; $bytes = ([System.IO.FileInfo]$target).Length; $digest = Get-Sha256 $target; [Console]::Out.WriteLine(([string]$bytes) + ' ' + $digest); break }",
" 'read-b64-block' { $blockIndex = [Int64]$arg1; $blockSize = [Int32]$arg2; if ($blockIndex -lt 0 -or $blockSize -le 0) { Fail 'invalid read block args' 2 }; $fs = [System.IO.File]::Open($target, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite); try { [void]$fs.Seek($blockIndex * $blockSize, [System.IO.SeekOrigin]::Begin); $buffer = New-Object byte[] $blockSize; $read = $fs.Read($buffer, 0, $blockSize); if ($read -gt 0) { [Console]::Out.Write([Convert]::ToBase64String($buffer, 0, $read)) } } finally { $fs.Dispose() }; break }",
" 'read-bulk-b64' { $expectedCount = [Int32]$targetArg; $items = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($items.Count -ne $expectedCount) { Fail ('bulk read record count mismatch: expected=' + $expectedCount + ' actual=' + $items.Count) 23 }; [Console]::Out.WriteLine('UNIDESK_APPLY_PATCH_V2_BULK_READ ' + $items.Count); foreach ($item in $items) { $path = Resolve-UnideskPath $item; if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail ('file not found: ' + $path) 1 }; $bytes = [System.IO.File]::ReadAllBytes($path); $pathB64 = Encode-Utf8 $item; $digest = Get-Sha256Bytes $bytes; [Console]::Out.Write($pathB64 + ' ' + $bytes.Length + ' ' + $digest + ' '); [Console]::Out.Write([System.Convert]::ToBase64String($bytes)); [Console]::Out.WriteLine() }; break }",
" 'apply-replacements-bulk-stdin' { $expectedCount = [Int32]$targetArg; $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }); if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 }; $utf8 = [System.Text.UTF8Encoding]::new($false); $plans = New-Object System.Collections.Generic.List[object]; $index = 0; foreach ($record in $records) { $index += 1; $fields = $record -split '\\s+'; if ($fields.Count -ne 6) { Fail 'bulk replacement malformed record' 23 }; $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative; $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $replacementsText = $fields[5]; if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 }; $source = [System.IO.File]::ReadAllBytes($resolved); if ($source.Length -ne $originalBytes -or (Get-Sha256Bytes $source) -ne $originalSha256) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 }; $lines = New-Object System.Collections.Generic.List[string]; $sourceText = [System.Text.Encoding]::UTF8.GetString($source); foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) }; $replacements = New-Object System.Collections.Generic.List[object]; if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } }; foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; $removeCount = $replacement.oldLength; if ($removeCount -gt 0) { $lines.RemoveRange($replacement.start, $removeCount) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } }; $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") }; $outputBytes = [System.Text.Encoding]::UTF8.GetBytes($outputText); if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 }; $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllText($tmp, $outputText, $utf8); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null }; foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } }; break }",
" 'write-b64-stdin' { Decode-ToTarget $target ([Console]::In.ReadToEnd()) ([Int64]$arg1) $arg2; break }",
" 'write-b64-begin' { Ensure-Parent $target; Set-TmpPaths $target $arg1; [System.IO.File]::WriteAllText($script:tmpB64, '', [System.Text.Encoding]::ASCII); break }",
" 'write-b64-append-stdin' { Set-TmpPaths $target $arg1; $chunk = ([Console]::In.ReadToEnd()) -replace '\\s',''; [System.IO.File]::AppendAllText($script:tmpB64, $chunk, [System.Text.Encoding]::ASCII); break }",
+106 -1
View File
@@ -5,7 +5,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr
import os from "node:os";
import path from "node:path";
import { sshHelp } from "./src/help";
import { runApplyPatchV2, type ApplyPatchV2TimingSummary } from "./src/apply-patch-v2";
import { runApplyPatchV2, type ApplyPatchV2TimingSummary, type ApplyPatchV2BulkReplacementWritePlan } from "./src/apply-patch-v2";
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote";
import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./src/ssh-file-transfer";
@@ -341,6 +341,95 @@ async function applyPatchV2Fixture(patch: string, files: Record<string, string>)
return { stdout: result.stdout, files: result.files, commands: result.commands };
}
async function applyPatchV2FsBulkFixtureAttempt(patch: string, files: Record<string, string>): Promise<{ stdout: string; stderr: string; exitCode: number | null; files: Record<string, string>; operations: string[]; error: unknown | null }> {
const state = new Map(Object.entries(files));
const operations: string[] = [];
const stdin = new PassThrough();
stdin.end(patch);
let stdout = "";
let stderr = "";
const stdoutSink = new Writable({
write(chunk, _encoding, callback) {
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
callback();
},
});
const stderrSink = new Writable({
write(chunk, _encoding, callback) {
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
callback();
},
});
let error: unknown | null = null;
let exitCode: number | null = null;
try {
exitCode = await runApplyPatchV2({
stdin,
stdout: stdoutSink,
stderr: stderrSink,
executor: {
fs: {
async stat(filePath) {
operations.push(`stat ${filePath}`);
const content = state.get(filePath);
if (content === undefined) throw new Error(`missing ${filePath}`);
const buffer = Buffer.from(content, "utf8");
return { bytes: buffer.length, sha256: sha256BufferHex(buffer) };
},
async readBlock(filePath, blockIndex, blockBytes) {
operations.push(`readBlock ${filePath}`);
const content = state.get(filePath);
if (content === undefined) throw new Error(`missing ${filePath}`);
const buffer = Buffer.from(content, "utf8");
return buffer.subarray(blockIndex * blockBytes, (blockIndex + 1) * blockBytes);
},
async writeFile(filePath, content) {
operations.push(`writeFile ${filePath}`);
state.set(filePath, content.toString("utf8"));
},
async deleteFile(filePath) {
operations.push(`deleteFile ${filePath}`);
state.delete(filePath);
},
async readFiles(paths) {
operations.push(`readFiles ${paths.join(",")}`);
const result = new Map<string, string>();
for (const filePath of paths) {
const content = state.get(filePath);
if (content === undefined) throw new Error(`missing ${filePath}`);
result.set(filePath, content);
}
return result;
},
async applyReplacementsBulk(paths: Iterable<string>, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>) {
const targets = Array.from(paths);
operations.push(`applyReplacementsBulk ${targets.join(",")}`);
for (const filePath of targets) {
const plan = plans.get(filePath);
const original = state.get(filePath);
if (plan === undefined || original === undefined) throw new Error(`missing replacement plan ${filePath}`);
const originalBuffer = Buffer.from(original, "utf8");
assertCondition(originalBuffer.length === plan.originalBytes && sha256BufferHex(originalBuffer) === plan.originalSha256, "fs bulk fixture original integrity mismatch", { filePath, plan });
const lines = original.split("\n");
if (lines.at(-1) === "") lines.pop();
for (const [start, oldLength, newLines] of [...plan.replacements].reverse()) {
lines.splice(start, oldLength, ...newLines);
}
const updated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
const updatedBuffer = Buffer.from(updated, "utf8");
assertCondition(updatedBuffer.length === plan.finalBytes && sha256BufferHex(updatedBuffer) === plan.finalSha256, "fs bulk fixture final integrity mismatch", { filePath, plan });
state.set(filePath, updated);
}
},
},
},
});
} catch (caught) {
error = caught;
}
return { stdout, stderr, exitCode, files: Object.fromEntries(state), operations, error };
}
function fileTransferFixture(initial: Record<string, Buffer> = {}, options: { emptyReadOnce?: Record<string, number[]>; shortReadOnce?: Record<string, Record<string, number>> } = {}): {
state: Map<string, Buffer>;
commands: Array<{ operation: string; stdin: boolean }>;
@@ -865,6 +954,22 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
const bulkTiming = applyPatchTimingFromStderr(bulkV2.stderr);
assertCondition(bulkTiming.remoteOperationCount === 2 && bulkTiming.remoteOperationCounts["read-bulk-b64"] === 1 && bulkTiming.remoteOperationCounts["apply-replacements-bulk-stdin"] === 1, "v2 timing summary should classify bulk read and line-level apply operations", bulkTiming);
const fsBulkV2 = await applyPatchV2FsBulkFixtureAttempt(bulkPatchLines.join("\n"), bulkFiles);
assertCondition(fsBulkV2.exitCode === 0 && fsBulkV2.error === null, "v2 fs executor should support the same multi-file bulk update path", fsBulkV2);
assertCondition(
fsBulkV2.operations.length === 2
&& fsBulkV2.operations[0]?.startsWith("readFiles ")
&& fsBulkV2.operations[1]?.startsWith("applyReplacementsBulk "),
"v2 fs multi-file update path should use fs bulk read and line-level apply operations",
fsBulkV2.operations,
);
assertCondition(!fsBulkV2.operations.some((operation) => operation.startsWith("stat ") || operation.startsWith("readBlock ") || operation.startsWith("writeFile ")), "v2 fs bulk path should avoid per-file stat/read/write operations", fsBulkV2.operations);
for (let fileIndex = 0; fileIndex < 4; fileIndex += 1) {
assertCondition(fsBulkV2.files[`bulk-${fileIndex}.txt`]?.includes(`file=${fileIndex} line=40 value=beta`), "v2 fs bulk path should write all changed files", { fileIndex, files: fsBulkV2.files });
}
const fsBulkTiming = applyPatchTimingFromStderr(fsBulkV2.stderr);
assertCondition(fsBulkTiming.remoteOperationCount === 2 && fsBulkTiming.remoteOperationCounts["fs.readFiles"] === 1 && fsBulkTiming.remoteOperationCounts["fs.applyReplacementsBulk"] === 1, "v2 timing summary should classify fs bulk read and line-level apply operations", fsBulkTiming);
const unprefixedUpdateContextV2 = await applyPatchV2FixtureAttempt([
"*** Begin Patch",
"*** Update File: internal/cloud/access-control.ts",