| author | |
| committer | |
| log | 7118e43d16b157dd3079f354bd66ca5d772a9a93 |
| tree | 7c8a186196469b91ddbcbbdf8871bff4fda077ec |
| parent | 4e8ee13d8f54e9aa84f1b15f9d802c30650217ea |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>19 files changed, 1102 insertions(+), 1141 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+4-5| ... | ... | @@ -22,8 +22,7 @@ import * as Schema from "effect/Schema"; |
| 22 | 22 | import * as Scope from "effect/Scope"; |
| 23 | 23 | import * as Stream from "effect/Stream"; |
| 24 | 24 | |
| 25 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; | |
| 26 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; | |
| 25 | import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; | |
| 27 | 26 | import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; |
| 28 | 27 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 29 | 28 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; |
| ... | ... | @@ -180,7 +179,7 @@ export interface OrchestrationIntegrationHarness { |
| 180 | 179 | readonly engine: OrchestrationEngineShape; |
| 181 | 180 | readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; |
| 182 | 181 | readonly providerService: ProviderService["Service"]; |
| 183 | readonly checkpointStore: CheckpointStore["Service"]; | |
| 182 | readonly checkpointStore: CheckpointStore.CheckpointStore["Service"]; | |
| 184 | 183 | readonly checkpointRepository: ProjectionCheckpointRepository["Service"]; |
| 185 | 184 | readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"]; |
| 186 | 185 | readonly waitForThread: ( |
| ... | ... | @@ -296,7 +295,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 296 | 295 | ); |
| 297 | 296 | const providerRegistryLayer = makeProviderRegistryLayer(); |
| 298 | 297 | |
| 299 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); | |
| 298 | const checkpointStoreLayer = CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)); | |
| 300 | 299 | const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; |
| 301 | 300 | const runtimeServicesLayer = Layer.mergeAll( |
| 302 | 301 | projectionSnapshotQueryLayer, |
| ... | ... | @@ -399,7 +398,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 399 | 398 | runtime.runPromise(Effect.service(ProviderService)), |
| 400 | 399 | ).pipe(Effect.orDie); |
| 401 | 400 | const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => |
| 402 | runtime.runPromise(Effect.service(CheckpointStore)), | |
| 401 | runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)), | |
| 403 | 402 | ).pipe(Effect.orDie); |
| 404 | 403 | const checkpointRepository = yield* tryRuntimePromise( |
| 405 | 404 | "load ProjectionCheckpointRepository service", |
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts created+418| ... | ... | @@ -0,0 +1,418 @@ |
| 1 | import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import { describe, expect } from "vite-plus/test"; | |
| 7 | ||
| 8 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 9 | import { checkpointRefForThreadTurn } from "./Utils.ts"; | |
| 10 | import * as CheckpointDiffQuery from "./CheckpointDiffQuery.ts"; | |
| 11 | import * as CheckpointStore from "./CheckpointStore.ts"; | |
| 12 | ||
| 13 | function makeThreadCheckpointContext(input: { | |
| 14 | readonly projectId: ProjectId; | |
| 15 | readonly threadId: ThreadId; | |
| 16 | readonly workspaceRoot: string; | |
| 17 | readonly worktreePath: string | null; | |
| 18 | readonly checkpointTurnCount: number; | |
| 19 | readonly checkpointRef: CheckpointRef; | |
| 20 | }): ProjectionSnapshotQuery.ProjectionThreadCheckpointContext { | |
| 21 | return { | |
| 22 | threadId: input.threadId, | |
| 23 | projectId: input.projectId, | |
| 24 | workspaceRoot: input.workspaceRoot, | |
| 25 | worktreePath: input.worktreePath, | |
| 26 | checkpoints: [ | |
| 27 | { | |
| 28 | turnId: TurnId.make("turn-1"), | |
| 29 | checkpointTurnCount: input.checkpointTurnCount, | |
| 30 | checkpointRef: input.checkpointRef, | |
| 31 | status: "ready", | |
| 32 | files: [], | |
| 33 | assistantMessageId: null, | |
| 34 | completedAt: "2026-01-01T00:00:00.000Z", | |
| 35 | }, | |
| 36 | ], | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | describe("CheckpointDiffQuery.layer", () => { | |
| 41 | it.effect("uses the narrow full-thread context lookup for all-turns diffs", () => | |
| 42 | Effect.gen(function* () { | |
| 43 | const projectId = ProjectId.make("project-full-thread"); | |
| 44 | const threadId = ThreadId.make("thread-full-thread"); | |
| 45 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); | |
| 46 | let getThreadCheckpointContextCalls = 0; | |
| 47 | let getFullThreadDiffContextCalls = 0; | |
| 48 | const diffCheckpointsCalls: Array<{ | |
| 49 | readonly fromCheckpointRef: CheckpointRef; | |
| 50 | readonly toCheckpointRef: CheckpointRef; | |
| 51 | readonly cwd: string; | |
| 52 | readonly ignoreWhitespace: boolean; | |
| 53 | }> = []; | |
| 54 | ||
| 55 | const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { | |
| 56 | isGitRepository: () => Effect.succeed(true), | |
| 57 | captureCheckpoint: () => Effect.void, | |
| 58 | hasCheckpointRef: () => Effect.succeed(true), | |
| 59 | restoreCheckpoint: () => Effect.succeed(true), | |
| 60 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => | |
| 61 | Effect.sync(() => { | |
| 62 | diffCheckpointsCalls.push({ | |
| 63 | fromCheckpointRef, | |
| 64 | toCheckpointRef, | |
| 65 | cwd, | |
| 66 | ignoreWhitespace, | |
| 67 | }); | |
| 68 | return "full thread diff patch"; | |
| 69 | }), | |
| 70 | deleteCheckpointRefs: () => Effect.void, | |
| 71 | }; | |
| 72 | ||
| 73 | const layer = CheckpointDiffQuery.layer.pipe( | |
| 74 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), | |
| 75 | Layer.provideMerge( | |
| 76 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | |
| 77 | getCommandReadModel: () => | |
| 78 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 79 | getSnapshot: () => | |
| 80 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 81 | getShellSnapshot: () => | |
| 82 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 83 | getArchivedShellSnapshot: () => | |
| 84 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 85 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 86 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 87 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 88 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 89 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 90 | getThreadCheckpointContext: () => | |
| 91 | Effect.sync(() => { | |
| 92 | getThreadCheckpointContextCalls += 1; | |
| 93 | return Option.none(); | |
| 94 | }), | |
| 95 | getFullThreadDiffContext: () => | |
| 96 | Effect.sync(() => { | |
| 97 | getFullThreadDiffContextCalls += 1; | |
| 98 | return Option.some({ | |
| 99 | threadId, | |
| 100 | projectId, | |
| 101 | workspaceRoot: "/tmp/workspace", | |
| 102 | worktreePath: "/tmp/worktree", | |
| 103 | latestCheckpointTurnCount: 4, | |
| 104 | toCheckpointRef, | |
| 105 | }); | |
| 106 | }), | |
| 107 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 108 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 109 | }), | |
| 110 | ), | |
| 111 | ); | |
| 112 | ||
| 113 | const result = yield* Effect.gen(function* () { | |
| 114 | const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; | |
| 115 | return yield* query.getFullThreadDiff({ | |
| 116 | threadId, | |
| 117 | toTurnCount: 4, | |
| 118 | ignoreWhitespace: true, | |
| 119 | }); | |
| 120 | }).pipe(Effect.provide(layer)); | |
| 121 | ||
| 122 | expect(getThreadCheckpointContextCalls).toBe(0); | |
| 123 | expect(getFullThreadDiffContextCalls).toBe(1); | |
| 124 | expect(diffCheckpointsCalls).toEqual([ | |
| 125 | { | |
| 126 | cwd: "/tmp/worktree", | |
| 127 | fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), | |
| 128 | toCheckpointRef, | |
| 129 | ignoreWhitespace: true, | |
| 130 | }, | |
| 131 | ]); | |
| 132 | expect(result).toEqual({ | |
| 133 | threadId, | |
| 134 | fromTurnCount: 0, | |
| 135 | toTurnCount: 4, | |
| 136 | diff: "full thread diff patch", | |
| 137 | }); | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("computes diffs using canonical turn-0 checkpoint refs", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const projectId = ProjectId.make("project-1"); | |
| 144 | const threadId = ThreadId.make("thread-1"); | |
| 145 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 146 | const diffCheckpointsCalls: Array<{ | |
| 147 | readonly fromCheckpointRef: CheckpointRef; | |
| 148 | readonly toCheckpointRef: CheckpointRef; | |
| 149 | readonly cwd: string; | |
| 150 | readonly ignoreWhitespace: boolean; | |
| 151 | }> = []; | |
| 152 | ||
| 153 | const threadCheckpointContext = makeThreadCheckpointContext({ | |
| 154 | projectId, | |
| 155 | threadId, | |
| 156 | workspaceRoot: "/tmp/workspace", | |
| 157 | worktreePath: null, | |
| 158 | checkpointTurnCount: 1, | |
| 159 | checkpointRef: toCheckpointRef, | |
| 160 | }); | |
| 161 | ||
| 162 | const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { | |
| 163 | isGitRepository: () => Effect.succeed(true), | |
| 164 | captureCheckpoint: () => Effect.void, | |
| 165 | hasCheckpointRef: () => Effect.succeed(true), | |
| 166 | restoreCheckpoint: () => Effect.succeed(true), | |
| 167 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => | |
| 168 | Effect.sync(() => { | |
| 169 | diffCheckpointsCalls.push({ | |
| 170 | fromCheckpointRef, | |
| 171 | toCheckpointRef, | |
| 172 | cwd, | |
| 173 | ignoreWhitespace, | |
| 174 | }); | |
| 175 | return "diff patch"; | |
| 176 | }), | |
| 177 | deleteCheckpointRefs: () => Effect.void, | |
| 178 | }; | |
| 179 | ||
| 180 | const layer = CheckpointDiffQuery.layer.pipe( | |
| 181 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), | |
| 182 | Layer.provideMerge( | |
| 183 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | |
| 184 | getCommandReadModel: () => | |
| 185 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 186 | getSnapshot: () => | |
| 187 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 188 | getShellSnapshot: () => | |
| 189 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 190 | getArchivedShellSnapshot: () => | |
| 191 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 192 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 193 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 194 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 195 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 196 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 197 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | |
| 198 | getFullThreadDiffContext: () => Effect.die("unused"), | |
| 199 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 200 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 201 | }), | |
| 202 | ), | |
| 203 | ); | |
| 204 | ||
| 205 | const result = yield* Effect.gen(function* () { | |
| 206 | const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; | |
| 207 | return yield* query.getTurnDiff({ | |
| 208 | threadId, | |
| 209 | fromTurnCount: 0, | |
| 210 | toTurnCount: 1, | |
| 211 | ignoreWhitespace: true, | |
| 212 | }); | |
| 213 | }).pipe(Effect.provide(layer)); | |
| 214 | ||
| 215 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); | |
| 216 | expect(diffCheckpointsCalls).toEqual([ | |
| 217 | { | |
| 218 | cwd: "/tmp/workspace", | |
| 219 | fromCheckpointRef: expectedFromRef, | |
| 220 | toCheckpointRef, | |
| 221 | ignoreWhitespace: true, | |
| 222 | }, | |
| 223 | ]); | |
| 224 | expect(result).toEqual({ | |
| 225 | threadId, | |
| 226 | fromTurnCount: 0, | |
| 227 | toTurnCount: 1, | |
| 228 | diff: "diff patch", | |
| 229 | }); | |
| 230 | }), | |
| 231 | ); | |
| 232 | ||
| 233 | it.effect("defaults to hide whitespace changes", () => | |
| 234 | Effect.gen(function* () { | |
| 235 | const projectId = ProjectId.make("project-default-whitespace"); | |
| 236 | const threadId = ThreadId.make("thread-default-whitespace"); | |
| 237 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 238 | const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; | |
| 239 | ||
| 240 | const threadCheckpointContext = makeThreadCheckpointContext({ | |
| 241 | projectId, | |
| 242 | threadId, | |
| 243 | workspaceRoot: "/tmp/workspace", | |
| 244 | worktreePath: null, | |
| 245 | checkpointTurnCount: 1, | |
| 246 | checkpointRef: toCheckpointRef, | |
| 247 | }); | |
| 248 | ||
| 249 | const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { | |
| 250 | isGitRepository: () => Effect.succeed(true), | |
| 251 | captureCheckpoint: () => Effect.void, | |
| 252 | hasCheckpointRef: () => Effect.succeed(true), | |
| 253 | restoreCheckpoint: () => Effect.succeed(true), | |
| 254 | diffCheckpoints: ({ ignoreWhitespace }) => | |
| 255 | Effect.sync(() => { | |
| 256 | diffCheckpointsCalls.push({ ignoreWhitespace }); | |
| 257 | return "diff patch"; | |
| 258 | }), | |
| 259 | deleteCheckpointRefs: () => Effect.void, | |
| 260 | }; | |
| 261 | ||
| 262 | const layer = CheckpointDiffQuery.layer.pipe( | |
| 263 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), | |
| 264 | Layer.provideMerge( | |
| 265 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | |
| 266 | getCommandReadModel: () => | |
| 267 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 268 | getSnapshot: () => | |
| 269 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 270 | getShellSnapshot: () => | |
| 271 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 272 | getArchivedShellSnapshot: () => | |
| 273 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 274 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 275 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 276 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 277 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 278 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 279 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | |
| 280 | getFullThreadDiffContext: () => Effect.die("unused"), | |
| 281 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 282 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 283 | }), | |
| 284 | ), | |
| 285 | ); | |
| 286 | ||
| 287 | yield* Effect.gen(function* () { | |
| 288 | const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; | |
| 289 | return yield* query.getTurnDiff({ | |
| 290 | threadId, | |
| 291 | fromTurnCount: 0, | |
| 292 | toTurnCount: 1, | |
| 293 | }); | |
| 294 | }).pipe(Effect.provide(layer)); | |
| 295 | ||
| 296 | expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); | |
| 297 | }), | |
| 298 | ); | |
| 299 | ||
| 300 | it.effect("does not preflight checkpoint refs before diffing", () => | |
| 301 | Effect.gen(function* () { | |
| 302 | const projectId = ProjectId.make("project-no-preflight"); | |
| 303 | const threadId = ThreadId.make("thread-no-preflight"); | |
| 304 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 305 | let hasCheckpointRefCallCount = 0; | |
| 306 | ||
| 307 | const threadCheckpointContext = makeThreadCheckpointContext({ | |
| 308 | projectId, | |
| 309 | threadId, | |
| 310 | workspaceRoot: "/tmp/workspace", | |
| 311 | worktreePath: null, | |
| 312 | checkpointTurnCount: 1, | |
| 313 | checkpointRef: toCheckpointRef, | |
| 314 | }); | |
| 315 | ||
| 316 | const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { | |
| 317 | isGitRepository: () => Effect.succeed(true), | |
| 318 | captureCheckpoint: () => Effect.void, | |
| 319 | hasCheckpointRef: () => | |
| 320 | Effect.sync(() => { | |
| 321 | hasCheckpointRefCallCount += 1; | |
| 322 | return true; | |
| 323 | }), | |
| 324 | restoreCheckpoint: () => Effect.succeed(true), | |
| 325 | diffCheckpoints: () => Effect.succeed("diff patch"), | |
| 326 | deleteCheckpointRefs: () => Effect.void, | |
| 327 | }; | |
| 328 | ||
| 329 | const layer = CheckpointDiffQuery.layer.pipe( | |
| 330 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), | |
| 331 | Layer.provideMerge( | |
| 332 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | |
| 333 | getCommandReadModel: () => | |
| 334 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 335 | getSnapshot: () => | |
| 336 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 337 | getShellSnapshot: () => | |
| 338 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 339 | getArchivedShellSnapshot: () => | |
| 340 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 341 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 342 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 343 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 344 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 345 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 346 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | |
| 347 | getFullThreadDiffContext: () => Effect.die("unused"), | |
| 348 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 349 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 350 | }), | |
| 351 | ), | |
| 352 | ); | |
| 353 | ||
| 354 | yield* Effect.gen(function* () { | |
| 355 | const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; | |
| 356 | return yield* query.getTurnDiff({ | |
| 357 | threadId, | |
| 358 | fromTurnCount: 0, | |
| 359 | toTurnCount: 1, | |
| 360 | ignoreWhitespace: true, | |
| 361 | }); | |
| 362 | }).pipe(Effect.provide(layer)); | |
| 363 | ||
| 364 | expect(hasCheckpointRefCallCount).toBe(0); | |
| 365 | }), | |
| 366 | ); | |
| 367 | ||
| 368 | it.effect("fails when the thread is missing from the snapshot", () => | |
| 369 | Effect.gen(function* () { | |
| 370 | const threadId = ThreadId.make("thread-missing"); | |
| 371 | ||
| 372 | const checkpointStore: CheckpointStore.CheckpointStore["Service"] = { | |
| 373 | isGitRepository: () => Effect.succeed(true), | |
| 374 | captureCheckpoint: () => Effect.void, | |
| 375 | hasCheckpointRef: () => Effect.succeed(true), | |
| 376 | restoreCheckpoint: () => Effect.succeed(true), | |
| 377 | diffCheckpoints: () => Effect.succeed(""), | |
| 378 | deleteCheckpointRefs: () => Effect.void, | |
| 379 | }; | |
| 380 | ||
| 381 | const layer = CheckpointDiffQuery.layer.pipe( | |
| 382 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), | |
| 383 | Layer.provideMerge( | |
| 384 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | |
| 385 | getCommandReadModel: () => | |
| 386 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 387 | getSnapshot: () => | |
| 388 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 389 | getShellSnapshot: () => | |
| 390 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 391 | getArchivedShellSnapshot: () => | |
| 392 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 393 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 394 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 395 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 396 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 397 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 398 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | |
| 399 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | |
| 400 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 401 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 402 | }), | |
| 403 | ), | |
| 404 | ); | |
| 405 | ||
| 406 | const error = yield* Effect.gen(function* () { | |
| 407 | const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; | |
| 408 | return yield* query.getTurnDiff({ | |
| 409 | threadId, | |
| 410 | fromTurnCount: 0, | |
| 411 | toTurnCount: 1, | |
| 412 | }); | |
| 413 | }).pipe(Effect.provide(layer), Effect.flip); | |
| 414 | ||
| 415 | expect(error.message).toContain("Thread 'thread-missing' not found."); | |
| 416 | }), | |
| 417 | ); | |
| 418 | }); |
apps/server/src/checkpointing/CheckpointDiffQuery.ts created+280| ... | ... | @@ -0,0 +1,280 @@ |
| 1 | /** | |
| 2 | * CheckpointDiffQuery - Query interface for computed checkpoint diffs. | |
| 3 | * | |
| 4 | * Provides read-only diff operations across checkpoint snapshots used by | |
| 5 | * orchestration APIs. | |
| 6 | * | |
| 7 | * @module CheckpointDiffQuery | |
| 8 | */ | |
| 9 | import { | |
| 10 | type CheckpointRef, | |
| 11 | OrchestrationGetTurnDiffResult, | |
| 12 | type OrchestrationGetFullThreadDiffInput, | |
| 13 | type OrchestrationGetFullThreadDiffResult, | |
| 14 | type OrchestrationGetTurnDiffInput, | |
| 15 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, | |
| 16 | type ThreadId, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import * as Context from "effect/Context"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as Layer from "effect/Layer"; | |
| 21 | import * as Option from "effect/Option"; | |
| 22 | import * as Schema from "effect/Schema"; | |
| 23 | ||
| 24 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 25 | import { CheckpointInvariantError, CheckpointUnavailableError } from "./Errors.ts"; | |
| 26 | import type { CheckpointServiceError } from "./Errors.ts"; | |
| 27 | import { checkpointRefForThreadTurn } from "./Utils.ts"; | |
| 28 | import * as CheckpointStore from "./CheckpointStore.ts"; | |
| 29 | ||
| 30 | /** Service tag for checkpoint diff queries. */ | |
| 31 | export class CheckpointDiffQuery extends Context.Service< | |
| 32 | CheckpointDiffQuery, | |
| 33 | { | |
| 34 | /** | |
| 35 | * Read the patch diff for a single turn checkpoint transition. | |
| 36 | * | |
| 37 | * Verifies checkpoint availability in both projection state and filesystem. | |
| 38 | */ | |
| 39 | readonly getTurnDiff: ( | |
| 40 | input: OrchestrationGetTurnDiffInput, | |
| 41 | ) => Effect.Effect<OrchestrationGetTurnDiffResultType, CheckpointServiceError>; | |
| 42 | ||
| 43 | /** | |
| 44 | * Read the full patch diff across a thread range of checkpoints. | |
| 45 | * | |
| 46 | * Uses turn-diff semantics with `fromTurnCount = 0`. | |
| 47 | */ | |
| 48 | readonly getFullThreadDiff: ( | |
| 49 | input: OrchestrationGetFullThreadDiffInput, | |
| 50 | ) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>; | |
| 51 | } | |
| 52 | >()("t3/checkpointing/CheckpointDiffQuery") {} | |
| 53 | ||
| 54 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); | |
| 55 | ||
| 56 | function buildTurnDiffResult( | |
| 57 | input: { | |
| 58 | readonly threadId: ThreadId; | |
| 59 | readonly fromTurnCount: number; | |
| 60 | readonly toTurnCount: number; | |
| 61 | }, | |
| 62 | diff: string, | |
| 63 | ): OrchestrationGetTurnDiffResultType { | |
| 64 | return { | |
| 65 | threadId: input.threadId, | |
| 66 | fromTurnCount: input.fromTurnCount, | |
| 67 | toTurnCount: input.toTurnCount, | |
| 68 | diff, | |
| 69 | }; | |
| 70 | } | |
| 71 | ||
| 72 | export const make = Effect.gen(function* () { | |
| 73 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 74 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | |
| 75 | ||
| 76 | const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")( | |
| 77 | function* (input) { | |
| 78 | const operation = "CheckpointDiffQuery.getTurnDiff"; | |
| 79 | const ignoreWhitespace = input.ignoreWhitespace ?? true; | |
| 80 | yield* Effect.annotateCurrentSpan({ | |
| 81 | "checkpoint.thread_id": input.threadId, | |
| 82 | "checkpoint.from_turn_count": input.fromTurnCount, | |
| 83 | "checkpoint.to_turn_count": input.toTurnCount, | |
| 84 | "checkpoint.ignore_whitespace": ignoreWhitespace, | |
| 85 | }); | |
| 86 | ||
| 87 | if (input.fromTurnCount === input.toTurnCount) { | |
| 88 | const emptyDiff: OrchestrationGetTurnDiffResultType = { | |
| 89 | threadId: input.threadId, | |
| 90 | fromTurnCount: input.fromTurnCount, | |
| 91 | toTurnCount: input.toTurnCount, | |
| 92 | diff: "", | |
| 93 | }; | |
| 94 | if (!isTurnDiffResult(emptyDiff)) { | |
| 95 | return yield* new CheckpointInvariantError({ | |
| 96 | operation, | |
| 97 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 98 | }); | |
| 99 | } | |
| 100 | return emptyDiff; | |
| 101 | } | |
| 102 | ||
| 103 | const threadContext = yield* projectionSnapshotQuery | |
| 104 | .getThreadCheckpointContext(input.threadId) | |
| 105 | .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); | |
| 106 | if (Option.isNone(threadContext)) { | |
| 107 | return yield* new CheckpointInvariantError({ | |
| 108 | operation, | |
| 109 | detail: `Thread '${input.threadId}' not found.`, | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | const maxTurnCount = threadContext.value.checkpoints.reduce( | |
| 114 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | |
| 115 | 0, | |
| 116 | ); | |
| 117 | if (input.toTurnCount > maxTurnCount) { | |
| 118 | return yield* new CheckpointUnavailableError({ | |
| 119 | threadId: input.threadId, | |
| 120 | turnCount: input.toTurnCount, | |
| 121 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, | |
| 122 | }); | |
| 123 | } | |
| 124 | ||
| 125 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; | |
| 126 | if (!workspaceCwd) { | |
| 127 | return yield* new CheckpointInvariantError({ | |
| 128 | operation, | |
| 129 | detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, | |
| 130 | }); | |
| 131 | } | |
| 132 | ||
| 133 | const fromCheckpointRef = | |
| 134 | input.fromTurnCount === 0 | |
| 135 | ? checkpointRefForThreadTurn(input.threadId, 0) | |
| 136 | : threadContext.value.checkpoints.find( | |
| 137 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | |
| 138 | )?.checkpointRef; | |
| 139 | if (!fromCheckpointRef) { | |
| 140 | return yield* new CheckpointUnavailableError({ | |
| 141 | threadId: input.threadId, | |
| 142 | turnCount: input.fromTurnCount, | |
| 143 | detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, | |
| 144 | }); | |
| 145 | } | |
| 146 | ||
| 147 | const toCheckpointRef = threadContext.value.checkpoints.find( | |
| 148 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | |
| 149 | )?.checkpointRef; | |
| 150 | if (!toCheckpointRef) { | |
| 151 | return yield* new CheckpointUnavailableError({ | |
| 152 | threadId: input.threadId, | |
| 153 | turnCount: input.toTurnCount, | |
| 154 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 155 | }); | |
| 156 | } | |
| 157 | ||
| 158 | const diff = yield* checkpointStore | |
| 159 | .diffCheckpoints({ | |
| 160 | cwd: workspaceCwd, | |
| 161 | fromCheckpointRef, | |
| 162 | toCheckpointRef, | |
| 163 | fallbackFromToHead: false, | |
| 164 | ignoreWhitespace, | |
| 165 | }) | |
| 166 | .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); | |
| 167 | ||
| 168 | const turnDiff = buildTurnDiffResult(input, diff); | |
| 169 | if (!isTurnDiffResult(turnDiff)) { | |
| 170 | return yield* new CheckpointInvariantError({ | |
| 171 | operation, | |
| 172 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 173 | }); | |
| 174 | } | |
| 175 | ||
| 176 | return turnDiff; | |
| 177 | }, | |
| 178 | ); | |
| 179 | ||
| 180 | const getFullThreadDiff: CheckpointDiffQuery["Service"]["getFullThreadDiff"] = Effect.fn( | |
| 181 | "CheckpointDiffQuery.getFullThreadDiff", | |
| 182 | )(function* (input) { | |
| 183 | const operation = "CheckpointDiffQuery.getFullThreadDiff"; | |
| 184 | const ignoreWhitespace = input.ignoreWhitespace ?? true; | |
| 185 | yield* Effect.annotateCurrentSpan({ | |
| 186 | "checkpoint.thread_id": input.threadId, | |
| 187 | "checkpoint.from_turn_count": 0, | |
| 188 | "checkpoint.to_turn_count": input.toTurnCount, | |
| 189 | "checkpoint.ignore_whitespace": ignoreWhitespace, | |
| 190 | "checkpoint.diff_kind": "full-thread", | |
| 191 | }); | |
| 192 | ||
| 193 | if (input.toTurnCount === 0) { | |
| 194 | const emptyDiff = buildTurnDiffResult( | |
| 195 | { | |
| 196 | threadId: input.threadId, | |
| 197 | fromTurnCount: 0, | |
| 198 | toTurnCount: 0, | |
| 199 | }, | |
| 200 | "", | |
| 201 | ); | |
| 202 | if (!isTurnDiffResult(emptyDiff)) { | |
| 203 | return yield* new CheckpointInvariantError({ | |
| 204 | operation, | |
| 205 | detail: "Computed full thread diff result does not satisfy contract schema.", | |
| 206 | }); | |
| 207 | } | |
| 208 | return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; | |
| 209 | } | |
| 210 | ||
| 211 | const threadContext = yield* projectionSnapshotQuery | |
| 212 | .getFullThreadDiffContext(input.threadId, input.toTurnCount) | |
| 213 | .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); | |
| 214 | ||
| 215 | if (Option.isNone(threadContext)) { | |
| 216 | return yield* new CheckpointInvariantError({ | |
| 217 | operation, | |
| 218 | detail: `Thread '${input.threadId}' not found.`, | |
| 219 | }); | |
| 220 | } | |
| 221 | ||
| 222 | if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { | |
| 223 | return yield* new CheckpointUnavailableError({ | |
| 224 | threadId: input.threadId, | |
| 225 | turnCount: input.toTurnCount, | |
| 226 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, | |
| 227 | }); | |
| 228 | } | |
| 229 | ||
| 230 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; | |
| 231 | if (!workspaceCwd) { | |
| 232 | return yield* new CheckpointInvariantError({ | |
| 233 | operation, | |
| 234 | detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, | |
| 235 | }); | |
| 236 | } | |
| 237 | ||
| 238 | if (!threadContext.value.toCheckpointRef) { | |
| 239 | return yield* new CheckpointUnavailableError({ | |
| 240 | threadId: input.threadId, | |
| 241 | turnCount: input.toTurnCount, | |
| 242 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 243 | }); | |
| 244 | } | |
| 245 | ||
| 246 | const diff = yield* checkpointStore | |
| 247 | .diffCheckpoints({ | |
| 248 | cwd: workspaceCwd, | |
| 249 | fromCheckpointRef: checkpointRefForThreadTurn(input.threadId, 0), | |
| 250 | toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef, | |
| 251 | fallbackFromToHead: false, | |
| 252 | ignoreWhitespace, | |
| 253 | }) | |
| 254 | .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints")); | |
| 255 | ||
| 256 | const turnDiff = buildTurnDiffResult( | |
| 257 | { | |
| 258 | threadId: input.threadId, | |
| 259 | fromTurnCount: 0, | |
| 260 | toTurnCount: input.toTurnCount, | |
| 261 | }, | |
| 262 | diff, | |
| 263 | ); | |
| 264 | if (!isTurnDiffResult(turnDiff)) { | |
| 265 | return yield* new CheckpointInvariantError({ | |
| 266 | operation, | |
| 267 | detail: "Computed full thread diff result does not satisfy contract schema.", | |
| 268 | }); | |
| 269 | } | |
| 270 | ||
| 271 | return turnDiff satisfies OrchestrationGetFullThreadDiffResult; | |
| 272 | }); | |
| 273 | ||
| 274 | return CheckpointDiffQuery.of({ | |
| 275 | getTurnDiff, | |
| 276 | getFullThreadDiff, | |
| 277 | }); | |
| 278 | }); | |
| 279 | ||
| 280 | export const layer = Layer.effect(CheckpointDiffQuery, make); |
apps/server/src/checkpointing/CheckpointStore.test.ts created+204| ... | ... | @@ -0,0 +1,204 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import { ThreadId, type VcsError } from "@t3tools/contracts"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | import * as Scope from "effect/Scope"; | |
| 12 | import { describe, expect } from "vite-plus/test"; | |
| 13 | ||
| 14 | import { checkpointRefForThreadTurn } from "./Utils.ts"; | |
| 15 | import * as CheckpointStore from "./CheckpointStore.ts"; | |
| 16 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 18 | import * as ServerConfig from "../config.ts"; | |
| 19 | ||
| 20 | const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { | |
| 21 | prefix: "t3-checkpoint-store-test-", | |
| 22 | }); | |
| 23 | const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); | |
| 24 | const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); | |
| 25 | const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( | |
| 26 | Layer.provideMerge(VcsDriverTestLayer), | |
| 27 | Layer.provideMerge(NodeServices.layer), | |
| 28 | ); | |
| 29 | const TestLayer = CheckpointStoreTestLayer.pipe( | |
| 30 | Layer.provideMerge(VcsProcessTestLayer), | |
| 31 | Layer.provideMerge(VcsDriverTestLayer), | |
| 32 | Layer.provideMerge(ServerConfigLayer), | |
| 33 | Layer.provideMerge(NodeServices.layer), | |
| 34 | ); | |
| 35 | ||
| 36 | function makeTmpDir( | |
| 37 | prefix = "checkpoint-store-test-", | |
| 38 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | |
| 39 | return Effect.gen(function* () { | |
| 40 | const fileSystem = yield* FileSystem.FileSystem; | |
| 41 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | |
| 42 | }); | |
| 43 | } | |
| 44 | ||
| 45 | function writeTextFile( | |
| 46 | filePath: string, | |
| 47 | contents: string, | |
| 48 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 49 | return Effect.gen(function* () { | |
| 50 | const fileSystem = yield* FileSystem.FileSystem; | |
| 51 | yield* fileSystem.writeFileString(filePath, contents); | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | function git( | |
| 56 | cwd: string, | |
| 57 | args: ReadonlyArray<string>, | |
| 58 | ): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> { | |
| 59 | return Effect.gen(function* () { | |
| 60 | const process = yield* VcsProcess.VcsProcess; | |
| 61 | const result = yield* process.run({ | |
| 62 | operation: "CheckpointStore.test.git", | |
| 63 | command: "git", | |
| 64 | cwd, | |
| 65 | args, | |
| 66 | timeoutMs: 10_000, | |
| 67 | }); | |
| 68 | return result.stdout.trim(); | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | function initRepoWithCommit( | |
| 73 | cwd: string, | |
| 74 | ): Effect.Effect< | |
| 75 | void, | |
| 76 | VcsError | PlatformError.PlatformError, | |
| 77 | VcsProcess.VcsProcess | FileSystem.FileSystem | |
| 78 | > { | |
| 79 | return Effect.gen(function* () { | |
| 80 | yield* git(cwd, ["init"]); | |
| 81 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | |
| 82 | yield* git(cwd, ["config", "user.name", "Test"]); | |
| 83 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); | |
| 84 | yield* git(cwd, ["add", "."]); | |
| 85 | yield* git(cwd, ["commit", "-m", "initial commit"]); | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | function buildLargeText(lineCount = 5_000): string { | |
| 90 | return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) | |
| 91 | .join("\n") | |
| 92 | .concat("\n"); | |
| 93 | } | |
| 94 | ||
| 95 | it.layer(TestLayer)("CheckpointStore.layer", (it) => { | |
| 96 | describe("diffCheckpoints", () => { | |
| 97 | it.effect("returns full oversized checkpoint diffs without truncation", () => | |
| 98 | Effect.gen(function* () { | |
| 99 | const tmp = yield* makeTmpDir(); | |
| 100 | yield* initRepoWithCommit(tmp); | |
| 101 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | |
| 102 | const threadId = ThreadId.make("thread-checkpoint-store"); | |
| 103 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); | |
| 104 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 105 | ||
| 106 | yield* checkpointStore.captureCheckpoint({ | |
| 107 | cwd: tmp, | |
| 108 | checkpointRef: fromCheckpointRef, | |
| 109 | }); | |
| 110 | yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); | |
| 111 | yield* checkpointStore.captureCheckpoint({ | |
| 112 | cwd: tmp, | |
| 113 | checkpointRef: toCheckpointRef, | |
| 114 | }); | |
| 115 | ||
| 116 | const diff = yield* checkpointStore.diffCheckpoints({ | |
| 117 | cwd: tmp, | |
| 118 | fromCheckpointRef, | |
| 119 | toCheckpointRef, | |
| 120 | ignoreWhitespace: true, | |
| 121 | }); | |
| 122 | ||
| 123 | expect(diff).toContain("diff --git"); | |
| 124 | expect(diff).not.toContain("[truncated]"); | |
| 125 | expect(diff).toContain("+line 04999"); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | it.effect("can hide indentation churn when changes wrap existing lines", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | const tmp = yield* makeTmpDir(); | |
| 132 | yield* initRepoWithCommit(tmp); | |
| 133 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | |
| 134 | const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); | |
| 135 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); | |
| 136 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 137 | ||
| 138 | const componentPath = path.join(tmp, "Component.tsx"); | |
| 139 | yield* writeTextFile( | |
| 140 | componentPath, | |
| 141 | [ | |
| 142 | "export function View() {", | |
| 143 | " return (", | |
| 144 | " <section>", | |
| 145 | " <h1>Title</h1>", | |
| 146 | " <p>Body</p>", | |
| 147 | " </section>", | |
| 148 | " );", | |
| 149 | "}", | |
| 150 | "", | |
| 151 | ].join("\n"), | |
| 152 | ); | |
| 153 | yield* checkpointStore.captureCheckpoint({ | |
| 154 | cwd: tmp, | |
| 155 | checkpointRef: fromCheckpointRef, | |
| 156 | }); | |
| 157 | yield* writeTextFile( | |
| 158 | componentPath, | |
| 159 | [ | |
| 160 | "export function View() {", | |
| 161 | " return (", | |
| 162 | " <section>", | |
| 163 | " {isReady ? (", | |
| 164 | " <div>", | |
| 165 | " <h1>Title</h1>", | |
| 166 | " <p>Body</p>", | |
| 167 | " </div>", | |
| 168 | " ) : null}", | |
| 169 | " </section>", | |
| 170 | " );", | |
| 171 | "}", | |
| 172 | "", | |
| 173 | ].join("\n"), | |
| 174 | ); | |
| 175 | yield* checkpointStore.captureCheckpoint({ | |
| 176 | cwd: tmp, | |
| 177 | checkpointRef: toCheckpointRef, | |
| 178 | }); | |
| 179 | ||
| 180 | const normalDiff = yield* checkpointStore.diffCheckpoints({ | |
| 181 | cwd: tmp, | |
| 182 | fromCheckpointRef, | |
| 183 | toCheckpointRef, | |
| 184 | ignoreWhitespace: false, | |
| 185 | }); | |
| 186 | const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({ | |
| 187 | cwd: tmp, | |
| 188 | fromCheckpointRef, | |
| 189 | toCheckpointRef, | |
| 190 | ignoreWhitespace: true, | |
| 191 | }); | |
| 192 | ||
| 193 | expect(normalDiff).toContain("diff --git"); | |
| 194 | expect(normalDiff).toContain("- <h1>Title</h1>"); | |
| 195 | expect(normalDiff).toContain("+ <h1>Title</h1>"); | |
| 196 | expect(whitespaceIgnoredDiff).toContain("diff --git"); | |
| 197 | expect(whitespaceIgnoredDiff).toContain("+ {isReady ? ("); | |
| 198 | expect(whitespaceIgnoredDiff).toContain("+ <div>"); | |
| 199 | expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>"); | |
| 200 | expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>"); | |
| 201 | }), | |
| 202 | ); | |
| 203 | }); | |
| 204 | }); |
apps/server/src/checkpointing/CheckpointStore.ts created+171| ... | ... | @@ -0,0 +1,171 @@ |
| 1 | /** | |
| 2 | * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. | |
| 3 | * | |
| 4 | * Owns hidden Git-ref checkpoint capture/restore and diff computation for a | |
| 5 | * workspace thread timeline. It does not store user-facing checkpoint metadata | |
| 6 | * and does not coordinate provider conversation rollback. | |
| 7 | * | |
| 8 | * The live adapter resolves the active VCS driver once per checkpoint operation | |
| 9 | * and delegates to the driver's optional checkpoint capability. | |
| 10 | * | |
| 11 | * Uses Effect `Context.Service` for dependency injection and exposes typed | |
| 12 | * domain errors for checkpoint storage operations. | |
| 13 | * | |
| 14 | * @module CheckpointStore | |
| 15 | */ | |
| 16 | import { VcsUnsupportedOperationError, type CheckpointRef } from "@t3tools/contracts"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as Effect from "effect/Effect"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | ||
| 21 | import type { CheckpointStoreError } from "./Errors.ts"; | |
| 22 | import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts"; | |
| 23 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 24 | ||
| 25 | export interface CaptureCheckpointInput { | |
| 26 | readonly cwd: string; | |
| 27 | readonly checkpointRef: CheckpointRef; | |
| 28 | } | |
| 29 | ||
| 30 | export interface RestoreCheckpointInput { | |
| 31 | readonly cwd: string; | |
| 32 | readonly checkpointRef: CheckpointRef; | |
| 33 | readonly fallbackToHead?: boolean; | |
| 34 | } | |
| 35 | ||
| 36 | export interface DiffCheckpointsInput { | |
| 37 | readonly cwd: string; | |
| 38 | readonly fromCheckpointRef: CheckpointRef; | |
| 39 | readonly toCheckpointRef: CheckpointRef; | |
| 40 | readonly fallbackFromToHead?: boolean; | |
| 41 | readonly ignoreWhitespace: boolean; | |
| 42 | } | |
| 43 | ||
| 44 | export interface DeleteCheckpointRefsInput { | |
| 45 | readonly cwd: string; | |
| 46 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; | |
| 47 | } | |
| 48 | ||
| 49 | /** Service tag for checkpoint persistence and restore operations. */ | |
| 50 | export class CheckpointStore extends Context.Service< | |
| 51 | CheckpointStore, | |
| 52 | { | |
| 53 | /** Check whether cwd is inside a Git worktree. */ | |
| 54 | readonly isGitRepository: (cwd: string) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 55 | ||
| 56 | /** | |
| 57 | * Capture a checkpoint commit and store it at the provided checkpoint ref. | |
| 58 | * | |
| 59 | * Uses an isolated temporary Git index and writes a hidden ref. | |
| 60 | */ | |
| 61 | readonly captureCheckpoint: ( | |
| 62 | input: CaptureCheckpointInput, | |
| 63 | ) => Effect.Effect<void, CheckpointStoreError>; | |
| 64 | ||
| 65 | /** Check whether a checkpoint ref exists. */ | |
| 66 | readonly hasCheckpointRef: ( | |
| 67 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, | |
| 68 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 69 | ||
| 70 | /** | |
| 71 | * Restore workspace and staging state to a checkpoint. | |
| 72 | * | |
| 73 | * Optionally falls back to current `HEAD` when the checkpoint ref is missing. | |
| 74 | */ | |
| 75 | readonly restoreCheckpoint: ( | |
| 76 | input: RestoreCheckpointInput, | |
| 77 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 78 | ||
| 79 | /** | |
| 80 | * Compute a patch diff between two checkpoint refs. | |
| 81 | * | |
| 82 | * Can optionally treat a missing "from" ref as `HEAD`. | |
| 83 | */ | |
| 84 | readonly diffCheckpoints: ( | |
| 85 | input: DiffCheckpointsInput, | |
| 86 | ) => Effect.Effect<string, CheckpointStoreError>; | |
| 87 | ||
| 88 | /** | |
| 89 | * Delete the provided checkpoint refs. | |
| 90 | * | |
| 91 | * Best-effort delete: missing refs are tolerated. | |
| 92 | */ | |
| 93 | readonly deleteCheckpointRefs: ( | |
| 94 | input: DeleteCheckpointRefsInput, | |
| 95 | ) => Effect.Effect<void, CheckpointStoreError>; | |
| 96 | } | |
| 97 | >()("t3/checkpointing/CheckpointStore") {} | |
| 98 | ||
| 99 | export const make = Effect.gen(function* () { | |
| 100 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | |
| 101 | ||
| 102 | const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( | |
| 103 | operation: string, | |
| 104 | cwd: string, | |
| 105 | ) { | |
| 106 | const handle = yield* vcsRegistry.resolve({ cwd }); | |
| 107 | if (!handle.driver.checkpoints) { | |
| 108 | return yield* new VcsUnsupportedOperationError({ | |
| 109 | operation, | |
| 110 | kind: handle.kind, | |
| 111 | detail: `${handle.kind} driver does not implement checkpoint operations.`, | |
| 112 | }); | |
| 113 | } | |
| 114 | return handle.driver.checkpoints satisfies VcsCheckpointOps; | |
| 115 | }); | |
| 116 | ||
| 117 | const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => | |
| 118 | vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( | |
| 119 | Effect.map(() => true), | |
| 120 | Effect.orElseSucceed(() => false), | |
| 121 | ); | |
| 122 | ||
| 123 | const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( | |
| 124 | "captureCheckpoint", | |
| 125 | )(function* (input) { | |
| 126 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); | |
| 127 | return yield* checkpoints.captureCheckpoint(input); | |
| 128 | }); | |
| 129 | ||
| 130 | const hasCheckpointRef: CheckpointStore["Service"]["hasCheckpointRef"] = Effect.fn( | |
| 131 | "hasCheckpointRef", | |
| 132 | )(function* (input) { | |
| 133 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); | |
| 134 | return yield* checkpoints.hasCheckpointRef(input); | |
| 135 | }); | |
| 136 | ||
| 137 | const restoreCheckpoint: CheckpointStore["Service"]["restoreCheckpoint"] = Effect.fn( | |
| 138 | "restoreCheckpoint", | |
| 139 | )(function* (input) { | |
| 140 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); | |
| 141 | return yield* checkpoints.restoreCheckpoint(input); | |
| 142 | }); | |
| 143 | ||
| 144 | const diffCheckpoints: CheckpointStore["Service"]["diffCheckpoints"] = Effect.fn( | |
| 145 | "diffCheckpoints", | |
| 146 | )(function* (input) { | |
| 147 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); | |
| 148 | return yield* checkpoints.diffCheckpoints(input); | |
| 149 | }); | |
| 150 | ||
| 151 | const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( | |
| 152 | "deleteCheckpointRefs", | |
| 153 | )(function* (input) { | |
| 154 | const checkpoints = yield* resolveCheckpoints( | |
| 155 | "CheckpointStore.deleteCheckpointRefs", | |
| 156 | input.cwd, | |
| 157 | ); | |
| 158 | return yield* checkpoints.deleteCheckpointRefs(input); | |
| 159 | }); | |
| 160 | ||
| 161 | return CheckpointStore.of({ | |
| 162 | isGitRepository, | |
| 163 | captureCheckpoint, | |
| 164 | hasCheckpointRef, | |
| 165 | restoreCheckpoint, | |
| 166 | diffCheckpoints, | |
| 167 | deleteCheckpointRefs, | |
| 168 | }); | |
| 169 | }); | |
| 170 | ||
| 171 | export const layer = Layer.effect(CheckpointStore, make); |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts deleted-421| ... | ... | @@ -1,421 +0,0 @@ |
| 1 | import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { describe, expect, it } from "vite-plus/test"; | |
| 6 | ||
| 7 | import { | |
| 8 | ProjectionSnapshotQuery, | |
| 9 | type ProjectionThreadCheckpointContext, | |
| 10 | } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 11 | import { checkpointRefForThreadTurn } from "../Utils.ts"; | |
| 12 | import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; | |
| 13 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | |
| 14 | import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; | |
| 15 | ||
| 16 | function makeThreadCheckpointContext(input: { | |
| 17 | readonly projectId: ProjectId; | |
| 18 | readonly threadId: ThreadId; | |
| 19 | readonly workspaceRoot: string; | |
| 20 | readonly worktreePath: string | null; | |
| 21 | readonly checkpointTurnCount: number; | |
| 22 | readonly checkpointRef: CheckpointRef; | |
| 23 | }): ProjectionThreadCheckpointContext { | |
| 24 | return { | |
| 25 | threadId: input.threadId, | |
| 26 | projectId: input.projectId, | |
| 27 | workspaceRoot: input.workspaceRoot, | |
| 28 | worktreePath: input.worktreePath, | |
| 29 | checkpoints: [ | |
| 30 | { | |
| 31 | turnId: TurnId.make("turn-1"), | |
| 32 | checkpointTurnCount: input.checkpointTurnCount, | |
| 33 | checkpointRef: input.checkpointRef, | |
| 34 | status: "ready", | |
| 35 | files: [], | |
| 36 | assistantMessageId: null, | |
| 37 | completedAt: "2026-01-01T00:00:00.000Z", | |
| 38 | }, | |
| 39 | ], | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | describe("CheckpointDiffQueryLive", () => { | |
| 44 | it("uses the narrow full-thread context lookup for all-turns diffs", async () => { | |
| 45 | const projectId = ProjectId.make("project-full-thread"); | |
| 46 | const threadId = ThreadId.make("thread-full-thread"); | |
| 47 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); | |
| 48 | let getThreadCheckpointContextCalls = 0; | |
| 49 | let getFullThreadDiffContextCalls = 0; | |
| 50 | const diffCheckpointsCalls: Array<{ | |
| 51 | readonly fromCheckpointRef: CheckpointRef; | |
| 52 | readonly toCheckpointRef: CheckpointRef; | |
| 53 | readonly cwd: string; | |
| 54 | readonly ignoreWhitespace: boolean; | |
| 55 | }> = []; | |
| 56 | ||
| 57 | const checkpointStore: CheckpointStoreShape = { | |
| 58 | isGitRepository: () => Effect.succeed(true), | |
| 59 | captureCheckpoint: () => Effect.void, | |
| 60 | hasCheckpointRef: () => Effect.succeed(true), | |
| 61 | restoreCheckpoint: () => Effect.succeed(true), | |
| 62 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => | |
| 63 | Effect.sync(() => { | |
| 64 | diffCheckpointsCalls.push({ | |
| 65 | fromCheckpointRef, | |
| 66 | toCheckpointRef, | |
| 67 | cwd, | |
| 68 | ignoreWhitespace, | |
| 69 | }); | |
| 70 | return "full thread diff patch"; | |
| 71 | }), | |
| 72 | deleteCheckpointRefs: () => Effect.void, | |
| 73 | }; | |
| 74 | ||
| 75 | const layer = CheckpointDiffQueryLive.pipe( | |
| 76 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 77 | Layer.provideMerge( | |
| 78 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 79 | getCommandReadModel: () => | |
| 80 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 81 | getSnapshot: () => | |
| 82 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 83 | getShellSnapshot: () => | |
| 84 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 85 | getArchivedShellSnapshot: () => | |
| 86 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 87 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 88 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 89 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 90 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 91 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 92 | getThreadCheckpointContext: () => | |
| 93 | Effect.sync(() => { | |
| 94 | getThreadCheckpointContextCalls += 1; | |
| 95 | return Option.none(); | |
| 96 | }), | |
| 97 | getFullThreadDiffContext: () => | |
| 98 | Effect.sync(() => { | |
| 99 | getFullThreadDiffContextCalls += 1; | |
| 100 | return Option.some({ | |
| 101 | threadId, | |
| 102 | projectId, | |
| 103 | workspaceRoot: "/tmp/workspace", | |
| 104 | worktreePath: "/tmp/worktree", | |
| 105 | latestCheckpointTurnCount: 4, | |
| 106 | toCheckpointRef, | |
| 107 | }); | |
| 108 | }), | |
| 109 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 110 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 111 | }), | |
| 112 | ), | |
| 113 | ); | |
| 114 | ||
| 115 | const result = await Effect.runPromise( | |
| 116 | Effect.gen(function* () { | |
| 117 | const query = yield* CheckpointDiffQuery; | |
| 118 | return yield* query.getFullThreadDiff({ | |
| 119 | threadId, | |
| 120 | toTurnCount: 4, | |
| 121 | ignoreWhitespace: true, | |
| 122 | }); | |
| 123 | }).pipe(Effect.provide(layer)), | |
| 124 | ); | |
| 125 | ||
| 126 | expect(getThreadCheckpointContextCalls).toBe(0); | |
| 127 | expect(getFullThreadDiffContextCalls).toBe(1); | |
| 128 | expect(diffCheckpointsCalls).toEqual([ | |
| 129 | { | |
| 130 | cwd: "/tmp/worktree", | |
| 131 | fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), | |
| 132 | toCheckpointRef, | |
| 133 | ignoreWhitespace: true, | |
| 134 | }, | |
| 135 | ]); | |
| 136 | expect(result).toEqual({ | |
| 137 | threadId, | |
| 138 | fromTurnCount: 0, | |
| 139 | toTurnCount: 4, | |
| 140 | diff: "full thread diff patch", | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { | |
| 145 | const projectId = ProjectId.make("project-1"); | |
| 146 | const threadId = ThreadId.make("thread-1"); | |
| 147 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 148 | const diffCheckpointsCalls: Array<{ | |
| 149 | readonly fromCheckpointRef: CheckpointRef; | |
| 150 | readonly toCheckpointRef: CheckpointRef; | |
| 151 | readonly cwd: string; | |
| 152 | readonly ignoreWhitespace: boolean; | |
| 153 | }> = []; | |
| 154 | ||
| 155 | const threadCheckpointContext = makeThreadCheckpointContext({ | |
| 156 | projectId, | |
| 157 | threadId, | |
| 158 | workspaceRoot: "/tmp/workspace", | |
| 159 | worktreePath: null, | |
| 160 | checkpointTurnCount: 1, | |
| 161 | checkpointRef: toCheckpointRef, | |
| 162 | }); | |
| 163 | ||
| 164 | const checkpointStore: CheckpointStoreShape = { | |
| 165 | isGitRepository: () => Effect.succeed(true), | |
| 166 | captureCheckpoint: () => Effect.void, | |
| 167 | hasCheckpointRef: () => Effect.succeed(true), | |
| 168 | restoreCheckpoint: () => Effect.succeed(true), | |
| 169 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => | |
| 170 | Effect.sync(() => { | |
| 171 | diffCheckpointsCalls.push({ | |
| 172 | fromCheckpointRef, | |
| 173 | toCheckpointRef, | |
| 174 | cwd, | |
| 175 | ignoreWhitespace, | |
| 176 | }); | |
| 177 | return "diff patch"; | |
| 178 | }), | |
| 179 | deleteCheckpointRefs: () => Effect.void, | |
| 180 | }; | |
| 181 | ||
| 182 | const layer = CheckpointDiffQueryLive.pipe( | |
| 183 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 184 | Layer.provideMerge( | |
| 185 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 186 | getCommandReadModel: () => | |
| 187 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 188 | getSnapshot: () => | |
| 189 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 190 | getShellSnapshot: () => | |
| 191 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 192 | getArchivedShellSnapshot: () => | |
| 193 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 194 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 195 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 196 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 197 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 198 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 199 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | |
| 200 | getFullThreadDiffContext: () => Effect.die("unused"), | |
| 201 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 202 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 203 | }), | |
| 204 | ), | |
| 205 | ); | |
| 206 | ||
| 207 | const result = await Effect.runPromise( | |
| 208 | Effect.gen(function* () { | |
| 209 | const query = yield* CheckpointDiffQuery; | |
| 210 | return yield* query.getTurnDiff({ | |
| 211 | threadId, | |
| 212 | fromTurnCount: 0, | |
| 213 | toTurnCount: 1, | |
| 214 | ignoreWhitespace: true, | |
| 215 | }); | |
| 216 | }).pipe(Effect.provide(layer)), | |
| 217 | ); | |
| 218 | ||
| 219 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); | |
| 220 | expect(diffCheckpointsCalls).toEqual([ | |
| 221 | { | |
| 222 | cwd: "/tmp/workspace", | |
| 223 | fromCheckpointRef: expectedFromRef, | |
| 224 | toCheckpointRef, | |
| 225 | ignoreWhitespace: true, | |
| 226 | }, | |
| 227 | ]); | |
| 228 | expect(result).toEqual({ | |
| 229 | threadId, | |
| 230 | fromTurnCount: 0, | |
| 231 | toTurnCount: 1, | |
| 232 | diff: "diff patch", | |
| 233 | }); | |
| 234 | }); | |
| 235 | ||
| 236 | it("defaults to hide whitespace changes", async () => { | |
| 237 | const projectId = ProjectId.make("project-default-whitespace"); | |
| 238 | const threadId = ThreadId.make("thread-default-whitespace"); | |
| 239 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 240 | const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; | |
| 241 | ||
| 242 | const threadCheckpointContext = makeThreadCheckpointContext({ | |
| 243 | projectId, | |
| 244 | threadId, | |
| 245 | workspaceRoot: "/tmp/workspace", | |
| 246 | worktreePath: null, | |
| 247 | checkpointTurnCount: 1, | |
| 248 | checkpointRef: toCheckpointRef, | |
| 249 | }); | |
| 250 | ||
| 251 | const checkpointStore: CheckpointStoreShape = { | |
| 252 | isGitRepository: () => Effect.succeed(true), | |
| 253 | captureCheckpoint: () => Effect.void, | |
| 254 | hasCheckpointRef: () => Effect.succeed(true), | |
| 255 | restoreCheckpoint: () => Effect.succeed(true), | |
| 256 | diffCheckpoints: ({ ignoreWhitespace }) => | |
| 257 | Effect.sync(() => { | |
| 258 | diffCheckpointsCalls.push({ ignoreWhitespace }); | |
| 259 | return "diff patch"; | |
| 260 | }), | |
| 261 | deleteCheckpointRefs: () => Effect.void, | |
| 262 | }; | |
| 263 | ||
| 264 | const layer = CheckpointDiffQueryLive.pipe( | |
| 265 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 266 | Layer.provideMerge( | |
| 267 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 268 | getCommandReadModel: () => | |
| 269 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 270 | getSnapshot: () => | |
| 271 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 272 | getShellSnapshot: () => | |
| 273 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 274 | getArchivedShellSnapshot: () => | |
| 275 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 276 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 277 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 278 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 279 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 280 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 281 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | |
| 282 | getFullThreadDiffContext: () => Effect.die("unused"), | |
| 283 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 284 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 285 | }), | |
| 286 | ), | |
| 287 | ); | |
| 288 | ||
| 289 | await Effect.runPromise( | |
| 290 | Effect.gen(function* () { | |
| 291 | const query = yield* CheckpointDiffQuery; | |
| 292 | return yield* query.getTurnDiff({ | |
| 293 | threadId, | |
| 294 | fromTurnCount: 0, | |
| 295 | toTurnCount: 1, | |
| 296 | }); | |
| 297 | }).pipe(Effect.provide(layer)), | |
| 298 | ); | |
| 299 | ||
| 300 | expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); | |
| 301 | }); | |
| 302 | ||
| 303 | it("does not preflight checkpoint refs before diffing", async () => { | |
| 304 | const projectId = ProjectId.make("project-no-preflight"); | |
| 305 | const threadId = ThreadId.make("thread-no-preflight"); | |
| 306 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 307 | let hasCheckpointRefCallCount = 0; | |
| 308 | ||
| 309 | const threadCheckpointContext = makeThreadCheckpointContext({ | |
| 310 | projectId, | |
| 311 | threadId, | |
| 312 | workspaceRoot: "/tmp/workspace", | |
| 313 | worktreePath: null, | |
| 314 | checkpointTurnCount: 1, | |
| 315 | checkpointRef: toCheckpointRef, | |
| 316 | }); | |
| 317 | ||
| 318 | const checkpointStore: CheckpointStoreShape = { | |
| 319 | isGitRepository: () => Effect.succeed(true), | |
| 320 | captureCheckpoint: () => Effect.void, | |
| 321 | hasCheckpointRef: () => | |
| 322 | Effect.sync(() => { | |
| 323 | hasCheckpointRefCallCount += 1; | |
| 324 | return true; | |
| 325 | }), | |
| 326 | restoreCheckpoint: () => Effect.succeed(true), | |
| 327 | diffCheckpoints: () => Effect.succeed("diff patch"), | |
| 328 | deleteCheckpointRefs: () => Effect.void, | |
| 329 | }; | |
| 330 | ||
| 331 | const layer = CheckpointDiffQueryLive.pipe( | |
| 332 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 333 | Layer.provideMerge( | |
| 334 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 335 | getCommandReadModel: () => | |
| 336 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 337 | getSnapshot: () => | |
| 338 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 339 | getShellSnapshot: () => | |
| 340 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 341 | getArchivedShellSnapshot: () => | |
| 342 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 343 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 344 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 345 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 346 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 347 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 348 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | |
| 349 | getFullThreadDiffContext: () => Effect.die("unused"), | |
| 350 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 351 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 352 | }), | |
| 353 | ), | |
| 354 | ); | |
| 355 | ||
| 356 | await Effect.runPromise( | |
| 357 | Effect.gen(function* () { | |
| 358 | const query = yield* CheckpointDiffQuery; | |
| 359 | return yield* query.getTurnDiff({ | |
| 360 | threadId, | |
| 361 | fromTurnCount: 0, | |
| 362 | toTurnCount: 1, | |
| 363 | ignoreWhitespace: true, | |
| 364 | }); | |
| 365 | }).pipe(Effect.provide(layer)), | |
| 366 | ); | |
| 367 | ||
| 368 | expect(hasCheckpointRefCallCount).toBe(0); | |
| 369 | }); | |
| 370 | ||
| 371 | it("fails when the thread is missing from the snapshot", async () => { | |
| 372 | const threadId = ThreadId.make("thread-missing"); | |
| 373 | ||
| 374 | const checkpointStore: CheckpointStoreShape = { | |
| 375 | isGitRepository: () => Effect.succeed(true), | |
| 376 | captureCheckpoint: () => Effect.void, | |
| 377 | hasCheckpointRef: () => Effect.succeed(true), | |
| 378 | restoreCheckpoint: () => Effect.succeed(true), | |
| 379 | diffCheckpoints: () => Effect.succeed(""), | |
| 380 | deleteCheckpointRefs: () => Effect.void, | |
| 381 | }; | |
| 382 | ||
| 383 | const layer = CheckpointDiffQueryLive.pipe( | |
| 384 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 385 | Layer.provideMerge( | |
| 386 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 387 | getCommandReadModel: () => | |
| 388 | Effect.die("CheckpointDiffQuery should not request the command read model"), | |
| 389 | getSnapshot: () => | |
| 390 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | |
| 391 | getShellSnapshot: () => | |
| 392 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 393 | getArchivedShellSnapshot: () => | |
| 394 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 395 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | |
| 396 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | |
| 397 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | |
| 398 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 399 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | |
| 400 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | |
| 401 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | |
| 402 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 403 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 404 | }), | |
| 405 | ), | |
| 406 | ); | |
| 407 | ||
| 408 | await expect( | |
| 409 | Effect.runPromise( | |
| 410 | Effect.gen(function* () { | |
| 411 | const query = yield* CheckpointDiffQuery; | |
| 412 | return yield* query.getTurnDiff({ | |
| 413 | threadId, | |
| 414 | fromTurnCount: 0, | |
| 415 | toTurnCount: 1, | |
| 416 | }); | |
| 417 | }).pipe(Effect.provide(layer)), | |
| 418 | ), | |
| 419 | ).rejects.toThrow("Thread 'thread-missing' not found."); | |
| 420 | }); | |
| 421 | }); |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts deleted-248| ... | ... | @@ -1,248 +0,0 @@ |
| 1 | import { | |
| 2 | type CheckpointRef, | |
| 3 | OrchestrationGetTurnDiffResult, | |
| 4 | type ThreadId, | |
| 5 | type OrchestrationGetFullThreadDiffResult, | |
| 6 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 14 | import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; | |
| 15 | import { checkpointRefForThreadTurn } from "../Utils.ts"; | |
| 16 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | |
| 17 | import { | |
| 18 | CheckpointDiffQuery, | |
| 19 | type CheckpointDiffQueryShape, | |
| 20 | } from "../Services/CheckpointDiffQuery.ts"; | |
| 21 | ||
| 22 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); | |
| 23 | ||
| 24 | function buildTurnDiffResult( | |
| 25 | input: { | |
| 26 | readonly threadId: ThreadId; | |
| 27 | readonly fromTurnCount: number; | |
| 28 | readonly toTurnCount: number; | |
| 29 | }, | |
| 30 | diff: string, | |
| 31 | ): OrchestrationGetTurnDiffResultType { | |
| 32 | return { | |
| 33 | threadId: input.threadId, | |
| 34 | fromTurnCount: input.fromTurnCount, | |
| 35 | toTurnCount: input.toTurnCount, | |
| 36 | diff, | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | const make = Effect.gen(function* () { | |
| 41 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 42 | const checkpointStore = yield* CheckpointStore; | |
| 43 | ||
| 44 | const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( | |
| 45 | function* (input) { | |
| 46 | const operation = "CheckpointDiffQuery.getTurnDiff"; | |
| 47 | const ignoreWhitespace = input.ignoreWhitespace ?? true; | |
| 48 | yield* Effect.annotateCurrentSpan({ | |
| 49 | "checkpoint.thread_id": input.threadId, | |
| 50 | "checkpoint.from_turn_count": input.fromTurnCount, | |
| 51 | "checkpoint.to_turn_count": input.toTurnCount, | |
| 52 | "checkpoint.ignore_whitespace": ignoreWhitespace, | |
| 53 | }); | |
| 54 | ||
| 55 | if (input.fromTurnCount === input.toTurnCount) { | |
| 56 | const emptyDiff: OrchestrationGetTurnDiffResultType = { | |
| 57 | threadId: input.threadId, | |
| 58 | fromTurnCount: input.fromTurnCount, | |
| 59 | toTurnCount: input.toTurnCount, | |
| 60 | diff: "", | |
| 61 | }; | |
| 62 | if (!isTurnDiffResult(emptyDiff)) { | |
| 63 | return yield* new CheckpointInvariantError({ | |
| 64 | operation, | |
| 65 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 66 | }); | |
| 67 | } | |
| 68 | return emptyDiff; | |
| 69 | } | |
| 70 | ||
| 71 | const threadContext = yield* projectionSnapshotQuery | |
| 72 | .getThreadCheckpointContext(input.threadId) | |
| 73 | .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); | |
| 74 | if (Option.isNone(threadContext)) { | |
| 75 | return yield* new CheckpointInvariantError({ | |
| 76 | operation, | |
| 77 | detail: `Thread '${input.threadId}' not found.`, | |
| 78 | }); | |
| 79 | } | |
| 80 | ||
| 81 | const maxTurnCount = threadContext.value.checkpoints.reduce( | |
| 82 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | |
| 83 | 0, | |
| 84 | ); | |
| 85 | if (input.toTurnCount > maxTurnCount) { | |
| 86 | return yield* new CheckpointUnavailableError({ | |
| 87 | threadId: input.threadId, | |
| 88 | turnCount: input.toTurnCount, | |
| 89 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, | |
| 90 | }); | |
| 91 | } | |
| 92 | ||
| 93 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; | |
| 94 | if (!workspaceCwd) { | |
| 95 | return yield* new CheckpointInvariantError({ | |
| 96 | operation, | |
| 97 | detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | const fromCheckpointRef = | |
| 102 | input.fromTurnCount === 0 | |
| 103 | ? checkpointRefForThreadTurn(input.threadId, 0) | |
| 104 | : threadContext.value.checkpoints.find( | |
| 105 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | |
| 106 | )?.checkpointRef; | |
| 107 | if (!fromCheckpointRef) { | |
| 108 | return yield* new CheckpointUnavailableError({ | |
| 109 | threadId: input.threadId, | |
| 110 | turnCount: input.fromTurnCount, | |
| 111 | detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, | |
| 112 | }); | |
| 113 | } | |
| 114 | ||
| 115 | const toCheckpointRef = threadContext.value.checkpoints.find( | |
| 116 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | |
| 117 | )?.checkpointRef; | |
| 118 | if (!toCheckpointRef) { | |
| 119 | return yield* new CheckpointUnavailableError({ | |
| 120 | threadId: input.threadId, | |
| 121 | turnCount: input.toTurnCount, | |
| 122 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 123 | }); | |
| 124 | } | |
| 125 | ||
| 126 | const diff = yield* checkpointStore | |
| 127 | .diffCheckpoints({ | |
| 128 | cwd: workspaceCwd, | |
| 129 | fromCheckpointRef, | |
| 130 | toCheckpointRef, | |
| 131 | fallbackFromToHead: false, | |
| 132 | ignoreWhitespace, | |
| 133 | }) | |
| 134 | .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); | |
| 135 | ||
| 136 | const turnDiff = buildTurnDiffResult(input, diff); | |
| 137 | if (!isTurnDiffResult(turnDiff)) { | |
| 138 | return yield* new CheckpointInvariantError({ | |
| 139 | operation, | |
| 140 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 141 | }); | |
| 142 | } | |
| 143 | ||
| 144 | return turnDiff; | |
| 145 | }, | |
| 146 | ); | |
| 147 | ||
| 148 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = Effect.fn( | |
| 149 | "CheckpointDiffQuery.getFullThreadDiff", | |
| 150 | )(function* (input) { | |
| 151 | const operation = "CheckpointDiffQuery.getFullThreadDiff"; | |
| 152 | const ignoreWhitespace = input.ignoreWhitespace ?? true; | |
| 153 | yield* Effect.annotateCurrentSpan({ | |
| 154 | "checkpoint.thread_id": input.threadId, | |
| 155 | "checkpoint.from_turn_count": 0, | |
| 156 | "checkpoint.to_turn_count": input.toTurnCount, | |
| 157 | "checkpoint.ignore_whitespace": ignoreWhitespace, | |
| 158 | "checkpoint.diff_kind": "full-thread", | |
| 159 | }); | |
| 160 | ||
| 161 | if (input.toTurnCount === 0) { | |
| 162 | const emptyDiff = buildTurnDiffResult( | |
| 163 | { | |
| 164 | threadId: input.threadId, | |
| 165 | fromTurnCount: 0, | |
| 166 | toTurnCount: 0, | |
| 167 | }, | |
| 168 | "", | |
| 169 | ); | |
| 170 | if (!isTurnDiffResult(emptyDiff)) { | |
| 171 | return yield* new CheckpointInvariantError({ | |
| 172 | operation, | |
| 173 | detail: "Computed full thread diff result does not satisfy contract schema.", | |
| 174 | }); | |
| 175 | } | |
| 176 | return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; | |
| 177 | } | |
| 178 | ||
| 179 | const threadContext = yield* projectionSnapshotQuery | |
| 180 | .getFullThreadDiffContext(input.threadId, input.toTurnCount) | |
| 181 | .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); | |
| 182 | ||
| 183 | if (Option.isNone(threadContext)) { | |
| 184 | return yield* new CheckpointInvariantError({ | |
| 185 | operation, | |
| 186 | detail: `Thread '${input.threadId}' not found.`, | |
| 187 | }); | |
| 188 | } | |
| 189 | ||
| 190 | if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { | |
| 191 | return yield* new CheckpointUnavailableError({ | |
| 192 | threadId: input.threadId, | |
| 193 | turnCount: input.toTurnCount, | |
| 194 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, | |
| 195 | }); | |
| 196 | } | |
| 197 | ||
| 198 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; | |
| 199 | if (!workspaceCwd) { | |
| 200 | return yield* new CheckpointInvariantError({ | |
| 201 | operation, | |
| 202 | detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, | |
| 203 | }); | |
| 204 | } | |
| 205 | ||
| 206 | if (!threadContext.value.toCheckpointRef) { | |
| 207 | return yield* new CheckpointUnavailableError({ | |
| 208 | threadId: input.threadId, | |
| 209 | turnCount: input.toTurnCount, | |
| 210 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 211 | }); | |
| 212 | } | |
| 213 | ||
| 214 | const diff = yield* checkpointStore | |
| 215 | .diffCheckpoints({ | |
| 216 | cwd: workspaceCwd, | |
| 217 | fromCheckpointRef: checkpointRefForThreadTurn(input.threadId, 0), | |
| 218 | toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef, | |
| 219 | fallbackFromToHead: false, | |
| 220 | ignoreWhitespace, | |
| 221 | }) | |
| 222 | .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints")); | |
| 223 | ||
| 224 | const turnDiff = buildTurnDiffResult( | |
| 225 | { | |
| 226 | threadId: input.threadId, | |
| 227 | fromTurnCount: 0, | |
| 228 | toTurnCount: input.toTurnCount, | |
| 229 | }, | |
| 230 | diff, | |
| 231 | ); | |
| 232 | if (!isTurnDiffResult(turnDiff)) { | |
| 233 | return yield* new CheckpointInvariantError({ | |
| 234 | operation, | |
| 235 | detail: "Computed full thread diff result does not satisfy contract schema.", | |
| 236 | }); | |
| 237 | } | |
| 238 | ||
| 239 | return turnDiff satisfies OrchestrationGetFullThreadDiffResult; | |
| 240 | }); | |
| 241 | ||
| 242 | return { | |
| 243 | getTurnDiff, | |
| 244 | getFullThreadDiff, | |
| 245 | } satisfies CheckpointDiffQueryShape; | |
| 246 | }); | |
| 247 | ||
| 248 | export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make); |
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts deleted-206| ... | ... | @@ -1,206 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as PlatformError from "effect/PlatformError"; | |
| 10 | import * as Scope from "effect/Scope"; | |
| 11 | import { describe, expect } from "vite-plus/test"; | |
| 12 | ||
| 13 | import { checkpointRefForThreadTurn } from "../Utils.ts"; | |
| 14 | import { CheckpointStoreLive } from "./CheckpointStore.ts"; | |
| 15 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | |
| 16 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | |
| 17 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | |
| 18 | import type { VcsError } from "@t3tools/contracts"; | |
| 19 | import { ServerConfig } from "../../config.ts"; | |
| 20 | import { ThreadId } from "@t3tools/contracts"; | |
| 21 | ||
| 22 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | |
| 23 | prefix: "t3-checkpoint-store-test-", | |
| 24 | }); | |
| 25 | const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); | |
| 26 | const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); | |
| 27 | const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( | |
| 28 | Layer.provideMerge(VcsDriverTestLayer), | |
| 29 | Layer.provideMerge(NodeServices.layer), | |
| 30 | ); | |
| 31 | const TestLayer = CheckpointStoreTestLayer.pipe( | |
| 32 | Layer.provideMerge(VcsProcessTestLayer), | |
| 33 | Layer.provideMerge(VcsDriverTestLayer), | |
| 34 | Layer.provideMerge(ServerConfigLayer), | |
| 35 | Layer.provideMerge(NodeServices.layer), | |
| 36 | ); | |
| 37 | ||
| 38 | function makeTmpDir( | |
| 39 | prefix = "checkpoint-store-test-", | |
| 40 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | |
| 41 | return Effect.gen(function* () { | |
| 42 | const fileSystem = yield* FileSystem.FileSystem; | |
| 43 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | function writeTextFile( | |
| 48 | filePath: string, | |
| 49 | contents: string, | |
| 50 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 51 | return Effect.gen(function* () { | |
| 52 | const fileSystem = yield* FileSystem.FileSystem; | |
| 53 | yield* fileSystem.writeFileString(filePath, contents); | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | function git( | |
| 58 | cwd: string, | |
| 59 | args: ReadonlyArray<string>, | |
| 60 | ): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> { | |
| 61 | return Effect.gen(function* () { | |
| 62 | const process = yield* VcsProcess.VcsProcess; | |
| 63 | const result = yield* process.run({ | |
| 64 | operation: "CheckpointStore.test.git", | |
| 65 | command: "git", | |
| 66 | cwd, | |
| 67 | args, | |
| 68 | timeoutMs: 10_000, | |
| 69 | }); | |
| 70 | return result.stdout.trim(); | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | function initRepoWithCommit( | |
| 75 | cwd: string, | |
| 76 | ): Effect.Effect< | |
| 77 | void, | |
| 78 | VcsError | PlatformError.PlatformError, | |
| 79 | VcsProcess.VcsProcess | FileSystem.FileSystem | |
| 80 | > { | |
| 81 | return Effect.gen(function* () { | |
| 82 | yield* git(cwd, ["init"]); | |
| 83 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | |
| 84 | yield* git(cwd, ["config", "user.name", "Test"]); | |
| 85 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); | |
| 86 | yield* git(cwd, ["add", "."]); | |
| 87 | yield* git(cwd, ["commit", "-m", "initial commit"]); | |
| 88 | }); | |
| 89 | } | |
| 90 | ||
| 91 | function buildLargeText(lineCount = 5_000): string { | |
| 92 | return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) | |
| 93 | .join("\n") | |
| 94 | .concat("\n"); | |
| 95 | } | |
| 96 | ||
| 97 | it.layer(TestLayer)("CheckpointStoreLive", (it) => { | |
| 98 | describe("diffCheckpoints", () => { | |
| 99 | it.effect("returns full oversized checkpoint diffs without truncation", () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const tmp = yield* makeTmpDir(); | |
| 102 | yield* initRepoWithCommit(tmp); | |
| 103 | const checkpointStore = yield* CheckpointStore; | |
| 104 | const threadId = ThreadId.make("thread-checkpoint-store"); | |
| 105 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); | |
| 106 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 107 | ||
| 108 | yield* checkpointStore.captureCheckpoint({ | |
| 109 | cwd: tmp, | |
| 110 | checkpointRef: fromCheckpointRef, | |
| 111 | }); | |
| 112 | yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); | |
| 113 | yield* checkpointStore.captureCheckpoint({ | |
| 114 | cwd: tmp, | |
| 115 | checkpointRef: toCheckpointRef, | |
| 116 | }); | |
| 117 | ||
| 118 | const diff = yield* checkpointStore.diffCheckpoints({ | |
| 119 | cwd: tmp, | |
| 120 | fromCheckpointRef, | |
| 121 | toCheckpointRef, | |
| 122 | ignoreWhitespace: true, | |
| 123 | }); | |
| 124 | ||
| 125 | expect(diff).toContain("diff --git"); | |
| 126 | expect(diff).not.toContain("[truncated]"); | |
| 127 | expect(diff).toContain("+line 04999"); | |
| 128 | }), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect("can hide indentation churn when changes wrap existing lines", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | const tmp = yield* makeTmpDir(); | |
| 134 | yield* initRepoWithCommit(tmp); | |
| 135 | const checkpointStore = yield* CheckpointStore; | |
| 136 | const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); | |
| 137 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); | |
| 138 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 139 | ||
| 140 | const componentPath = path.join(tmp, "Component.tsx"); | |
| 141 | yield* writeTextFile( | |
| 142 | componentPath, | |
| 143 | [ | |
| 144 | "export function View() {", | |
| 145 | " return (", | |
| 146 | " <section>", | |
| 147 | " <h1>Title</h1>", | |
| 148 | " <p>Body</p>", | |
| 149 | " </section>", | |
| 150 | " );", | |
| 151 | "}", | |
| 152 | "", | |
| 153 | ].join("\n"), | |
| 154 | ); | |
| 155 | yield* checkpointStore.captureCheckpoint({ | |
| 156 | cwd: tmp, | |
| 157 | checkpointRef: fromCheckpointRef, | |
| 158 | }); | |
| 159 | yield* writeTextFile( | |
| 160 | componentPath, | |
| 161 | [ | |
| 162 | "export function View() {", | |
| 163 | " return (", | |
| 164 | " <section>", | |
| 165 | " {isReady ? (", | |
| 166 | " <div>", | |
| 167 | " <h1>Title</h1>", | |
| 168 | " <p>Body</p>", | |
| 169 | " </div>", | |
| 170 | " ) : null}", | |
| 171 | " </section>", | |
| 172 | " );", | |
| 173 | "}", | |
| 174 | "", | |
| 175 | ].join("\n"), | |
| 176 | ); | |
| 177 | yield* checkpointStore.captureCheckpoint({ | |
| 178 | cwd: tmp, | |
| 179 | checkpointRef: toCheckpointRef, | |
| 180 | }); | |
| 181 | ||
| 182 | const normalDiff = yield* checkpointStore.diffCheckpoints({ | |
| 183 | cwd: tmp, | |
| 184 | fromCheckpointRef, | |
| 185 | toCheckpointRef, | |
| 186 | ignoreWhitespace: false, | |
| 187 | }); | |
| 188 | const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({ | |
| 189 | cwd: tmp, | |
| 190 | fromCheckpointRef, | |
| 191 | toCheckpointRef, | |
| 192 | ignoreWhitespace: true, | |
| 193 | }); | |
| 194 | ||
| 195 | expect(normalDiff).toContain("diff --git"); | |
| 196 | expect(normalDiff).toContain("- <h1>Title</h1>"); | |
| 197 | expect(normalDiff).toContain("+ <h1>Title</h1>"); | |
| 198 | expect(whitespaceIgnoredDiff).toContain("diff --git"); | |
| 199 | expect(whitespaceIgnoredDiff).toContain("+ {isReady ? ("); | |
| 200 | expect(whitespaceIgnoredDiff).toContain("+ <div>"); | |
| 201 | expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>"); | |
| 202 | expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>"); | |
| 203 | }), | |
| 204 | ); | |
| 205 | }); | |
| 206 | }); |
apps/server/src/checkpointing/Layers/CheckpointStore.ts deleted-89| ... | ... | @@ -1,89 +0,0 @@ |
| 1 | /** | |
| 2 | * CheckpointStoreLive - Filesystem checkpoint store adapter layer. | |
| 3 | * | |
| 4 | * Resolves the active VCS driver once per checkpoint operation and delegates | |
| 5 | * checkpoint-specific behavior to the driver's optional checkpoint capability. | |
| 6 | * | |
| 7 | * @module CheckpointStoreLive | |
| 8 | */ | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | ||
| 12 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | |
| 13 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; | |
| 14 | import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; | |
| 15 | import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts"; | |
| 16 | ||
| 17 | const makeCheckpointStore = Effect.gen(function* () { | |
| 18 | const vcsRegistry = yield* VcsDriverRegistry; | |
| 19 | ||
| 20 | const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( | |
| 21 | operation: string, | |
| 22 | cwd: string, | |
| 23 | ) { | |
| 24 | const handle = yield* vcsRegistry.resolve({ cwd }); | |
| 25 | if (!handle.driver.checkpoints) { | |
| 26 | return yield* new VcsUnsupportedOperationError({ | |
| 27 | operation, | |
| 28 | kind: handle.kind, | |
| 29 | detail: `${handle.kind} driver does not implement checkpoint operations.`, | |
| 30 | }); | |
| 31 | } | |
| 32 | return handle.driver.checkpoints satisfies VcsCheckpointOps; | |
| 33 | }); | |
| 34 | ||
| 35 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | |
| 36 | vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( | |
| 37 | Effect.map(() => true), | |
| 38 | Effect.orElseSucceed(() => false), | |
| 39 | ); | |
| 40 | ||
| 41 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( | |
| 42 | "captureCheckpoint", | |
| 43 | )(function* (input) { | |
| 44 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); | |
| 45 | return yield* checkpoints.captureCheckpoint(input); | |
| 46 | }); | |
| 47 | ||
| 48 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = Effect.fn("hasCheckpointRef")( | |
| 49 | function* (input) { | |
| 50 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); | |
| 51 | return yield* checkpoints.hasCheckpointRef(input); | |
| 52 | }, | |
| 53 | ); | |
| 54 | ||
| 55 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( | |
| 56 | "restoreCheckpoint", | |
| 57 | )(function* (input) { | |
| 58 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); | |
| 59 | return yield* checkpoints.restoreCheckpoint(input); | |
| 60 | }); | |
| 61 | ||
| 62 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( | |
| 63 | function* (input) { | |
| 64 | const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); | |
| 65 | return yield* checkpoints.diffCheckpoints(input); | |
| 66 | }, | |
| 67 | ); | |
| 68 | ||
| 69 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( | |
| 70 | "deleteCheckpointRefs", | |
| 71 | )(function* (input) { | |
| 72 | const checkpoints = yield* resolveCheckpoints( | |
| 73 | "CheckpointStore.deleteCheckpointRefs", | |
| 74 | input.cwd, | |
| 75 | ); | |
| 76 | return yield* checkpoints.deleteCheckpointRefs(input); | |
| 77 | }); | |
| 78 | ||
| 79 | return { | |
| 80 | isGitRepository, | |
| 81 | captureCheckpoint, | |
| 82 | hasCheckpointRef, | |
| 83 | restoreCheckpoint, | |
| 84 | diffCheckpoints, | |
| 85 | deleteCheckpointRefs, | |
| 86 | } satisfies CheckpointStoreShape; | |
| 87 | }); | |
| 88 | ||
| 89 | export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); |
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts deleted-49| ... | ... | @@ -1,49 +0,0 @@ |
| 1 | /** | |
| 2 | * CheckpointDiffQuery - Query interface for computed checkpoint diffs. | |
| 3 | * | |
| 4 | * Provides read-only diff operations across checkpoint snapshots used by | |
| 5 | * orchestration APIs. | |
| 6 | * | |
| 7 | * @module CheckpointDiffQuery | |
| 8 | */ | |
| 9 | import type { | |
| 10 | OrchestrationGetFullThreadDiffInput, | |
| 11 | OrchestrationGetFullThreadDiffResult, | |
| 12 | OrchestrationGetTurnDiffInput, | |
| 13 | OrchestrationGetTurnDiffResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import * as Context from "effect/Context"; | |
| 16 | import type * as Effect from "effect/Effect"; | |
| 17 | ||
| 18 | import type { CheckpointServiceError } from "../Errors.ts"; | |
| 19 | ||
| 20 | /** | |
| 21 | * CheckpointDiffQueryShape - Service API for checkpoint diff queries. | |
| 22 | */ | |
| 23 | export interface CheckpointDiffQueryShape { | |
| 24 | /** | |
| 25 | * Read the patch diff for a single turn checkpoint transition. | |
| 26 | * | |
| 27 | * Verifies checkpoint availability in both projection state and filesystem. | |
| 28 | */ | |
| 29 | readonly getTurnDiff: ( | |
| 30 | input: OrchestrationGetTurnDiffInput, | |
| 31 | ) => Effect.Effect<OrchestrationGetTurnDiffResult, CheckpointServiceError>; | |
| 32 | ||
| 33 | /** | |
| 34 | * Read the full patch diff across a thread range of checkpoints. | |
| 35 | * | |
| 36 | * Delegates to turn diff with `fromTurnCount = 0`. | |
| 37 | */ | |
| 38 | readonly getFullThreadDiff: ( | |
| 39 | input: OrchestrationGetFullThreadDiffInput, | |
| 40 | ) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * CheckpointDiffQuery - Service tag for checkpoint diff queries. | |
| 45 | */ | |
| 46 | export class CheckpointDiffQuery extends Context.Service< | |
| 47 | CheckpointDiffQuery, | |
| 48 | CheckpointDiffQueryShape | |
| 49 | >()("t3/checkpointing/Services/CheckpointDiffQuery") {} |
apps/server/src/checkpointing/Services/CheckpointStore.ts deleted-101| ... | ... | @@ -1,101 +0,0 @@ |
| 1 | /** | |
| 2 | * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. | |
| 3 | * | |
| 4 | * Owns hidden Git-ref checkpoint capture/restore and diff computation for a | |
| 5 | * workspace thread timeline. It does not store user-facing checkpoint metadata | |
| 6 | * and does not coordinate provider conversation rollback. | |
| 7 | * | |
| 8 | * Uses Effect `Context.Service` for dependency injection and exposes typed | |
| 9 | * domain errors for checkpoint storage operations. | |
| 10 | * | |
| 11 | * @module CheckpointStore | |
| 12 | */ | |
| 13 | import * as Context from "effect/Context"; | |
| 14 | import type * as Effect from "effect/Effect"; | |
| 15 | ||
| 16 | import type { CheckpointStoreError } from "../Errors.ts"; | |
| 17 | import { CheckpointRef } from "@t3tools/contracts"; | |
| 18 | ||
| 19 | export interface CaptureCheckpointInput { | |
| 20 | readonly cwd: string; | |
| 21 | readonly checkpointRef: CheckpointRef; | |
| 22 | } | |
| 23 | ||
| 24 | export interface RestoreCheckpointInput { | |
| 25 | readonly cwd: string; | |
| 26 | readonly checkpointRef: CheckpointRef; | |
| 27 | readonly fallbackToHead?: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | export interface DiffCheckpointsInput { | |
| 31 | readonly cwd: string; | |
| 32 | readonly fromCheckpointRef: CheckpointRef; | |
| 33 | readonly toCheckpointRef: CheckpointRef; | |
| 34 | readonly fallbackFromToHead?: boolean; | |
| 35 | readonly ignoreWhitespace: boolean; | |
| 36 | } | |
| 37 | ||
| 38 | export interface DeleteCheckpointRefsInput { | |
| 39 | readonly cwd: string; | |
| 40 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * CheckpointStoreShape - Service API for checkpoint capture/restore and diff access. | |
| 45 | */ | |
| 46 | export interface CheckpointStoreShape { | |
| 47 | /** | |
| 48 | * Check whether cwd is inside a Git worktree. | |
| 49 | */ | |
| 50 | readonly isGitRepository: (cwd: string) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 51 | ||
| 52 | /** | |
| 53 | * Capture a checkpoint commit and store it at the provided checkpoint ref. | |
| 54 | * | |
| 55 | * Uses an isolated temporary Git index and writes a hidden ref. | |
| 56 | */ | |
| 57 | readonly captureCheckpoint: ( | |
| 58 | input: CaptureCheckpointInput, | |
| 59 | ) => Effect.Effect<void, CheckpointStoreError>; | |
| 60 | ||
| 61 | /** | |
| 62 | * Check whether a checkpoint ref exists. | |
| 63 | */ | |
| 64 | readonly hasCheckpointRef: ( | |
| 65 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, | |
| 66 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 67 | ||
| 68 | /** | |
| 69 | * Restore workspace/staging state to a checkpoint. | |
| 70 | * | |
| 71 | * Optionally falls back to current `HEAD` when the checkpoint ref is missing. | |
| 72 | */ | |
| 73 | readonly restoreCheckpoint: ( | |
| 74 | input: RestoreCheckpointInput, | |
| 75 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 76 | ||
| 77 | /** | |
| 78 | * Compute patch diff between two checkpoint refs. | |
| 79 | * | |
| 80 | * Can optionally treat missing "from" ref as `HEAD`. | |
| 81 | */ | |
| 82 | readonly diffCheckpoints: ( | |
| 83 | input: DiffCheckpointsInput, | |
| 84 | ) => Effect.Effect<string, CheckpointStoreError>; | |
| 85 | ||
| 86 | /** | |
| 87 | * Delete the provided checkpoint refs. | |
| 88 | * | |
| 89 | * Best-effort delete: missing refs are tolerated. | |
| 90 | */ | |
| 91 | readonly deleteCheckpointRefs: ( | |
| 92 | input: DeleteCheckpointRefsInput, | |
| 93 | ) => Effect.Effect<void, CheckpointStoreError>; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * CheckpointStore - Service tag for checkpoint persistence and restore operations. | |
| 98 | */ | |
| 99 | export class CheckpointStore extends Context.Service<CheckpointStore, CheckpointStoreShape>()( | |
| 100 | "t3/checkpointing/Services/CheckpointStore", | |
| 101 | ) {} |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+9-5| ... | ... | @@ -30,8 +30,7 @@ import * as Scope from "effect/Scope"; |
| 30 | 30 | import * as Stream from "effect/Stream"; |
| 31 | 31 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 32 | 32 | |
| 33 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; | |
| 34 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | |
| 33 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | |
| 35 | 34 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 36 | 35 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 37 | 36 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| ... | ... | @@ -247,7 +246,10 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) |
| 247 | 246 | |
| 248 | 247 | describe("CheckpointReactor", () => { |
| 249 | 248 | let runtime: ManagedRuntime.ManagedRuntime< |
| 250 | OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery, | |
| 249 | | OrchestrationEngineService | |
| 250 | | CheckpointReactor | |
| 251 | | CheckpointStore.CheckpointStore | |
| 252 | | ProjectionSnapshotQuery, | |
| 251 | 253 | unknown |
| 252 | 254 | > | null = null; |
| 253 | 255 | let scope: Scope.Closeable | null = null; |
| ... | ... | @@ -328,7 +330,7 @@ describe("CheckpointReactor", () => { |
| 328 | 330 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 329 | 331 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 330 | 332 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 331 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), | |
| 333 | Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), | |
| 332 | 334 | Layer.provideMerge( |
| 333 | 335 | WorkspaceEntries.layer.pipe( |
| 334 | 336 | Layer.provide(WorkspacePathsLive), |
| ... | ... | @@ -345,7 +347,9 @@ describe("CheckpointReactor", () => { |
| 345 | 347 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 346 | 348 | const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); |
| 347 | 349 | const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); |
| 348 | const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); | |
| 350 | const checkpointStore = await runtime.runPromise( | |
| 351 | Effect.service(CheckpointStore.CheckpointStore), | |
| 352 | ); | |
| 349 | 353 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 350 | 354 | await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); |
| 351 | 355 | const drain = () => Effect.runPromise(reactor.drain); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+2-2| ... | ... | @@ -24,7 +24,7 @@ import { |
| 24 | 24 | checkpointRefForThreadTurn, |
| 25 | 25 | resolveThreadWorkspaceCwd, |
| 26 | 26 | } from "../../checkpointing/Utils.ts"; |
| 27 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | |
| 27 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | |
| 28 | 28 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 29 | 29 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| 30 | 30 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| ... | ... | @@ -81,7 +81,7 @@ const make = Effect.gen(function* () { |
| 81 | 81 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 82 | 82 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 83 | 83 | const providerService = yield* ProviderService; |
| 84 | const checkpointStore = yield* CheckpointStore; | |
| 84 | const checkpointStore = yield* CheckpointStore.CheckpointStore; | |
| 85 | 85 | const receiptBus = yield* RuntimeReceiptBus; |
| 86 | 86 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 87 | 87 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
apps/server/src/server.test.ts+1-1| ... | ... | @@ -71,7 +71,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 71 | 71 | |
| 72 | 72 | import * as ServerConfig from "./config.ts"; |
| 73 | 73 | import { makeRoutesLayer } from "./server.ts"; |
| 74 | import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.ts"; | |
| 74 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | |
| 75 | 75 | import * as GitManager from "./git/GitManager.ts"; |
| 76 | 76 | import * as Keybindings from "./keybindings.ts"; |
| 77 | 77 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
apps/server/src/server.ts+4-4| ... | ... | @@ -25,8 +25,8 @@ import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts |
| 25 | 25 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 26 | 26 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| 27 | 27 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; |
| 28 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; | |
| 29 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; | |
| 28 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | |
| 29 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; | |
| 30 | 30 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 31 | 31 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 32 | 32 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| ... | ... | @@ -232,8 +232,8 @@ const VcsLayerLive = Layer.empty.pipe( |
| 232 | 232 | ); |
| 233 | 233 | |
| 234 | 234 | const CheckpointingLayerLive = Layer.empty.pipe( |
| 235 | Layer.provideMerge(CheckpointDiffQueryLive), | |
| 236 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | |
| 235 | Layer.provideMerge(CheckpointDiffQuery.layer), | |
| 236 | Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | |
| 237 | 237 | ); |
| 238 | 238 | |
| 239 | 239 | const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); |
apps/server/src/ws.ts+1-1| ... | ... | @@ -56,7 +56,7 @@ import { clamp } from "effect/Number"; |
| 56 | 56 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 57 | 57 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 58 | 58 | |
| 59 | import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.ts"; | |
| 59 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | |
| 60 | 60 | import * as ServerConfig from "./config.ts"; |
| 61 | 61 | import * as Keybindings from "./keybindings.ts"; |
| 62 | 62 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
docs/operations/effect-fn-checklist.md+6-6| ... | ... | @@ -130,12 +130,12 @@ Effect.fn("name")( |
| 130 | 130 | - [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555) |
| 131 | 131 | - [x] Remaining nested callback wrappers in this file |
| 132 | 132 | |
| 133 | ### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`) | |
| 133 | ### `apps/server/src/checkpointing/CheckpointStore.ts` (`10`) | |
| 134 | 134 | |
| 135 | - [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L89) | |
| 136 | - [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L183) | |
| 137 | - [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L220) | |
| 138 | - [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L252) | |
| 135 | - [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L123) | |
| 136 | - [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L137) | |
| 137 | - [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L144) | |
| 138 | - [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L151) | |
| 139 | 139 | - [ ] Nested callback wrappers in this file |
| 140 | 140 | |
| 141 | 141 | ### `apps/server/src/provider/Layers/EventNdjsonLogger.ts` (`9`) |
| ... | ... | @@ -190,7 +190,7 @@ Effect.fn("name")( |
| 190 | 190 | - [ ] [apps/server/src/persistence/Migrations.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Migrations.ts) (`2`) |
| 191 | 191 | - [ ] [apps/server/src/open.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/open.ts) (`2`) |
| 192 | 192 | - [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`) |
| 193 | - [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`) | |
| 193 | - [ ] [apps/server/src/checkpointing/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointDiffQuery.ts) (`2`) | |
| 194 | 194 | - [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`) |
| 195 | 195 | |
| 196 | 196 | ``` |
docs/reference/encyclopedia.md+2-2| ... | ... | @@ -172,8 +172,8 @@ The file patch and changed-file summary for one turn. It is usually computed in |
| 172 | 172 | [16]: ./provider-architecture.md |
| 173 | 173 | [17]: ../apps/server/src/provider/Layers/CodexAdapter.ts |
| 174 | 174 | [18]: ./runtime-modes.md |
| 175 | [19]: ../apps/server/src/checkpointing/Services/CheckpointStore.ts | |
| 176 | [20]: ../apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts | |
| 175 | [19]: ../apps/server/src/checkpointing/CheckpointStore.ts | |
| 176 | [20]: ../apps/server/src/checkpointing/CheckpointDiffQuery.ts | |
| 177 | 177 | [21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts |
| 178 | 178 | [22]: ../apps/server/src/checkpointing/Utils.ts |
| 179 | 179 | [23]: ../apps/server/src/checkpointing/Diffs.ts |
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts-1| ... | ... | @@ -25,7 +25,6 @@ const LEGACY_BASELINE = new Map<string, number>([ |
| 25 | 25 | ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1], |
| 26 | 26 | ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2], |
| 27 | 27 | ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2], |
| 28 | ["apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts", 5], | |
| 29 | 28 | ["apps/server/src/orchestration/commandInvariants.test.ts", 6], |
| 30 | 29 | ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42], |
| 31 | 30 | ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5], |