| 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 | 28 | "@effect/sql-sqlite-bun": "catalog:", |
| 29 | 29 | "@ff-labs/fff-node": "0.9.4", |
| 30 | 30 | "@opencode-ai/sdk": "^1.3.15", |
| 31 | "@pierre/diffs": "catalog:", | |
| 32 | 31 | "effect": "catalog:", |
| 33 | 32 | "msgpackr-extract": "3.0.4", |
| 34 | 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 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | 5 | import { it } from "@effect/vitest"; |
| 6 | 6 | import { ThreadId, type VcsError } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | 8 | import * as Effect from "effect/Effect"; |
| 8 | 9 | import * as FileSystem from "effect/FileSystem"; |
| 9 | 10 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -12,6 +13,7 @@ import * as Scope from "effect/Scope"; |
| 12 | 13 | import { describe, expect } from "vite-plus/test"; |
| 13 | 14 | |
| 14 | 15 | import { checkpointRefForThreadTurn } from "./Utils.ts"; |
| 16 | import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; | |
| 15 | 17 | import * as CheckpointStore from "./CheckpointStore.ts"; |
| 16 | 18 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 17 | 19 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| ... | ... | @@ -250,6 +252,182 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { |
| 250 | 252 | expect(whitespaceIgnoredDiff).toContain("+ <div>"); |
| 251 | 253 | expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>"); |
| 252 | 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 | 39 | readonly toCheckpointRef: CheckpointRef; |
| 40 | 40 | readonly fallbackFromToHead?: boolean; |
| 41 | 41 | readonly ignoreWhitespace: boolean; |
| 42 | readonly format?: "patch" | "numstat"; | |
| 42 | 43 | } |
| 43 | 44 | |
| 44 | 45 | export interface DeleteCheckpointRefsInput { |
| ... | ... | @@ -77,8 +78,9 @@ export class CheckpointStore extends Context.Service< |
| 77 | 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 | 84 | * Can optionally treat a missing "from" ref as `HEAD`. |
| 83 | 85 | */ |
| 84 | 86 | readonly diffCheckpoints: ( |
apps/server/src/checkpointing/Diffs.test.ts+34-48| ... | ... | @@ -1,68 +1,54 @@ |
| 1 | 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", () => { | |
| 6 | it("returns empty list for empty diff", () => { | |
| 7 | expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]); | |
| 5 | describe("parseTurnDiffFilesFromNumstat", () => { | |
| 6 | it("returns an empty list when no files changed", () => { | |
| 7 | expect(parseTurnDiffFilesFromNumstat("")).toEqual([]); | |
| 8 | 8 | }); |
| 9 | 9 | |
| 10 | it("parses per-file additions and deletions", () => { | |
| 11 | const diff = [ | |
| 12 | "diff --git a/a.txt b/a.txt", | |
| 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([ | |
| 10 | it("sorts files and preserves addition and deletion counts", () => { | |
| 11 | const numstat = ["0\t2\tsrc/b.ts", "2\t1\ta.txt", ""].join("\0"); | |
| 12 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ | |
| 32 | 13 | { path: "a.txt", additions: 2, deletions: 1 }, |
| 33 | 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", () => { | |
| 38 | const diff = [ | |
| 39 | "diff --git a/src/old.ts b/src/new.ts", | |
| 40 | "similarity index 100%", | |
| 41 | "rename from src/old.ts", | |
| 42 | "rename to src/new.ts", | |
| 18 | it("uses destination paths for renames and copies", () => { | |
| 19 | const numstat = [ | |
| 20 | "0\t0\t", | |
| 21 | "src/old.ts", | |
| 22 | "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 | 33 | { path: "src/new.ts", additions: 0, deletions: 0 }, |
| 48 | 34 | ]); |
| 49 | 35 | }); |
| 50 | 36 | |
| 51 | it("normalizes CRLF input before parsing", () => { | |
| 52 | const diff = [ | |
| 53 | "diff --git a/a.txt b/a.txt", | |
| 54 | "index 1111111..2222222 100644", | |
| 55 | "--- a/a.txt", | |
| 56 | "+++ b/a.txt", | |
| 57 | "@@ -1 +1,2 @@", | |
| 58 | "-one", | |
| 59 | "+one updated", | |
| 60 | "+two", | |
| 61 | "", | |
| 62 | ].join("\r\n"); | |
| 37 | it("keeps binary files and empty files with zero line changes", () => { | |
| 38 | const numstat = ["-\t-\timage.png", "0\t0\tempty.txt", ""].join("\0"); | |
| 39 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ | |
| 40 | { path: "empty.txt", additions: 0, deletions: 0 }, | |
| 41 | { path: "image.png", additions: 0, deletions: 0 }, | |
| 42 | ]); | |
| 43 | }); | |
| 63 | 44 | |
| 64 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | |
| 65 | { path: "a.txt", additions: 2, deletions: 1 }, | |
| 45 | it("preserves Unicode, tabs, line endings, and spaces in paths", () => { | |
| 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 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | |
| 2 | ||
| 3 | 1 | export interface TurnDiffFileSummary { |
| 4 | 2 | readonly path: string; |
| 5 | 3 | readonly additions: number; |
| 6 | 4 | readonly deletions: number; |
| 7 | 5 | } |
| 8 | 6 | |
| 9 | export function parseTurnDiffFilesFromUnifiedDiff( | |
| 10 | diff: string, | |
| 11 | ): ReadonlyArray<TurnDiffFileSummary> { | |
| 12 | const normalized = diff.replace(/\r\n/g, "\n").trim(); | |
| 13 | if (normalized.length === 0) { | |
| 14 | return []; | |
| 15 | } | |
| 7 | /** Reads Git's NUL-delimited numstat output without decoding display paths. */ | |
| 8 | export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<TurnDiffFileSummary> { | |
| 9 | const records = numstat.split("\0"); | |
| 10 | const files: TurnDiffFileSummary[] = []; | |
| 11 | ||
| 12 | for (let index = 0; index < records.length; index += 1) { | |
| 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); | |
| 18 | const files = parsedPatches.flatMap((patch) => | |
| 19 | patch.files.map((file) => ({ | |
| 20 | path: file.name, | |
| 21 | additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0), | |
| 22 | deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0), | |
| 23 | })), | |
| 24 | ); | |
| 17 | let path = record.slice(counts[0].length); | |
| 18 | if (path.length === 0) { | |
| 19 | // Renames and copies use two more records: the source and destination. | |
| 20 | path = records[index + 2] ?? ""; | |
| 21 | index += 2; | |
| 22 | } | |
| 23 | if (path.length === 0) continue; | |
| 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 | 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 | 27 | import * as Layer from "effect/Layer"; |
| 28 | 28 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 29 | 29 | import * as PubSub from "effect/PubSub"; |
| 30 | import * as Queue from "effect/Queue"; | |
| 30 | 31 | import * as Scope from "effect/Scope"; |
| 31 | 32 | import * as Stream from "effect/Stream"; |
| 32 | 33 | import { it as effectIt } from "@effect/vitest"; |
| ... | ... | @@ -43,7 +44,8 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 43 | 44 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 44 | 45 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 45 | 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 | 49 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 48 | 50 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 49 | 51 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| ... | ... | @@ -262,7 +264,8 @@ describe("CheckpointReactor", () => { |
| 262 | 264 | | OrchestrationEngineService |
| 263 | 265 | | CheckpointReactor |
| 264 | 266 | | CheckpointStore.CheckpointStore |
| 265 | | ProjectionSnapshotQuery, | |
| 267 | | ProjectionSnapshotQuery | |
| 268 | | RuntimeReceiptBus.RuntimeReceiptBus, | |
| 266 | 269 | unknown |
| 267 | 270 | > | null = null; |
| 268 | 271 | let scope: Scope.Closeable | null = null; |
| ... | ... | @@ -356,7 +359,7 @@ describe("CheckpointReactor", () => { |
| 356 | 359 | const layer = CheckpointReactorLive.pipe( |
| 357 | 360 | Layer.provideMerge(orchestrationLayer), |
| 358 | 361 | Layer.provideMerge(projectionSnapshotLayer), |
| 359 | Layer.provideMerge(RuntimeReceiptBusLive), | |
| 362 | Layer.provideMerge(RuntimeReceiptBusTest), | |
| 360 | 363 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 361 | 364 | Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })), |
| 362 | 365 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| ... | ... | @@ -380,8 +383,21 @@ describe("CheckpointReactor", () => { |
| 380 | 383 | const checkpointStore = await runtime.runPromise( |
| 381 | 384 | Effect.service(CheckpointStore.CheckpointStore), |
| 382 | 385 | ); |
| 383 | scope = await Effect.runPromise(Scope.make("sequential")); | |
| 384 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); | |
| 386 | const receiptBus = await runtime.runPromise( | |
| 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 | 401 | const drain = () => Effect.runPromise(reactor.drain); |
| 386 | 402 | |
| 387 | 403 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| ... | ... | @@ -470,16 +486,19 @@ describe("CheckpointReactor", () => { |
| 470 | 486 | provider, |
| 471 | 487 | cwd, |
| 472 | 488 | drain, |
| 489 | nextReceipt: Queue.take(receipts), | |
| 473 | 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 () => { | |
| 478 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | |
| 479 | const createdAt = "2026-01-01T00:00:00.000Z"; | |
| 494 | effectIt.effect("captures baseline and large turn summaries before completion receipts", () => | |
| 495 | Effect.gen(function* () { | |
| 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( | |
| 482 | harness.engine.dispatch({ | |
| 501 | yield* harness.engine.dispatch({ | |
| 483 | 502 | type: "thread.session.set", |
| 484 | 503 | commandId: CommandId.make("cmd-session-set-capture"), |
| 485 | 504 | threadId: ThreadId.make("thread-1"), |
| ... | ... | @@ -493,62 +512,83 @@ describe("CheckpointReactor", () => { |
| 493 | 512 | updatedAt: createdAt, |
| 494 | 513 | }, |
| 495 | 514 | createdAt, |
| 496 | }), | |
| 497 | ); | |
| 515 | }); | |
| 498 | 516 | |
| 499 | harness.provider.emit({ | |
| 500 | type: "turn.started", | |
| 501 | eventId: EventId.make("evt-turn-started-1"), | |
| 502 | provider: ProviderDriverKind.make("codex"), | |
| 517 | harness.provider.emit({ | |
| 518 | type: "turn.started", | |
| 519 | eventId: EventId.make("evt-turn-started-1"), | |
| 520 | provider: ProviderDriverKind.make("codex"), | |
| 503 | 521 | |
| 504 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 505 | threadId: ThreadId.make("thread-1"), | |
| 506 | turnId: asTurnId("turn-1"), | |
| 507 | }); | |
| 508 | await waitForGitRefExists( | |
| 509 | harness.cwd, | |
| 510 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 511 | ); | |
| 522 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 523 | threadId: ThreadId.make("thread-1"), | |
| 524 | turnId: asTurnId("turn-1"), | |
| 525 | }); | |
| 526 | expect(yield* harness.nextReceipt).toMatchObject({ | |
| 527 | type: "checkpoint.baseline.captured", | |
| 528 | checkpointTurnCount: 0, | |
| 529 | }); | |
| 512 | 530 | |
| 513 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); | |
| 514 | harness.provider.emit({ | |
| 515 | type: "turn.completed", | |
| 516 | eventId: EventId.make("evt-turn-completed-1"), | |
| 517 | provider: ProviderDriverKind.make("codex"), | |
| 531 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); | |
| 532 | const largeFileLineCount = 25_000; | |
| 533 | NodeFS.writeFileSync( | |
| 534 | NodePath.join(harness.cwd, "large.txt"), | |
| 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", | |
| 520 | threadId: ThreadId.make("thread-1"), | |
| 521 | turnId: asTurnId("turn-1"), | |
| 522 | payload: { state: "completed" }, | |
| 523 | }); | |
| 543 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 544 | threadId: ThreadId.make("thread-1"), | |
| 545 | turnId: asTurnId("turn-1"), | |
| 546 | payload: { state: "completed" }, | |
| 547 | }); | |
| 524 | 548 | |
| 525 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | |
| 526 | const thread = await waitForThread( | |
| 527 | harness.readModel, | |
| 528 | (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, | |
| 529 | ); | |
| 530 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | |
| 531 | expect( | |
| 532 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), | |
| 533 | ).toBe(true); | |
| 534 | expect( | |
| 535 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | |
| 536 | ).toBe(true); | |
| 537 | expect( | |
| 538 | gitShowFileAtRef( | |
| 539 | harness.cwd, | |
| 540 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 541 | "README.md", | |
| 542 | ), | |
| 543 | ).toBe("v1\n"); | |
| 544 | expect( | |
| 545 | gitShowFileAtRef( | |
| 546 | harness.cwd, | |
| 547 | checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 548 | "README.md", | |
| 549 | ), | |
| 550 | ).toBe("v2\n"); | |
| 551 | }); | |
| 549 | expect(yield* harness.nextReceipt).toMatchObject({ | |
| 550 | type: "checkpoint.diff.finalized", | |
| 551 | turnId: "turn-1", | |
| 552 | checkpointTurnCount: 1, | |
| 553 | }); | |
| 554 | const thread = (yield* Effect.promise(harness.readModel)).threads.find( | |
| 555 | (entry) => entry.id === "thread-1", | |
| 556 | ); | |
| 557 | expect(thread?.checkpoints[0]).toMatchObject({ | |
| 558 | checkpointTurnCount: 1, | |
| 559 | files: [ | |
| 560 | { path: "large.txt", kind: "modified", additions: largeFileLineCount, deletions: 0 }, | |
| 561 | { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, | |
| 562 | ], | |
| 563 | }); | |
| 564 | expect(yield* harness.nextReceipt).toMatchObject({ | |
| 565 | type: "turn.processing.quiesced", | |
| 566 | turnId: "turn-1", | |
| 567 | checkpointTurnCount: 1, | |
| 568 | }); | |
| 569 | yield* Effect.promise(harness.drain); | |
| 570 | expect( | |
| 571 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), | |
| 572 | ).toBe(true); | |
| 573 | expect( | |
| 574 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | |
| 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 | 593 | it("refreshes local git status state on turn completion using the session cwd", async () => { |
| 554 | 594 | const gitStatusRefreshCalls: string[] = []; |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-2| ... | ... | @@ -21,7 +21,7 @@ import * as Stream from "effect/Stream"; |
| 21 | 21 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 22 | 22 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; |
| 23 | 23 | |
| 24 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; | |
| 24 | import { parseTurnDiffFilesFromNumstat } from "../../checkpointing/Diffs.ts"; | |
| 25 | 25 | import { |
| 26 | 26 | checkpointRefForThreadTurn, |
| 27 | 27 | resolveThreadWorkspaceCwd, |
| ... | ... | @@ -270,10 +270,11 @@ const make = Effect.gen(function* () { |
| 270 | 270 | toCheckpointRef: targetCheckpointRef, |
| 271 | 271 | fallbackFromToHead: false, |
| 272 | 272 | ignoreWhitespace: false, |
| 273 | format: "numstat", | |
| 273 | 274 | }) |
| 274 | 275 | .pipe( |
| 275 | 276 | Effect.map((diff) => |
| 276 | parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ | |
| 277 | parseTurnDiffFilesFromNumstat(diff).map((file) => ({ | |
| 277 | 278 | path: file.path, |
| 278 | 279 | kind: "modified" as const, |
| 279 | 280 | additions: file.additions, |
apps/server/src/vcs/GitVcsDriver.test.ts+4| ... | ... | @@ -68,6 +68,7 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ |
| 68 | 68 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 69 | 69 | let observedEnv: NodeJS.ProcessEnv | undefined; |
| 70 | 70 | let observedAppendTruncationMarker: boolean | undefined; |
| 71 | let observedOutputMode: VcsProcess.VcsProcessInput["outputMode"]; | |
| 71 | 72 | |
| 72 | 73 | return Effect.gen(function* () { |
| 73 | 74 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); |
| ... | ... | @@ -80,12 +81,14 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 80 | 81 | GIT_INDEX_FILE: "/tmp/t3-index", |
| 81 | 82 | }, |
| 82 | 83 | appendTruncationMarker: true, |
| 84 | outputMode: "error", | |
| 83 | 85 | }); |
| 84 | 86 | |
| 85 | 87 | assert.deepStrictEqual(observedEnv, { |
| 86 | 88 | GIT_INDEX_FILE: "/tmp/t3-index", |
| 87 | 89 | }); |
| 88 | 90 | assert.strictEqual(observedAppendTruncationMarker, true); |
| 91 | assert.strictEqual(observedOutputMode, "error"); | |
| 89 | 92 | }).pipe( |
| 90 | 93 | Effect.provide( |
| 91 | 94 | Layer.mergeAll( |
| ... | ... | @@ -95,6 +98,7 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 95 | 98 | Effect.sync(() => { |
| 96 | 99 | observedEnv = input.env; |
| 97 | 100 | observedAppendTruncationMarker = input.appendTruncationMarker; |
| 101 | observedOutputMode = input.outputMode; | |
| 98 | 102 | return { |
| 99 | 103 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 100 | 104 | stdout: "", |
apps/server/src/vcs/GitVcsDriver.ts+6-1| ... | ... | @@ -431,6 +431,7 @@ const gitCommand = ( |
| 431 | 431 | readonly allowNonZeroExit?: boolean; |
| 432 | 432 | readonly timeoutMs?: number; |
| 433 | 433 | readonly maxOutputBytes?: number; |
| 434 | readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; | |
| 434 | 435 | readonly appendTruncationMarker?: boolean; |
| 435 | 436 | }, |
| 436 | 437 | ) => |
| ... | ... | @@ -447,6 +448,7 @@ const gitCommand = ( |
| 447 | 448 | : {}), |
| 448 | 449 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 449 | 450 | ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| 451 | ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}), | |
| 450 | 452 | ...(options?.appendTruncationMarker !== undefined |
| 451 | 453 | ? { appendTruncationMarker: options.appendTruncationMarker } |
| 452 | 454 | : {}), |
| ... | ... | @@ -485,6 +487,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 485 | 487 | ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), |
| 486 | 488 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), |
| 487 | 489 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), |
| 490 | ...(input.outputMode !== undefined ? { outputMode: input.outputMode } : {}), | |
| 488 | 491 | ...(input.appendTruncationMarker !== undefined |
| 489 | 492 | ? { appendTruncationMarker: input.appendTruncationMarker } |
| 490 | 493 | : {}), |
| ... | ... | @@ -838,6 +841,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 838 | 841 | "checkpoint.from_ref": input.fromCheckpointRef, |
| 839 | 842 | "checkpoint.to_ref": input.toCheckpointRef, |
| 840 | 843 | "checkpoint.ignore_whitespace": input.ignoreWhitespace, |
| 844 | "checkpoint.format": input.format ?? "patch", | |
| 841 | 845 | "checkpoint.fallback_from_to_head": input.fallbackFromToHead, |
| 842 | 846 | }); |
| 843 | 847 | |
| ... | ... | @@ -869,7 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 869 | 873 | cwd: input.cwd, |
| 870 | 874 | args: [ |
| 871 | 875 | "diff", |
| 872 | "--patch", | |
| 876 | ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]), | |
| 873 | 877 | "--no-color", |
| 874 | 878 | "--no-ext-diff", |
| 875 | 879 | "--no-textconv", |
| ... | ... | @@ -880,6 +884,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 880 | 884 | ], |
| 881 | 885 | allowNonZeroExit: true, |
| 882 | 886 | maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, |
| 887 | outputMode: input.format === "numstat" ? "error" : "truncate", | |
| 883 | 888 | }); |
| 884 | 889 | |
| 885 | 890 | if (result.exitCode !== 0) { |
apps/server/src/vcs/VcsDriver.ts+1| ... | ... | @@ -31,6 +31,7 @@ export interface VcsDiffCheckpointsInput { |
| 31 | 31 | readonly toCheckpointRef: CheckpointRef; |
| 32 | 32 | readonly fallbackFromToHead?: boolean; |
| 33 | 33 | readonly ignoreWhitespace: boolean; |
| 34 | readonly format?: "patch" | "numstat"; | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | export interface VcsDeleteCheckpointRefsInput { |
apps/server/src/vcs/VcsProcess.test.ts+20-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 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 | 4 | import * as Duration from "effect/Duration"; |
| 4 | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -359,6 +360,24 @@ describe("VcsProcess.run", () => { |
| 359 | 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 | 381 | it.effect("fails with VcsProcessTimeoutError on timeout", () => |
| 363 | 382 | Effect.gen(function* () { |
| 364 | 383 | const errorFiber = yield* run({ |
apps/server/src/vcs/VcsProcess.ts+2-1| ... | ... | @@ -29,6 +29,7 @@ export interface VcsProcessInput { |
| 29 | 29 | readonly allowNonZeroExit?: boolean; |
| 30 | 30 | readonly timeoutMs?: number; |
| 31 | 31 | readonly maxOutputBytes?: number; |
| 32 | readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; | |
| 32 | 33 | readonly appendTruncationMarker?: boolean; |
| 33 | 34 | } |
| 34 | 35 | |
| ... | ... | @@ -125,7 +126,7 @@ export const make = Effect.gen(function* () { |
| 125 | 126 | ...(input.env !== undefined ? { env: input.env } : {}), |
| 126 | 127 | timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 127 | 128 | maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, |
| 128 | outputMode: "truncate", | |
| 129 | outputMode: input.outputMode ?? "truncate", | |
| 129 | 130 | truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", |
| 130 | 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 | 147 | |
| 148 | 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 | 152 | #### Turn diff |
| 153 | 153 |
pnpm-lock.yaml-3| ... | ... | @@ -496,9 +496,6 @@ importers: |
| 496 | 496 | '@opencode-ai/sdk': |
| 497 | 497 | specifier: ^1.3.15 |
| 498 | 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 | 499 | effect: |
| 503 | 500 | specifier: 4.0.0-beta.103 |
| 504 | 501 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) |