diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index a5bd5f10de..a0711dfe03 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -3,6 +3,9 @@ package api import ( "net/http" + "net/http/pprof" + "os" + "runtime" "time" "github.com/gin-gonic/gin" @@ -155,6 +158,11 @@ func (s *Server) setupRoutes() { s.mcpServer.RegisterRoutes(s.router) api.PUT("/mcp/mode", s.handleSetMcpMode) } + + // pprof + memory stats (enabled via KANDEV_DEBUG_PPROF_ENABLED=true) + if os.Getenv("KANDEV_DEBUG_PPROF_ENABLED") == "true" { + s.registerPprofRoutes() + } } // Health check response @@ -170,6 +178,36 @@ func (s *Server) handleHealth(c *gin.Context) { }) } +func (s *Server) registerPprofRoutes() { + g := s.router.Group("/debug/pprof") + g.GET("/", gin.WrapF(pprof.Index)) + g.GET("/cmdline", gin.WrapF(pprof.Cmdline)) + g.GET("/profile", gin.WrapF(pprof.Profile)) + g.GET("/symbol", gin.WrapF(pprof.Symbol)) + g.POST("/symbol", gin.WrapF(pprof.Symbol)) + g.GET("/trace", gin.WrapF(pprof.Trace)) + for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} { + g.GET("/"+name, gin.WrapH(pprof.Handler(name))) + } + + s.router.GET("/api/v1/debug/memory", func(c *gin.Context) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + c.JSON(http.StatusOK, gin.H{ + "heap_alloc_mb": float64(m.HeapAlloc) / (1024 * 1024), + "heap_inuse_mb": float64(m.HeapInuse) / (1024 * 1024), + "heap_sys_mb": float64(m.HeapSys) / (1024 * 1024), + "heap_objects": m.HeapObjects, + "goroutines": runtime.NumGoroutine(), + "num_gc": m.NumGC, + "sys_mb": float64(m.Sys) / (1024 * 1024), + "stack_inuse_mb": float64(m.StackInuse) / (1024 * 1024), + }) + }) + + s.logger.Info("pprof endpoints registered at /debug/pprof/") +} + // handleSetMcpMode changes the MCP tool mode for this instance. func (s *Server) handleSetMcpMode(c *gin.Context) { var req struct { diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go index 5732123418..69d39727ec 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go @@ -1,8 +1,12 @@ package process import ( + "bytes" "context" + "io" + "os" "os/exec" + "path/filepath" "strconv" "strings" @@ -10,6 +14,28 @@ import ( "go.uber.org/zap" ) +const ( + // maxDiffFileSize is the maximum file size for which we generate diffs. + // Files larger than this are skipped with DiffSkipReason "too_large". + maxDiffFileSize = 10 * 1024 * 1024 // 10 MB + + // maxDiffOutputSize is the maximum diff output size per file. + // Diffs exceeding this are truncated with DiffSkipReason "truncated". + maxDiffOutputSize = 256 * 1024 // 256 KB + + // maxTotalDiffBytes is the cumulative diff budget per GitStatusUpdate. + // Once exceeded, remaining files are skipped with DiffSkipReason "budget_exceeded". + maxTotalDiffBytes = 2 * 1024 * 1024 // 2 MB + + // binaryCheckSize is how many bytes to inspect for null bytes to detect binary files. + binaryCheckSize = 8 * 1024 // 8 KB + + diffSkipReasonTooLarge = "too_large" + diffSkipReasonBinary = "binary" + diffSkipReasonTruncated = "truncated" + diffSkipReasonBudgetExceeded = "budget_exceeded" +) + // enrichWithDiffData adds diff information (additions, deletions, diff content) to file info func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) { // Always diff against HEAD for unstaged/staged content so that files committed @@ -65,6 +91,44 @@ func (wt *WorkspaceTracker) enrichWithBranchDiff(ctx context.Context, update *ty update.BranchDeletions = deletions } +// totalDiffBytes returns the cumulative size of all diff content in the update. +func totalDiffBytes(update *types.GitStatusUpdate) int64 { + var total int64 + for _, fi := range update.Files { + total += int64(len(fi.Diff)) + } + return total +} + +// capDiffOutput runs a git diff command and returns at most maxDiffOutputSize bytes. +// Returns the output string and whether it was truncated. +func capDiffOutput(ctx context.Context, workDir string, args ...string) (string, bool) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = workDir + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", false + } + if err := cmd.Start(); err != nil { + _ = stdout.Close() + return "", false + } + + limited := io.LimitReader(stdout, maxDiffOutputSize+1) + data, _ := io.ReadAll(limited) + truncated := len(data) > maxDiffOutputSize + if truncated { + data = data[:maxDiffOutputSize] + } + + // Drain remaining stdout so the process doesn't hang on a full pipe. + _, _ = io.Copy(io.Discard, stdout) + _ = cmd.Wait() + + return string(data), truncated +} + // enrichWithUnstagedDiff populates additions/deletions and diff content for files // with unstaged changes by comparing the worktree against baseRef. func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) { @@ -102,11 +166,18 @@ func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update * fileInfo.Additions = additions fileInfo.Deletions = deletions - // Get the actual diff content for this file (compare against base branch) - diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) + if totalDiffBytes(update) >= maxTotalDiffBytes { + fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded + update.Files[filePath] = fileInfo + continue + } + + diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", baseRef, "--", filePath) + if diffOut != "" { + fileInfo.Diff = diffOut + if truncated { + fileInfo.DiffSkipReason = diffSkipReasonTruncated + } } update.Files[filePath] = fileInfo @@ -153,16 +224,29 @@ func (wt *WorkspaceTracker) enrichWithStagedDiff(ctx context.Context, update *ty } // Get the staged diff content if we don't have diff content yet if fileInfo.Diff == "" { - diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) + if totalDiffBytes(update) >= maxTotalDiffBytes { + fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded + update.Files[filePath] = fileInfo + continue + } + + diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", "--cached", baseRef, "--", filePath) + if diffOut != "" { + fileInfo.Diff = diffOut + if truncated { + fileInfo.DiffSkipReason = diffSkipReasonTruncated + } } } update.Files[filePath] = fileInfo } } +// isBinaryContent checks for null bytes in the data, same heuristic git uses. +func isBinaryContent(data []byte) bool { + return bytes.IndexByte(data, 0) != -1 +} + // enrichUntrackedFileDiffs builds a synthetic git diff for untracked files showing all // lines as additions, so the diff viewer can display their full content. func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update *types.GitStatusUpdate) { @@ -170,19 +254,62 @@ func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update if fileInfo.Status != fileStatusUntracked { continue } - catCmd := exec.CommandContext(ctx, "cat", filePath) - catCmd.Dir = wt.workDir - catOut, err := catCmd.Output() + + if totalDiffBytes(update) >= maxTotalDiffBytes { + fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded + update.Files[filePath] = fileInfo + continue + } + + safePath, err := wt.sanitizePath(filePath) if err != nil { continue } - content := string(catOut) + + info, err := os.Stat(filepath.Clean(safePath)) + if err != nil { + continue + } + if info.Size() > maxDiffFileSize { + fileInfo.DiffSkipReason = diffSkipReasonTooLarge + update.Files[filePath] = fileInfo + continue + } + + f, err := os.Open(filepath.Clean(safePath)) + if err != nil { + continue + } + + // Read first chunk to check for binary content. + header := make([]byte, binaryCheckSize) + n, _ := f.Read(header) + if n > 0 && isBinaryContent(header[:n]) { + _ = f.Close() + fileInfo.DiffSkipReason = diffSkipReasonBinary + update.Files[filePath] = fileInfo + continue + } + + // Read only enough content to fill maxDiffOutputSize of diff output. + // No need to read the full file — any diff beyond maxDiffOutputSize gets truncated anyway. + var buf bytes.Buffer + buf.Write(header[:n]) + remaining := int64(maxDiffOutputSize) - int64(n) + if remaining > 0 { + _, _ = io.Copy(&buf, io.LimitReader(f, remaining)) + } + _ = f.Close() + + content := buf.String() lines := strings.Split(content, "\n") + // Trim trailing empty element from final newline so line count is accurate. + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } fileInfo.Additions = len(lines) fileInfo.Deletions = 0 - // Format as a proper git diff with all required headers. - // The @git-diff-view/react library requires the full git diff format. var diffBuilder strings.Builder diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n") diffBuilder.WriteString("new file mode 100644\n") @@ -193,7 +320,14 @@ func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update for _, line := range lines { diffBuilder.WriteString("+" + line + "\n") } - fileInfo.Diff = diffBuilder.String() + + diffContent := diffBuilder.String() + if len(diffContent) > maxDiffOutputSize { + diffContent = diffContent[:maxDiffOutputSize] + fileInfo.DiffSkipReason = diffSkipReasonTruncated + } + + fileInfo.Diff = diffContent update.Files[filePath] = fileInfo } } diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go new file mode 100644 index 0000000000..17ebf4085f --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go @@ -0,0 +1,219 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func TestIsBinaryContent(t *testing.T) { + tests := []struct { + name string + data []byte + binary bool + }{ + {"empty", []byte{}, false}, + {"text", []byte("hello world\n"), false}, + {"utf8", []byte("héllo wörld\n"), false}, + {"null byte", []byte("hello\x00world"), true}, + {"null at start", []byte{0, 'h', 'i'}, true}, + {"ELF header", []byte{0x7f, 'E', 'L', 'F', 0, 0}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isBinaryContent(tt.data); got != tt.binary { + t.Errorf("isBinaryContent(%q) = %v, want %v", tt.data, got, tt.binary) + } + }) + } +} + +func TestTotalDiffBytes(t *testing.T) { + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "a.go": {Diff: "abc"}, + "b.go": {Diff: "defgh"}, + "c.go": {Diff: ""}, + }, + } + got := totalDiffBytes(update) + if got != 8 { + t.Errorf("totalDiffBytes = %d, want 8", got) + } +} + +func TestCapDiffOutput_Truncation(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a file with content larger than maxDiffOutputSize + bigContent := strings.Repeat("x", maxDiffOutputSize+1000) + writeFile(t, repoDir, "big.txt", bigContent) + runGit(t, repoDir, "add", "big.txt") + + // Get diff — should be truncated + out, truncated := capDiffOutput(context.Background(), repoDir, "diff", "--cached", "--", "big.txt") + if !truncated { + t.Error("expected truncated=true for large diff") + } + if len(out) > maxDiffOutputSize { + t.Errorf("output len=%d exceeds maxDiffOutputSize=%d", len(out), maxDiffOutputSize) + } +} + +func TestEnrichUntrackedFileDiffs_TooLarge(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a file just over the size limit + bigPath := filepath.Join(repoDir, "huge.bin") + f, err := os.Create(bigPath) + if err != nil { + t.Fatal(err) + } + // Write maxDiffFileSize + 1 bytes (all 'A') + buf := make([]byte, 1024*1024) // 1MB chunks + for i := range buf { + buf[i] = 'A' + } + written := int64(0) + for written < maxDiffFileSize+1 { + chunk := buf + if remaining := maxDiffFileSize + 1 - written; remaining < int64(len(chunk)) { + chunk = chunk[:remaining] + } + n, err := f.Write(chunk) + if err != nil { + _ = f.Close() + t.Fatal(err) + } + written += int64(n) + } + _ = f.Close() + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "huge.bin": {Path: "huge.bin", Status: "untracked"}, + }, + } + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + fi := update.Files["huge.bin"] + if fi.DiffSkipReason != diffSkipReasonTooLarge { + t.Errorf("DiffSkipReason = %q, want %q", fi.DiffSkipReason, diffSkipReasonTooLarge) + } + if fi.Diff != "" { + t.Error("expected empty Diff for too-large file") + } +} + +func TestEnrichUntrackedFileDiffs_Binary(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a binary file (contains null bytes) + binPath := filepath.Join(repoDir, "image.png") + binContent := []byte("PNG\x00\x00\x00fake binary content") + if err := os.WriteFile(binPath, binContent, 0644); err != nil { + t.Fatal(err) + } + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "image.png": {Path: "image.png", Status: "untracked"}, + }, + } + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + fi := update.Files["image.png"] + if fi.DiffSkipReason != diffSkipReasonBinary { + t.Errorf("DiffSkipReason = %q, want %q", fi.DiffSkipReason, diffSkipReasonBinary) + } + if fi.Diff != "" { + t.Error("expected empty Diff for binary file") + } +} + +func TestEnrichUntrackedFileDiffs_BudgetExceeded(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create files large enough that their synthetic diffs exceed maxTotalDiffBytes. + // Each file's diff includes headers + "+line\n" per line, so content of ~maxDiffOutputSize + // per file ensures we blow through the 2MB budget in a few files. + fileCount := 20 + contentPerFile := maxDiffOutputSize / 2 + files := make(map[string]streams.FileInfo) + + for i := 0; i < fileCount; i++ { + name := filepath.Join(repoDir, strings.Repeat("a", i+1)+".txt") + content := strings.Repeat("x\n", contentPerFile/2) + if err := os.WriteFile(name, []byte(content), 0644); err != nil { + t.Fatal(err) + } + relName := filepath.Base(name) + files[relName] = streams.FileInfo{Path: relName, Status: "untracked"} + } + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{Files: files} + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + budgetExceededCount := 0 + for _, fi := range update.Files { + if fi.DiffSkipReason == diffSkipReasonBudgetExceeded { + budgetExceededCount++ + } + } + if budgetExceededCount == 0 { + t.Error("expected at least one file with budget_exceeded skip reason") + } +} + +func TestEnrichUntrackedFileDiffs_SmallTextFile(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + txtPath := filepath.Join(repoDir, "hello.txt") + if err := os.WriteFile(txtPath, []byte("hello world\n"), 0644); err != nil { + t.Fatal(err) + } + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "hello.txt": {Path: "hello.txt", Status: "untracked"}, + }, + } + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + fi := update.Files["hello.txt"] + if fi.Diff == "" { + t.Error("expected non-empty Diff for small text file") + } + if fi.DiffSkipReason != "" { + t.Errorf("expected empty DiffSkipReason, got %q", fi.DiffSkipReason) + } + if fi.Additions != 1 { + t.Errorf("Additions = %d, want 1", fi.Additions) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index 1ee829afb1..1bd6e1cf7d 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -78,6 +78,10 @@ type FileInfo struct { // Diff contains the unified diff content for this file. Diff string `json:"diff,omitempty"` + + // DiffSkipReason explains why diff content was omitted or truncated. + // Values: "too_large", "binary", "truncated", "budget_exceeded". + DiffSkipReason string `json:"diff_skip_reason,omitempty"` } // GitCommitNotification is sent when a new commit is detected in the workspace. diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index 34148580d5..f1a3311189 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -29,6 +29,7 @@ import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import type { DiffComment } from "@/lib/diff/types"; +import { diffSkipReasonLabel } from "./types"; import type { ReviewFile } from "./types"; function isMarkdownPath(filePath: string): boolean { @@ -451,6 +452,60 @@ function useScrollIntoViewOnSelect( }, [isSelected, sectionRef, setCollapsed]); } +function renderDiffContent(opts: { + shouldRender: boolean; + file: ReviewFile; + sessionId: string; + wordWrap: boolean; + expandUnchanged: boolean; + onRevertBlock: (filePath: string, info: RevertBlockInfo) => void; + onCommentRun: (comment: DiffComment) => void; + onToggleExpandUnchanged: () => void; +}) { + const { + shouldRender, + file, + sessionId, + wordWrap, + expandUnchanged, + onRevertBlock, + onCommentRun, + onToggleExpandUnchanged, + } = opts; + if (shouldRender && file.diff) { + return ( + <> + + {file.diff_skip_reason === "truncated" && ( +
+ Diff truncated — showing first 256 KB +
+ )} + + ); + } + return ( +
+ {diffSkipReasonLabel(file.diff_skip_reason)} +
+ ); +} + function FileDiffSection({ file, isReviewed, @@ -478,8 +533,8 @@ function FileDiffSection({ [wordWrap], ); const { isVisible, sentinelRef } = useLazyVisible(scrollContainer); - // Force load when: visible via intersection observer, or forceLoad is true (all files up to selected) - const shouldRenderContent = isVisible || forceLoad; + // Force load when visible via intersection observer, or forceLoad is true + const shouldRenderContent = isVisible || !!forceLoad; useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed); const scrollSentinelRef = useAutoMarkOnScroll({ autoMarkOnScroll, @@ -527,28 +582,16 @@ function FileDiffSection({ />
{!collapsed && - (shouldRenderContent && file.diff ? ( - - ) : ( -
- Loading diff... -
- ))} + renderDiffContent({ + shouldRender: shouldRenderContent, + file, + sessionId, + wordWrap: effectiveWordWrap, + expandUnchanged, + onRevertBlock: handleRevertBlock, + onCommentRun: handleCommentRun, + onToggleExpandUnchanged: handleToggleExpandUnchanged, + })}
); } diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts index 85999993e3..c3d4e3b4d9 100644 --- a/apps/web/components/review/types.ts +++ b/apps/web/components/review/types.ts @@ -8,8 +8,24 @@ export type ReviewFile = { deletions: number; staged: boolean; source: "uncommitted" | "committed" | "pr"; + diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; }; +export function diffSkipReasonLabel(reason?: string): string { + switch (reason) { + case "too_large": + return "File too large to diff (>10 MB)"; + case "binary": + return "Binary file — not diffable"; + case "truncated": + return "Diff truncated (>256 KB)"; + case "budget_exceeded": + return "Diff skipped — too many changed files"; + default: + return "Loading diff..."; + } +} + export type FileTreeNode = { name: string; path: string; diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index c26df7b1a2..16d0cb6577 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -35,6 +35,7 @@ type TaskChangesPanelProps = { type UncommittedFile = { diff?: string; + diff_skip_reason?: ReviewFile["diff_skip_reason"]; status?: string; additions?: number; deletions?: number; @@ -48,7 +49,8 @@ function addUncommittedFiles( ) { for (const [path, file] of Object.entries(files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ""; - if (diff) { + const skipReason = file.diff_skip_reason; + if (diff || skipReason) { fileMap.set(path, { path, diff, @@ -57,6 +59,7 @@ function addUncommittedFiles( deletions: file.deletions ?? 0, staged: file.staged ?? false, source: "uncommitted", + diff_skip_reason: skipReason, }); } } diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 316c1192d1..2bd3e52fd6 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -48,6 +48,7 @@ export type FileInfo = { deletions?: number; old_path?: string; diff?: string; + diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; }; export type GitStatusEntry = { diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 7aa7b60098..f5ccab5c25 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -264,6 +264,7 @@ export type FileInfo = { deletions?: number; old_path?: string; diff?: string; + diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; }; export type ProcessOutputPayload = {