| author | |
| committer | |
| log | c163d502dd32b993c03d8c20a5fae55b159bd8dc |
| tree | 7aaea18d35524268a41de29d9e490ab0cf18e8b5 |
| parent | 4ee2a9d046a0986451c703c9221bb32ba53540bb |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 376 insertions(+), 137 deletions(-)
apps/server/package.json-1| ... | @@ -28,7 +28,6 @@ | ... | @@ -28,7 +28,6 @@ |
| 28 | "@effect/sql-sqlite-bun": "catalog:", | 28 | "@effect/sql-sqlite-bun": "catalog:", |
| 29 | "@ff-labs/fff-node": "0.9.4", | 29 | "@ff-labs/fff-node": "0.9.4", |
| 30 | "@opencode-ai/sdk": "^1.3.15", | 30 | "@opencode-ai/sdk": "^1.3.15", |
| 31 | "@pierre/diffs": "catalog:", | ||
| 32 | "effect": "catalog:", | 31 | "effect": "catalog:", |
| 33 | "msgpackr-extract": "3.0.4", | 32 | "msgpackr-extract": "3.0.4", |
| 34 | "node-pty": "^1.1.0", | 33 | "node-pty": "^1.1.0", |
apps/server/src/checkpointing/CheckpointStore.test.ts+178| ... | @@ -4,6 +4,7 @@ import * as NodePath from "node:path"; | ... | @@ -4,6 +4,7 @@ import * as NodePath from "node:path"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { it } from "@effect/vitest"; | 5 | import { it } from "@effect/vitest"; |
| 6 | import { ThreadId, type VcsError } from "@t3tools/contracts"; | 6 | import { ThreadId, type VcsError } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 9 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| ... | @@ -12,6 +13,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -12,6 +13,7 @@ import * as Scope from "effect/Scope"; |
| 12 | import { describe, expect } from "vite-plus/test"; | 13 | import { describe, expect } from "vite-plus/test"; |
| 13 | 14 | ||
| 14 | import { checkpointRefForThreadTurn } from "./Utils.ts"; | 15 | import { checkpointRefForThreadTurn } from "./Utils.ts"; |
| 16 | import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; | ||
| 15 | import * as CheckpointStore from "./CheckpointStore.ts"; | 17 | import * as CheckpointStore from "./CheckpointStore.ts"; |
| 16 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 18 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 19 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| ... | @@ -250,6 +252,182 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { | ... | @@ -250,6 +252,182 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { |
| 250 | expect(whitespaceIgnoredDiff).toContain("+ <div>"); | 252 | expect(whitespaceIgnoredDiff).toContain("+ <div>"); |
| 251 | expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>"); | 253 | expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>"); |
| 252 | expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>"); | 254 | expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>"); |
| 255 | |||
| 256 | for (const ignoreWhitespace of [false, true]) { | ||
| 257 | const numstat = yield* checkpointStore.diffCheckpoints({ | ||
| 258 | cwd: tmp, | ||
| 259 | fromCheckpointRef, | ||
| 260 | toCheckpointRef, | ||
| 261 | ignoreWhitespace, | ||
| 262 | format: "numstat", | ||
| 263 | }); | ||
| 264 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ | ||
| 265 | { | ||
| 266 | path: "Component.tsx", | ||
| 267 | additions: ignoreWhitespace ? 4 : 6, | ||
| 268 | deletions: ignoreWhitespace ? 0 : 2, | ||
| 269 | }, | ||
| 270 | ]); | ||
| 271 | } | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | }); | ||
| 275 | |||
| 276 | describe("checkpoint file summaries", () => { | ||
| 277 | it.effect("counts changes whose full patch exceeds the output limit", () => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const tmp = yield* makeTmpDir(); | ||
| 280 | yield* initRepoWithCommit(tmp); | ||
| 281 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | ||
| 282 | const threadId = ThreadId.make("large-checkpoint-summary"); | ||
| 283 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); | ||
| 284 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | ||
| 285 | const filePath = NodePath.join(tmp, "README.md"); | ||
| 286 | const lineCount = 20_000; | ||
| 287 | yield* writeTextFile(filePath, `${"before".repeat(50)}\n`.repeat(lineCount)); | ||
| 288 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef }); | ||
| 289 | yield* writeTextFile(filePath, `${"after".repeat(60)}\n`.repeat(lineCount)); | ||
| 290 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef }); | ||
| 291 | |||
| 292 | const numstat = yield* checkpointStore.diffCheckpoints({ | ||
| 293 | cwd: tmp, | ||
| 294 | fromCheckpointRef, | ||
| 295 | toCheckpointRef, | ||
| 296 | ignoreWhitespace: false, | ||
| 297 | format: "numstat", | ||
| 298 | }); | ||
| 299 | |||
| 300 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ | ||
| 301 | { path: "README.md", additions: lineCount, deletions: lineCount }, | ||
| 302 | ]); | ||
| 303 | expect(numstat.length).toBeLessThan(100); | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | |||
| 307 | it.effect("preserves file paths and turn ranges without changing the user index", () => | ||
| 308 | Effect.gen(function* () { | ||
| 309 | const tmp = yield* makeTmpDir(); | ||
| 310 | yield* initRepoWithCommit(tmp); | ||
| 311 | yield* git(tmp, ["config", "diff.renames", "copies"]); | ||
| 312 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 313 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | ||
| 314 | const threadId = ThreadId.make("checkpoint-summary-paths"); | ||
| 315 | const baseline = checkpointRefForThreadTurn(threadId, 0); | ||
| 316 | const firstTurn = checkpointRefForThreadTurn(threadId, 1); | ||
| 317 | const secondTurn = checkpointRefForThreadTurn(threadId, 2); | ||
| 318 | const copiedText = Array.from({ length: 20 }, (_, index) => `copy line ${index}\n`).join( | ||
| 319 | "", | ||
| 320 | ); | ||
| 321 | const platform = yield* HostProcessPlatform; | ||
| 322 | const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt"; | ||
| 323 | const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt"; | ||
| 324 | for (const [path, contents] of Object.entries({ | ||
| 325 | "copy-source.txt": copiedText, | ||
| 326 | "deleted.txt": "delete me\n", | ||
| 327 | "rename-old.txt": "before\nkeep one\nkeep two\nkeep three\n", | ||
| 328 | "binary.bin": "\0before", | ||
| 329 | })) { | ||
| 330 | yield* writeTextFile(NodePath.join(tmp, path), contents); | ||
| 331 | } | ||
| 332 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: baseline }); | ||
| 333 | |||
| 334 | yield* fileSystem.rename( | ||
| 335 | NodePath.join(tmp, "rename-old.txt"), | ||
| 336 | NodePath.join(tmp, renamedPath), | ||
| 337 | ); | ||
| 338 | yield* fileSystem.remove(NodePath.join(tmp, "deleted.txt")); | ||
| 339 | for (const [path, contents] of Object.entries({ | ||
| 340 | "copy-source.txt": `${copiedText}one more\n`, | ||
| 341 | "copied.txt": copiedText, | ||
| 342 | [renamedPath]: "after\nkeep one\nkeep two\nkeep three\n", | ||
| 343 | "binary.bin": "\0after", | ||
| 344 | "empty.txt": "", | ||
| 345 | [addedPath]: "first\nsecond\n", | ||
| 346 | })) { | ||
| 347 | yield* writeTextFile(NodePath.join(tmp, path), contents); | ||
| 348 | } | ||
| 349 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: firstTurn }); | ||
| 350 | const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index")); | ||
| 351 | const input = { | ||
| 352 | cwd: tmp, | ||
| 353 | fromCheckpointRef: baseline, | ||
| 354 | toCheckpointRef: firstTurn, | ||
| 355 | ignoreWhitespace: false, | ||
| 356 | format: "numstat" as const, | ||
| 357 | }; | ||
| 358 | const firstSummary = parseTurnDiffFilesFromNumstat( | ||
| 359 | yield* checkpointStore.diffCheckpoints(input), | ||
| 360 | ); | ||
| 361 | const expectedFiles = [ | ||
| 362 | { path: "binary.bin", additions: 0, deletions: 0 }, | ||
| 363 | { path: "copied.txt", additions: 0, deletions: 0 }, | ||
| 364 | { path: "copy-source.txt", additions: 1, deletions: 0 }, | ||
| 365 | { path: "deleted.txt", additions: 0, deletions: 1 }, | ||
| 366 | { path: "empty.txt", additions: 0, deletions: 0 }, | ||
| 367 | { path: addedPath, additions: 2, deletions: 0 }, | ||
| 368 | { path: renamedPath, additions: 1, deletions: 1 }, | ||
| 369 | ].toSorted((left, right) => left.path.localeCompare(right.path)); | ||
| 370 | expect(firstSummary).toEqual(expectedFiles); | ||
| 371 | |||
| 372 | yield* fileSystem.remove(NodePath.join(tmp, "empty.txt")); | ||
| 373 | yield* writeTextFile(NodePath.join(tmp, "copy-source.txt"), "replacement\n"); | ||
| 374 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: secondTurn }); | ||
| 375 | const secondSummary = parseTurnDiffFilesFromNumstat( | ||
| 376 | yield* checkpointStore.diffCheckpoints({ | ||
| 377 | ...input, | ||
| 378 | fromCheckpointRef: firstTurn, | ||
| 379 | toCheckpointRef: secondTurn, | ||
| 380 | }), | ||
| 381 | ); | ||
| 382 | expect(secondSummary).toEqual([ | ||
| 383 | { path: "copy-source.txt", additions: 1, deletions: 21 }, | ||
| 384 | { path: "empty.txt", additions: 0, deletions: 0 }, | ||
| 385 | ]); | ||
| 386 | |||
| 387 | const inclusiveSummary = parseTurnDiffFilesFromNumstat( | ||
| 388 | yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: secondTurn }), | ||
| 389 | ); | ||
| 390 | expect(inclusiveSummary).toEqual( | ||
| 391 | expectedFiles | ||
| 392 | .filter((file) => file.path !== "empty.txt") | ||
| 393 | .map((file) => | ||
| 394 | file.path === "copy-source.txt" ? { ...file, additions: 1, deletions: 20 } : file, | ||
| 395 | ), | ||
| 396 | ); | ||
| 397 | expect( | ||
| 398 | yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: baseline }), | ||
| 399 | ).toBe(""); | ||
| 400 | expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex); | ||
| 401 | }), | ||
| 402 | ); | ||
| 403 | |||
| 404 | it.effect("uses HEAD for a missing baseline only when requested", () => | ||
| 405 | Effect.gen(function* () { | ||
| 406 | const tmp = yield* makeTmpDir(); | ||
| 407 | yield* initRepoWithCommit(tmp); | ||
| 408 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | ||
| 409 | const threadId = ThreadId.make("checkpoint-summary-fallback"); | ||
| 410 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); | ||
| 411 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | ||
| 412 | yield* writeTextFile(NodePath.join(tmp, "README.md"), "changed\n"); | ||
| 413 | yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef }); | ||
| 414 | const input = { | ||
| 415 | cwd: tmp, | ||
| 416 | fromCheckpointRef, | ||
| 417 | toCheckpointRef, | ||
| 418 | ignoreWhitespace: false, | ||
| 419 | format: "numstat" as const, | ||
| 420 | }; | ||
| 421 | |||
| 422 | const error = yield* Effect.flip(checkpointStore.diffCheckpoints(input)); | ||
| 423 | expect(error._tag).toBe("VcsProcessExitError"); | ||
| 424 | const numstat = yield* checkpointStore.diffCheckpoints({ | ||
| 425 | ...input, | ||
| 426 | fallbackFromToHead: true, | ||
| 427 | }); | ||
| 428 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ | ||
| 429 | { path: "README.md", additions: 1, deletions: 1 }, | ||
| 430 | ]); | ||
| 253 | }), | 431 | }), |
| 254 | ); | 432 | ); |
| 255 | }); | 433 | }); |
apps/server/src/checkpointing/CheckpointStore.ts+3-1| ... | @@ -39,6 +39,7 @@ export interface DiffCheckpointsInput { | ... | @@ -39,6 +39,7 @@ export interface DiffCheckpointsInput { |
| 39 | readonly toCheckpointRef: CheckpointRef; | 39 | readonly toCheckpointRef: CheckpointRef; |
| 40 | readonly fallbackFromToHead?: boolean; | 40 | readonly fallbackFromToHead?: boolean; |
| 41 | readonly ignoreWhitespace: boolean; | 41 | readonly ignoreWhitespace: boolean; |
| 42 | readonly format?: "patch" | "numstat"; | ||
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | export interface DeleteCheckpointRefsInput { | 45 | export interface DeleteCheckpointRefsInput { |
| ... | @@ -77,8 +78,9 @@ export class CheckpointStore extends Context.Service< | ... | @@ -77,8 +78,9 @@ export class CheckpointStore extends Context.Service< |
| 77 | ) => Effect.Effect<boolean, CheckpointStoreError>; | 78 | ) => Effect.Effect<boolean, CheckpointStoreError>; |
| 78 | 79 | ||
| 79 | /** | 80 | /** |
| 80 | * Compute a patch diff between two checkpoint refs. | 81 | * Compute a diff between two checkpoint refs. Defaults to a full patch. |
| 81 | * | 82 | * |
| 83 | * Numstat output has NUL-delimited paths for file summaries. | ||
| 82 | * Can optionally treat a missing "from" ref as `HEAD`. | 84 | * Can optionally treat a missing "from" ref as `HEAD`. |
| 83 | */ | 85 | */ |
| 84 | readonly diffCheckpoints: ( | 86 | readonly diffCheckpoints: ( |
apps/server/src/checkpointing/Diffs.test.ts+34-48| ... | @@ -1,68 +1,54 @@ | ... | @@ -1,68 +1,54 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts"; | 3 | import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; |
| 4 | 4 | ||
| 5 | describe("parseTurnDiffFilesFromUnifiedDiff", () => { | 5 | describe("parseTurnDiffFilesFromNumstat", () => { |
| 6 | it("returns empty list for empty diff", () => { | 6 | it("returns an empty list when no files changed", () => { |
| 7 | expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]); | 7 | expect(parseTurnDiffFilesFromNumstat("")).toEqual([]); |
| 8 | }); | 8 | }); |
| 9 | 9 | ||
| 10 | it("parses per-file additions and deletions", () => { | 10 | it("sorts files and preserves addition and deletion counts", () => { |
| 11 | const diff = [ | 11 | const numstat = ["0\t2\tsrc/b.ts", "2\t1\ta.txt", ""].join("\0"); |
| 12 | "diff --git a/a.txt b/a.txt", | 12 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 13 | "index 1111111..2222222 100644", | ||
| 14 | "--- a/a.txt", | ||
| 15 | "+++ b/a.txt", | ||
| 16 | "@@ -1,2 +1,3 @@", | ||
| 17 | " one", | ||
| 18 | "-two", | ||
| 19 | "+two updated", | ||
| 20 | "+three", | ||
| 21 | "diff --git a/src/b.ts b/src/b.ts", | ||
| 22 | "index 3333333..4444444 100644", | ||
| 23 | "--- a/src/b.ts", | ||
| 24 | "+++ b/src/b.ts", | ||
| 25 | "@@ -3,2 +3,0 @@", | ||
| 26 | "-old", | ||
| 27 | "-stale", | ||
| 28 | "", | ||
| 29 | ].join("\n"); | ||
| 30 | |||
| 31 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | ||
| 32 | { path: "a.txt", additions: 2, deletions: 1 }, | 13 | { path: "a.txt", additions: 2, deletions: 1 }, |
| 33 | { path: "src/b.ts", additions: 0, deletions: 2 }, | 14 | { path: "src/b.ts", additions: 0, deletions: 2 }, |
| 34 | ]); | 15 | ]); |
| 35 | }); | 16 | }); |
| 36 | 17 | ||
| 37 | it("parses rename-only diffs with zero line changes", () => { | 18 | it("uses destination paths for renames and copies", () => { |
| 38 | const diff = [ | 19 | const numstat = [ |
| 39 | "diff --git a/src/old.ts b/src/new.ts", | 20 | "0\t0\t", |
| 40 | "similarity index 100%", | 21 | "src/old.ts", |
| 41 | "rename from src/old.ts", | 22 | "src/new.ts", |
| 42 | "rename to src/new.ts", | 23 | "2\t1\t", |
| 24 | "src/source.ts", | ||
| 25 | "src/copied.ts", | ||
| 26 | "1\t0\tother.ts", | ||
| 43 | "", | 27 | "", |
| 44 | ].join("\n"); | 28 | ].join("\0"); |
| 45 | 29 | ||
| 46 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | 30 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 31 | { path: "other.ts", additions: 1, deletions: 0 }, | ||
| 32 | { path: "src/copied.ts", additions: 2, deletions: 1 }, | ||
| 47 | { path: "src/new.ts", additions: 0, deletions: 0 }, | 33 | { path: "src/new.ts", additions: 0, deletions: 0 }, |
| 48 | ]); | 34 | ]); |
| 49 | }); | 35 | }); |
| 50 | 36 | ||
| 51 | it("normalizes CRLF input before parsing", () => { | 37 | it("keeps binary files and empty files with zero line changes", () => { |
| 52 | const diff = [ | 38 | const numstat = ["-\t-\timage.png", "0\t0\tempty.txt", ""].join("\0"); |
| 53 | "diff --git a/a.txt b/a.txt", | 39 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 54 | "index 1111111..2222222 100644", | 40 | { path: "empty.txt", additions: 0, deletions: 0 }, |
| 55 | "--- a/a.txt", | 41 | { path: "image.png", additions: 0, deletions: 0 }, |
| 56 | "+++ b/a.txt", | 42 | ]); |
| 57 | "@@ -1 +1,2 @@", | 43 | }); |
| 58 | "-one", | ||
| 59 | "+one updated", | ||
| 60 | "+two", | ||
| 61 | "", | ||
| 62 | ].join("\r\n"); | ||
| 63 | 44 | ||
| 64 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | 45 | it("preserves Unicode, tabs, line endings, and spaces in paths", () => { |
| 65 | { path: "a.txt", additions: 2, deletions: 1 }, | 46 | const path = " café\tline\r\nname.txt "; |
| 47 | const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; | ||
| 48 | |||
| 49 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]); | ||
| 50 | expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ | ||
| 51 | { path, additions: 1, deletions: 0 }, | ||
| 66 | ]); | 52 | ]); |
| 67 | }); | 53 | }); |
| 68 | }); | 54 | }); |
apps/server/src/checkpointing/Diffs.ts+23-17| ... | @@ -1,27 +1,33 @@ | ... | @@ -1,27 +1,33 @@ |
| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | ||
| 2 | |||
| 3 | export interface TurnDiffFileSummary { | 1 | export interface TurnDiffFileSummary { |
| 4 | readonly path: string; | 2 | readonly path: string; |
| 5 | readonly additions: number; | 3 | readonly additions: number; |
| 6 | readonly deletions: number; | 4 | readonly deletions: number; |
| 7 | } | 5 | } |
| 8 | 6 | ||
| 9 | export function parseTurnDiffFilesFromUnifiedDiff( | 7 | /** Reads Git's NUL-delimited numstat output without decoding display paths. */ |
| 10 | diff: string, | 8 | export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<TurnDiffFileSummary> { |
| 11 | ): ReadonlyArray<TurnDiffFileSummary> { | 9 | const records = numstat.split("\0"); |
| 12 | const normalized = diff.replace(/\r\n/g, "\n").trim(); | 10 | const files: TurnDiffFileSummary[] = []; |
| 13 | if (normalized.length === 0) { | 11 | |
| 14 | return []; | 12 | for (let index = 0; index < records.length; index += 1) { |
| 15 | } | 13 | const record = records[index]!; |
| 14 | const counts = /^(\d+|-)\t(\d+|-)\t/.exec(record); | ||
| 15 | if (!counts) continue; | ||
| 16 | 16 | ||
| 17 | const parsedPatches = parsePatchFiles(normalized); | 17 | let path = record.slice(counts[0].length); |
| 18 | const files = parsedPatches.flatMap((patch) => | 18 | if (path.length === 0) { |
| 19 | patch.files.map((file) => ({ | 19 | // Renames and copies use two more records: the source and destination. |
| 20 | path: file.name, | 20 | path = records[index + 2] ?? ""; |
| 21 | additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0), | 21 | index += 2; |
| 22 | deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0), | 22 | } |
| 23 | })), | 23 | if (path.length === 0) continue; |
| 24 | ); | 24 | |
| 25 | files.push({ | ||
| 26 | path, | ||
| 27 | additions: counts[1] === "-" ? 0 : Number(counts[1]), | ||
| 28 | deletions: counts[2] === "-" ? 0 : Number(counts[2]), | ||
| 29 | }); | ||
| 30 | } | ||
| 25 | 31 | ||
| 26 | return files.toSorted((left, right) => left.path.localeCompare(right.path)); | 32 | return files.toSorted((left, right) => left.path.localeCompare(right.path)); |
| 27 | } | 33 | } |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+101-61| ... | @@ -27,6 +27,7 @@ import * as Exit from "effect/Exit"; | ... | @@ -27,6 +27,7 @@ import * as Exit from "effect/Exit"; |
| 27 | import * as Layer from "effect/Layer"; | 27 | import * as Layer from "effect/Layer"; |
| 28 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 28 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 29 | import * as PubSub from "effect/PubSub"; | 29 | import * as PubSub from "effect/PubSub"; |
| 30 | import * as Queue from "effect/Queue"; | ||
| 30 | import * as Scope from "effect/Scope"; | 31 | import * as Scope from "effect/Scope"; |
| 31 | import * as Stream from "effect/Stream"; | 32 | import * as Stream from "effect/Stream"; |
| 32 | import { it as effectIt } from "@effect/vitest"; | 33 | import { it as effectIt } from "@effect/vitest"; |
| ... | @@ -43,7 +44,8 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | ... | @@ -43,7 +44,8 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 43 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 44 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 44 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; | 45 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 45 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; | 46 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; |
| 46 | import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; | 47 | import { RuntimeReceiptBusTest } from "./RuntimeReceiptBus.ts"; |
| 48 | import * as RuntimeReceiptBus from "../Services/RuntimeReceiptBus.ts"; | ||
| 47 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 49 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 48 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 50 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 49 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 51 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| ... | @@ -262,7 +264,8 @@ describe("CheckpointReactor", () => { | ... | @@ -262,7 +264,8 @@ describe("CheckpointReactor", () => { |
| 262 | | OrchestrationEngineService | 264 | | OrchestrationEngineService |
| 263 | | CheckpointReactor | 265 | | CheckpointReactor |
| 264 | | CheckpointStore.CheckpointStore | 266 | | CheckpointStore.CheckpointStore |
| 265 | | ProjectionSnapshotQuery, | 267 | | ProjectionSnapshotQuery |
| 268 | | RuntimeReceiptBus.RuntimeReceiptBus, | ||
| 266 | unknown | 269 | unknown |
| 267 | > | null = null; | 270 | > | null = null; |
| 268 | let scope: Scope.Closeable | null = null; | 271 | let scope: Scope.Closeable | null = null; |
| ... | @@ -356,7 +359,7 @@ describe("CheckpointReactor", () => { | ... | @@ -356,7 +359,7 @@ describe("CheckpointReactor", () => { |
| 356 | const layer = CheckpointReactorLive.pipe( | 359 | const layer = CheckpointReactorLive.pipe( |
| 357 | Layer.provideMerge(orchestrationLayer), | 360 | Layer.provideMerge(orchestrationLayer), |
| 358 | Layer.provideMerge(projectionSnapshotLayer), | 361 | Layer.provideMerge(projectionSnapshotLayer), |
| 359 | Layer.provideMerge(RuntimeReceiptBusLive), | 362 | Layer.provideMerge(RuntimeReceiptBusTest), |
| 360 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 363 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 361 | Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })), | 364 | Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })), |
| 362 | Layer.provideMerge(vcsStatusBroadcasterLayer), | 365 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| ... | @@ -380,8 +383,21 @@ describe("CheckpointReactor", () => { | ... | @@ -380,8 +383,21 @@ describe("CheckpointReactor", () => { |
| 380 | const checkpointStore = await runtime.runPromise( | 383 | const checkpointStore = await runtime.runPromise( |
| 381 | Effect.service(CheckpointStore.CheckpointStore), | 384 | Effect.service(CheckpointStore.CheckpointStore), |
| 382 | ); | 385 | ); |
| 383 | scope = await Effect.runPromise(Scope.make("sequential")); | 386 | const receiptBus = await runtime.runPromise( |
| 384 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); | 387 | Effect.service(RuntimeReceiptBus.RuntimeReceiptBus), |
| 388 | ); | ||
| 389 | const testScope = await Effect.runPromise(Scope.make("sequential")); | ||
| 390 | scope = testScope; | ||
| 391 | const receipts = await Effect.runPromise( | ||
| 392 | Effect.gen(function* () { | ||
| 393 | const receipts = yield* Queue.unbounded<RuntimeReceiptBus.OrchestrationRuntimeReceipt>(); | ||
| 394 | yield* Stream.runForEach(receiptBus.streamEventsForTest, (receipt) => | ||
| 395 | Queue.offer(receipts, receipt), | ||
| 396 | ).pipe(Effect.forkIn(testScope, { startImmediately: true })); | ||
| 397 | yield* reactor.start().pipe(Scope.provide(testScope)); | ||
| 398 | return receipts; | ||
| 399 | }), | ||
| 400 | ); | ||
| 385 | const drain = () => Effect.runPromise(reactor.drain); | 401 | const drain = () => Effect.runPromise(reactor.drain); |
| 386 | 402 | ||
| 387 | const createdAt = "2026-01-01T00:00:00.000Z"; | 403 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| ... | @@ -470,16 +486,19 @@ describe("CheckpointReactor", () => { | ... | @@ -470,16 +486,19 @@ describe("CheckpointReactor", () => { |
| 470 | provider, | 486 | provider, |
| 471 | cwd, | 487 | cwd, |
| 472 | drain, | 488 | drain, |
| 489 | nextReceipt: Queue.take(receipts), | ||
| 473 | pullRequestRefreshes, | 490 | pullRequestRefreshes, |
| 474 | }; | 491 | }; |
| 475 | } | 492 | } |
| 476 | 493 | ||
| 477 | it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { | 494 | effectIt.effect("captures baseline and large turn summaries before completion receipts", () => |
| 478 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | 495 | Effect.gen(function* () { |
| 479 | const createdAt = "2026-01-01T00:00:00.000Z"; | 496 | const harness = yield* Effect.promise(() => |
| 497 | createHarness({ seedFilesystemCheckpoints: false }), | ||
| 498 | ); | ||
| 499 | const createdAt = "2026-01-01T00:00:00.000Z"; | ||
| 480 | 500 | ||
| 481 | await Effect.runPromise( | 501 | yield* harness.engine.dispatch({ |
| 482 | harness.engine.dispatch({ | ||
| 483 | type: "thread.session.set", | 502 | type: "thread.session.set", |
| 484 | commandId: CommandId.make("cmd-session-set-capture"), | 503 | commandId: CommandId.make("cmd-session-set-capture"), |
| 485 | threadId: ThreadId.make("thread-1"), | 504 | threadId: ThreadId.make("thread-1"), |
| ... | @@ -493,62 +512,83 @@ describe("CheckpointReactor", () => { | ... | @@ -493,62 +512,83 @@ describe("CheckpointReactor", () => { |
| 493 | updatedAt: createdAt, | 512 | updatedAt: createdAt, |
| 494 | }, | 513 | }, |
| 495 | createdAt, | 514 | createdAt, |
| 496 | }), | 515 | }); |
| 497 | ); | ||
| 498 | 516 | ||
| 499 | harness.provider.emit({ | 517 | harness.provider.emit({ |
| 500 | type: "turn.started", | 518 | type: "turn.started", |
| 501 | eventId: EventId.make("evt-turn-started-1"), | 519 | eventId: EventId.make("evt-turn-started-1"), |
| 502 | provider: ProviderDriverKind.make("codex"), | 520 | provider: ProviderDriverKind.make("codex"), |
| 503 | 521 | ||
| 504 | createdAt: "2026-01-01T00:00:00.000Z", | 522 | createdAt: "2026-01-01T00:00:00.000Z", |
| 505 | threadId: ThreadId.make("thread-1"), | 523 | threadId: ThreadId.make("thread-1"), |
| 506 | turnId: asTurnId("turn-1"), | 524 | turnId: asTurnId("turn-1"), |
| 507 | }); | 525 | }); |
| 508 | await waitForGitRefExists( | 526 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 509 | harness.cwd, | 527 | type: "checkpoint.baseline.captured", |
| 510 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | 528 | checkpointTurnCount: 0, |
| 511 | ); | 529 | }); |
| 512 | 530 | ||
| 513 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); | 531 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 514 | harness.provider.emit({ | 532 | const largeFileLineCount = 25_000; |
| 515 | type: "turn.completed", | 533 | NodeFS.writeFileSync( |
| 516 | eventId: EventId.make("evt-turn-completed-1"), | 534 | NodePath.join(harness.cwd, "large.txt"), |
| 517 | provider: ProviderDriverKind.make("codex"), | 535 | `${"payload".repeat(64)}\n`.repeat(largeFileLineCount), |
| 536 | "utf8", | ||
| 537 | ); | ||
| 538 | harness.provider.emit({ | ||
| 539 | type: "turn.completed", | ||
| 540 | eventId: EventId.make("evt-turn-completed-1"), | ||
| 541 | provider: ProviderDriverKind.make("codex"), | ||
| 518 | 542 | ||
| 519 | createdAt: "2026-01-01T00:00:00.000Z", | 543 | createdAt: "2026-01-01T00:00:00.000Z", |
| 520 | threadId: ThreadId.make("thread-1"), | 544 | threadId: ThreadId.make("thread-1"), |
| 521 | turnId: asTurnId("turn-1"), | 545 | turnId: asTurnId("turn-1"), |
| 522 | payload: { state: "completed" }, | 546 | payload: { state: "completed" }, |
| 523 | }); | 547 | }); |
| 524 | 548 | ||
| 525 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | 549 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 526 | const thread = await waitForThread( | 550 | type: "checkpoint.diff.finalized", |
| 527 | harness.readModel, | 551 | turnId: "turn-1", |
| 528 | (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, | 552 | checkpointTurnCount: 1, |
| 529 | ); | 553 | }); |
| 530 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | 554 | const thread = (yield* Effect.promise(harness.readModel)).threads.find( |
| 531 | expect( | 555 | (entry) => entry.id === "thread-1", |
| 532 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), | 556 | ); |
| 533 | ).toBe(true); | 557 | expect(thread?.checkpoints[0]).toMatchObject({ |
| 534 | expect( | 558 | checkpointTurnCount: 1, |
| 535 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | 559 | files: [ |
| 536 | ).toBe(true); | 560 | { path: "large.txt", kind: "modified", additions: largeFileLineCount, deletions: 0 }, |
| 537 | expect( | 561 | { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, |
| 538 | gitShowFileAtRef( | 562 | ], |
| 539 | harness.cwd, | 563 | }); |
| 540 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | 564 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 541 | "README.md", | 565 | type: "turn.processing.quiesced", |
| 542 | ), | 566 | turnId: "turn-1", |
| 543 | ).toBe("v1\n"); | 567 | checkpointTurnCount: 1, |
| 544 | expect( | 568 | }); |
| 545 | gitShowFileAtRef( | 569 | yield* Effect.promise(harness.drain); |
| 546 | harness.cwd, | 570 | expect( |
| 547 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | 571 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), |
| 548 | "README.md", | 572 | ).toBe(true); |
| 549 | ), | 573 | expect( |
| 550 | ).toBe("v2\n"); | 574 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), |
| 551 | }); | 575 | ).toBe(true); |
| 576 | expect( | ||
| 577 | gitShowFileAtRef( | ||
| 578 | harness.cwd, | ||
| 579 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | ||
| 580 | "README.md", | ||
| 581 | ), | ||
| 582 | ).toBe("v1\n"); | ||
| 583 | expect( | ||
| 584 | gitShowFileAtRef( | ||
| 585 | harness.cwd, | ||
| 586 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | ||
| 587 | "README.md", | ||
| 588 | ), | ||
| 589 | ).toBe("v2\n"); | ||
| 590 | }), | ||
| 591 | ); | ||
| 552 | 592 | ||
| 553 | it("refreshes local git status state on turn completion using the session cwd", async () => { | 593 | it("refreshes local git status state on turn completion using the session cwd", async () => { |
| 554 | const gitStatusRefreshCalls: string[] = []; | 594 | const gitStatusRefreshCalls: string[] = []; |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-2| ... | @@ -21,7 +21,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -21,7 +21,7 @@ import * as Stream from "effect/Stream"; |
| 21 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 21 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 22 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; | 22 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; |
| 23 | 23 | ||
| 24 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; | 24 | import { parseTurnDiffFilesFromNumstat } from "../../checkpointing/Diffs.ts"; |
| 25 | import { | 25 | import { |
| 26 | checkpointRefForThreadTurn, | 26 | checkpointRefForThreadTurn, |
| 27 | resolveThreadWorkspaceCwd, | 27 | resolveThreadWorkspaceCwd, |
| ... | @@ -270,10 +270,11 @@ const make = Effect.gen(function* () { | ... | @@ -270,10 +270,11 @@ const make = Effect.gen(function* () { |
| 270 | toCheckpointRef: targetCheckpointRef, | 270 | toCheckpointRef: targetCheckpointRef, |
| 271 | fallbackFromToHead: false, | 271 | fallbackFromToHead: false, |
| 272 | ignoreWhitespace: false, | 272 | ignoreWhitespace: false, |
| 273 | format: "numstat", | ||
| 273 | }) | 274 | }) |
| 274 | .pipe( | 275 | .pipe( |
| 275 | Effect.map((diff) => | 276 | Effect.map((diff) => |
| 276 | parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ | 277 | parseTurnDiffFilesFromNumstat(diff).map((file) => ({ |
| 277 | path: file.path, | 278 | path: file.path, |
| 278 | kind: "modified" as const, | 279 | kind: "modified" as const, |
| 279 | additions: file.additions, | 280 | additions: file.additions, |
apps/server/src/vcs/GitVcsDriver.test.ts+4| ... | @@ -68,6 +68,7 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ | ... | @@ -68,6 +68,7 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ |
| 68 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { | 68 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 69 | let observedEnv: NodeJS.ProcessEnv | undefined; | 69 | let observedEnv: NodeJS.ProcessEnv | undefined; |
| 70 | let observedAppendTruncationMarker: boolean | undefined; | 70 | let observedAppendTruncationMarker: boolean | undefined; |
| 71 | let observedOutputMode: VcsProcess.VcsProcessInput["outputMode"]; | ||
| 71 | 72 | ||
| 72 | return Effect.gen(function* () { | 73 | return Effect.gen(function* () { |
| 73 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | 74 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| ... | @@ -80,12 +81,14 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { | ... | @@ -80,12 +81,14 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 80 | GIT_INDEX_FILE: "/tmp/t3-index", | 81 | GIT_INDEX_FILE: "/tmp/t3-index", |
| 81 | }, | 82 | }, |
| 82 | appendTruncationMarker: true, | 83 | appendTruncationMarker: true, |
| 84 | outputMode: "error", | ||
| 83 | }); | 85 | }); |
| 84 | 86 | ||
| 85 | assert.deepStrictEqual(observedEnv, { | 87 | assert.deepStrictEqual(observedEnv, { |
| 86 | GIT_INDEX_FILE: "/tmp/t3-index", | 88 | GIT_INDEX_FILE: "/tmp/t3-index", |
| 87 | }); | 89 | }); |
| 88 | assert.strictEqual(observedAppendTruncationMarker, true); | 90 | assert.strictEqual(observedAppendTruncationMarker, true); |
| 91 | assert.strictEqual(observedOutputMode, "error"); | ||
| 89 | }).pipe( | 92 | }).pipe( |
| 90 | Effect.provide( | 93 | Effect.provide( |
| 91 | Layer.mergeAll( | 94 | Layer.mergeAll( |
| ... | @@ -95,6 +98,7 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { | ... | @@ -95,6 +98,7 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 95 | Effect.sync(() => { | 98 | Effect.sync(() => { |
| 96 | observedEnv = input.env; | 99 | observedEnv = input.env; |
| 97 | observedAppendTruncationMarker = input.appendTruncationMarker; | 100 | observedAppendTruncationMarker = input.appendTruncationMarker; |
| 101 | observedOutputMode = input.outputMode; | ||
| 98 | return { | 102 | return { |
| 99 | exitCode: ChildProcessSpawner.ExitCode(0), | 103 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 100 | stdout: "", | 104 | stdout: "", |
apps/server/src/vcs/GitVcsDriver.ts+6-1| ... | @@ -431,6 +431,7 @@ const gitCommand = ( | ... | @@ -431,6 +431,7 @@ const gitCommand = ( |
| 431 | readonly allowNonZeroExit?: boolean; | 431 | readonly allowNonZeroExit?: boolean; |
| 432 | readonly timeoutMs?: number; | 432 | readonly timeoutMs?: number; |
| 433 | readonly maxOutputBytes?: number; | 433 | readonly maxOutputBytes?: number; |
| 434 | readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; | ||
| 434 | readonly appendTruncationMarker?: boolean; | 435 | readonly appendTruncationMarker?: boolean; |
| 435 | }, | 436 | }, |
| 436 | ) => | 437 | ) => |
| ... | @@ -447,6 +448,7 @@ const gitCommand = ( | ... | @@ -447,6 +448,7 @@ const gitCommand = ( |
| 447 | : {}), | 448 | : {}), |
| 448 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | 449 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 449 | ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | 450 | ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| 451 | ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}), | ||
| 450 | ...(options?.appendTruncationMarker !== undefined | 452 | ...(options?.appendTruncationMarker !== undefined |
| 451 | ? { appendTruncationMarker: options.appendTruncationMarker } | 453 | ? { appendTruncationMarker: options.appendTruncationMarker } |
| 452 | : {}), | 454 | : {}), |
| ... | @@ -485,6 +487,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -485,6 +487,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 485 | ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), | 487 | ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), |
| 486 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), | 488 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), |
| 487 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), | 489 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), |
| 490 | ...(input.outputMode !== undefined ? { outputMode: input.outputMode } : {}), | ||
| 488 | ...(input.appendTruncationMarker !== undefined | 491 | ...(input.appendTruncationMarker !== undefined |
| 489 | ? { appendTruncationMarker: input.appendTruncationMarker } | 492 | ? { appendTruncationMarker: input.appendTruncationMarker } |
| 490 | : {}), | 493 | : {}), |
| ... | @@ -838,6 +841,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -838,6 +841,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 838 | "checkpoint.from_ref": input.fromCheckpointRef, | 841 | "checkpoint.from_ref": input.fromCheckpointRef, |
| 839 | "checkpoint.to_ref": input.toCheckpointRef, | 842 | "checkpoint.to_ref": input.toCheckpointRef, |
| 840 | "checkpoint.ignore_whitespace": input.ignoreWhitespace, | 843 | "checkpoint.ignore_whitespace": input.ignoreWhitespace, |
| 844 | "checkpoint.format": input.format ?? "patch", | ||
| 841 | "checkpoint.fallback_from_to_head": input.fallbackFromToHead, | 845 | "checkpoint.fallback_from_to_head": input.fallbackFromToHead, |
| 842 | }); | 846 | }); |
| 843 | 847 | ||
| ... | @@ -869,7 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -869,7 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 869 | cwd: input.cwd, | 873 | cwd: input.cwd, |
| 870 | args: [ | 874 | args: [ |
| 871 | "diff", | 875 | "diff", |
| 872 | "--patch", | 876 | ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]), |
| 873 | "--no-color", | 877 | "--no-color", |
| 874 | "--no-ext-diff", | 878 | "--no-ext-diff", |
| 875 | "--no-textconv", | 879 | "--no-textconv", |
| ... | @@ -880,6 +884,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( | ... | @@ -880,6 +884,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 880 | ], | 884 | ], |
| 881 | allowNonZeroExit: true, | 885 | allowNonZeroExit: true, |
| 882 | maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, | 886 | maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, |
| 887 | outputMode: input.format === "numstat" ? "error" : "truncate", | ||
| 883 | }); | 888 | }); |
| 884 | 889 | ||
| 885 | if (result.exitCode !== 0) { | 890 | if (result.exitCode !== 0) { |
apps/server/src/vcs/VcsDriver.ts+1| ... | @@ -31,6 +31,7 @@ export interface VcsDiffCheckpointsInput { | ... | @@ -31,6 +31,7 @@ export interface VcsDiffCheckpointsInput { |
| 31 | readonly toCheckpointRef: CheckpointRef; | 31 | readonly toCheckpointRef: CheckpointRef; |
| 32 | readonly fallbackFromToHead?: boolean; | 32 | readonly fallbackFromToHead?: boolean; |
| 33 | readonly ignoreWhitespace: boolean; | 33 | readonly ignoreWhitespace: boolean; |
| 34 | readonly format?: "patch" | "numstat"; | ||
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | export interface VcsDeleteCheckpointRefsInput { | 37 | export interface VcsDeleteCheckpointRefsInput { |
apps/server/src/vcs/VcsProcess.test.ts+20-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 3 | import { HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; | ||
| 3 | import * as Duration from "effect/Duration"; | 4 | import * as Duration from "effect/Duration"; |
| 4 | import * as Deferred from "effect/Deferred"; | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| ... | @@ -359,6 +360,24 @@ describe("VcsProcess.run", () => { | ... | @@ -359,6 +360,24 @@ describe("VcsProcess.run", () => { |
| 359 | }).pipe(provideLive), | 360 | }).pipe(provideLive), |
| 360 | ); | 361 | ); |
| 361 | 362 | ||
| 363 | it.effect("fails with measured byte counts when output must not be truncated", () => | ||
| 364 | Effect.gen(function* () { | ||
| 365 | const error = yield* run({ | ||
| 366 | operation: "test.output-limit", | ||
| 367 | command: "node", | ||
| 368 | args: ["-e", "process.stdout.write('x'.repeat(2048))"], | ||
| 369 | cwd: yield* HostProcessWorkingDirectory, | ||
| 370 | maxOutputBytes: 128, | ||
| 371 | outputMode: "error", | ||
| 372 | }).pipe(Effect.flip); | ||
| 373 | |||
| 374 | assert(error._tag === "VcsProcessOutputLimitError"); | ||
| 375 | expect(error.stream).toBe("stdout"); | ||
| 376 | expect(error.maxBytes).toBe(128); | ||
| 377 | expect(error.observedBytes).toBeGreaterThan(error.maxBytes); | ||
| 378 | }).pipe(provideLive), | ||
| 379 | ); | ||
| 380 | |||
| 362 | it.effect("fails with VcsProcessTimeoutError on timeout", () => | 381 | it.effect("fails with VcsProcessTimeoutError on timeout", () => |
| 363 | Effect.gen(function* () { | 382 | Effect.gen(function* () { |
| 364 | const errorFiber = yield* run({ | 383 | const errorFiber = yield* run({ |
apps/server/src/vcs/VcsProcess.ts+2-1| ... | @@ -29,6 +29,7 @@ export interface VcsProcessInput { | ... | @@ -29,6 +29,7 @@ export interface VcsProcessInput { |
| 29 | readonly allowNonZeroExit?: boolean; | 29 | readonly allowNonZeroExit?: boolean; |
| 30 | readonly timeoutMs?: number; | 30 | readonly timeoutMs?: number; |
| 31 | readonly maxOutputBytes?: number; | 31 | readonly maxOutputBytes?: number; |
| 32 | readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; | ||
| 32 | readonly appendTruncationMarker?: boolean; | 33 | readonly appendTruncationMarker?: boolean; |
| 33 | } | 34 | } |
| 34 | 35 | ||
| ... | @@ -125,7 +126,7 @@ export const make = Effect.gen(function* () { | ... | @@ -125,7 +126,7 @@ export const make = Effect.gen(function* () { |
| 125 | ...(input.env !== undefined ? { env: input.env } : {}), | 126 | ...(input.env !== undefined ? { env: input.env } : {}), |
| 126 | timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | 127 | timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 127 | maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, | 128 | maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, |
| 128 | outputMode: "truncate", | 129 | outputMode: input.outputMode ?? "truncate", |
| 129 | truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", | 130 | truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", |
| 130 | timeoutBehavior: "error", | 131 | timeoutBehavior: "error", |
| 131 | }) | 132 | }) |
docs/internals/glossary.md+1-1| ... | @@ -147,7 +147,7 @@ The starting checkpoint for diffing a thread timeline. This flow is surfaced thr | ... | @@ -147,7 +147,7 @@ The starting checkpoint for diffing a thread timeline. This flow is surfaced thr |
| 147 | 147 | ||
| 148 | #### Checkpoint diff | 148 | #### Checkpoint diff |
| 149 | 149 | ||
| 150 | The patch difference between two checkpoints. Query logic lives in [CheckpointDiffQuery.ts][20], diff parsing lives in [Diffs.ts][23], and finalization is coordinated by [CheckpointReactor.ts][6]. | 150 | The difference between two checkpoints. [CheckpointDiffQuery.ts][20] reads full patches on demand. [CheckpointReactor.ts][6] uses NUL-delimited Git numstat output for automatic file summaries, parsed by [Diffs.ts][23]. |
| 151 | 151 | ||
| 152 | #### Turn diff | 152 | #### Turn diff |
| 153 | 153 |
pnpm-lock.yaml-3| ... | @@ -496,9 +496,6 @@ importers: | ... | @@ -496,9 +496,6 @@ importers: |
| 496 | '@opencode-ai/sdk': | 496 | '@opencode-ai/sdk': |
| 497 | specifier: ^1.3.15 | 497 | specifier: ^1.3.15 |
| 498 | version: 1.15.13 | 498 | version: 1.15.13 |
| 499 | '@pierre/diffs': | ||
| 500 | specifier: 'catalog:' | ||
| 501 | version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 502 | effect: | 499 | effect: |
| 503 | specifier: 4.0.0-beta.103 | 500 | specifier: 4.0.0-beta.103 |
| 504 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) | 501 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) |