| author | |
| committer | |
| log | 54dd17e7b9f576f3219d2512497c492180b76fd1 |
| tree | b7cd4b4eb0710f54134efd537de2f90ae330fe7b |
| parent | 32942a5f20246ad6a0bd8f0e72e73c084676d45b |
18 files changed, 2832 insertions(+), 183 deletions(-)
.plans/11-effect.md created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | |||
| 2 | |||
| 3 | PR 1: Service contracts + error taxonomy | ||
| 4 | Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. | ||
| 5 | Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). | ||
| 6 | No behavior change yet, just interfaces and compile-time wiring points. | ||
| 7 | PR 2: CheckpointStore Effect adapter | ||
| 8 | Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter). | ||
| 9 | Map all thrown/Promise errors to tagged errors. | ||
| 10 | Add service tests proving parity for isGitRepository, capture, restore, diff, prune. | ||
| 11 | PR 3: CodexService Effect adapter | ||
| 12 | Wrap current CodexAppServerManager behind CodexServiceLive (adapter). | ||
| 13 | Convert public API to Effect return types with typed errors. | ||
| 14 | Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API. | ||
| 15 | PR 4: ProviderService Effect adapter | ||
| 16 | Wrap current ProviderManager behind ProviderServiceLive (adapter). | ||
| 17 | Provider methods become Effect methods with typed errors. | ||
| 18 | Route emitted provider events through an Effect PubSub surface. | ||
| 19 | PR 5: wsServer migration to Effect services | ||
| 20 | Stop instantiating provider/codex classes directly in wsServer. | ||
| 21 | Resolve ProviderService (and related services) from one runtime/layer graph. | ||
| 22 | Keep WS contract behavior identical. | ||
| 23 | PR 6: Native CheckpointStore implementation | ||
| 24 | Refactor checkpoint internals from Promise/throws to native Effect. | ||
| 25 | Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue). | ||
| 26 | Keep adapter tests plus new failure-path tests. | ||
| 27 | PR 7: Codex transport/RPC core as native Effect | ||
| 28 | Split codex into scoped process layer + RPC request/response layer + session registry. | ||
| 29 | Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics. | ||
| 30 | Keep protocol behavior and ordering guarantees. | ||
| 31 | PR 8: Codex protocol decoding hardening | ||
| 32 | Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes. | ||
| 33 | Map decode failures to typed tagged errors (with root cause). | ||
| 34 | Add regression tests for malformed/partial protocol messages. | ||
| 35 | PR 9: Native ProviderService orchestration | ||
| 36 | Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies. | ||
| 37 | Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services. | ||
| 38 | Remove throw-based flow entirely from provider path. | ||
| 39 | PR 10: Cleanup + deprecation removal | ||
| 40 | Remove legacy class implementations/adapters once parity is proven. | ||
| 41 | Finalize layer composition and startup graph docs. | ||
| 42 | Add architecture notes for service boundaries and error model. | ||
| \ No newline at end of file | |||
apps/server/src/checkpointing/Errors.ts+2-22| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts"; | ||
| 2 | 3 | ||
| 3 | /** | 4 | /** |
| 4 | * CheckpointValidationError - Invalid checkpoint API input. | 5 | * CheckpointValidationError - Invalid checkpoint API input. |
| ... | @@ -67,22 +68,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin | ... | @@ -67,22 +68,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin |
| 67 | } | 68 | } |
| 68 | } | 69 | } |
| 69 | 70 | ||
| 70 | /** | ||
| 71 | * CheckpointCatalogPersistenceError - Metadata persistence/read failure. | ||
| 72 | */ | ||
| 73 | export class CheckpointCatalogPersistenceError extends Schema.TaggedErrorClass<CheckpointCatalogPersistenceError>()( | ||
| 74 | "CheckpointCatalogPersistenceError", | ||
| 75 | { | ||
| 76 | operation: Schema.String, | ||
| 77 | detail: Schema.String, | ||
| 78 | cause: Schema.optional(Schema.Defect), | ||
| 79 | }, | ||
| 80 | ) { | ||
| 81 | override get message(): string { | ||
| 82 | return `Checkpoint catalog persistence error in ${this.operation}: ${this.detail}`; | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | /** | 71 | /** |
| 87 | * CheckpointServiceValidationError - Invalid application-level checkpoint request. | 72 | * CheckpointServiceValidationError - Invalid application-level checkpoint request. |
| 88 | */ | 73 | */ |
| ... | @@ -131,18 +116,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint | ... | @@ -131,18 +116,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint |
| 131 | } | 116 | } |
| 132 | 117 | ||
| 133 | export type CheckpointStoreError = | 118 | export type CheckpointStoreError = |
| 134 | | CheckpointValidationError | ||
| 135 | | CheckpointGitCommandError | 119 | | CheckpointGitCommandError |
| 136 | | CheckpointUnavailableError | 120 | | CheckpointUnavailableError |
| 137 | | CheckpointRepositoryError; | 121 | | CheckpointRepositoryError; |
| 138 | 122 | ||
| 139 | export type CheckpointCatalogError = | ||
| 140 | | CheckpointValidationError | ||
| 141 | | CheckpointCatalogPersistenceError; | ||
| 142 | |||
| 143 | export type CheckpointServiceError = | 123 | export type CheckpointServiceError = |
| 144 | | CheckpointStoreError | 124 | | CheckpointStoreError |
| 145 | | CheckpointCatalogError | 125 | | CheckpointMetadataRepositoryError |
| 146 | | CheckpointServiceValidationError | 126 | | CheckpointServiceValidationError |
| 147 | | CheckpointSessionNotFoundError | 127 | | CheckpointSessionNotFoundError |
| 148 | | CheckpointInvariantError; | 128 | | CheckpointInvariantError; |
apps/server/src/checkpointing/Layers/CheckpointService.test.ts created+404| ... | @@ -0,0 +1,404 @@ | ||
| 1 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import type { | ||
| 6 | ProviderApprovalDecision, | ||
| 7 | ProviderEvent, | ||
| 8 | ProviderKind, | ||
| 9 | ProviderSendTurnInput, | ||
| 10 | ProviderSession, | ||
| 11 | ProviderSessionStartInput, | ||
| 12 | ProviderTurnStartResult, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { NodeServices } from "@effect/platform-node"; | ||
| 15 | import { it, assert } from "@effect/vitest"; | ||
| 16 | import { Effect, Layer } from "effect"; | ||
| 17 | |||
| 18 | import { runGit as runGitProcess } from "../../git/Process.ts"; | ||
| 19 | import { CheckpointServiceLive } from "./CheckpointService.ts"; | ||
| 20 | import { CheckpointStoreLive } from "./CheckpointStore.ts"; | ||
| 21 | import { CheckpointService } from "../Services/CheckpointService.ts"; | ||
| 22 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | ||
| 23 | import { | ||
| 24 | ProviderAdapterSessionNotFoundError, | ||
| 25 | ProviderAdapterValidationError, | ||
| 26 | ProviderUnsupportedError, | ||
| 27 | type ProviderAdapterError, | ||
| 28 | } from "../../provider/Errors.ts"; | ||
| 29 | import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts"; | ||
| 30 | import type { | ||
| 31 | ProviderAdapterShape, | ||
| 32 | ProviderThreadSnapshot, | ||
| 33 | ProviderThreadTurnSnapshot, | ||
| 34 | } from "../../provider/Services/ProviderAdapter.ts"; | ||
| 35 | import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; | ||
| 36 | import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts"; | ||
| 37 | import { makeSqliteCheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; | ||
| 38 | import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; | ||
| 39 | |||
| 40 | const makeTemporaryDirectoryScoped = (prefix: string) => | ||
| 41 | Effect.acquireRelease( | ||
| 42 | Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), | ||
| 43 | (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), | ||
| 44 | ); | ||
| 45 | |||
| 46 | const runGit = (cwd: string, args: readonly string[]) => | ||
| 47 | runGitProcess({ | ||
| 48 | operation: "CheckpointService.test.git", | ||
| 49 | cwd, | ||
| 50 | args, | ||
| 51 | }).pipe( | ||
| 52 | Effect.map((result) => result.stdout.trim()), | ||
| 53 | Effect.provide(NodeServices.layer), | ||
| 54 | ); | ||
| 55 | |||
| 56 | function createRepo() { | ||
| 57 | return Effect.gen(function* () { | ||
| 58 | const cwd = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-repo-"); | ||
| 59 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | ||
| 60 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | ||
| 61 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | ||
| 62 | yield* Effect.promise(() => writeFile(path.join(cwd, "README.md"), "v1\n")); | ||
| 63 | yield* runGit(cwd, ["add", "."]); | ||
| 64 | yield* runGit(cwd, ["commit", "-m", "Initial"]); | ||
| 65 | return cwd; | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | |||
| 69 | class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterError> { | ||
| 70 | readonly provider = "codex" as const; | ||
| 71 | |||
| 72 | private active = true; | ||
| 73 | private snapshot: ProviderThreadSnapshot; | ||
| 74 | private readonly session: ProviderSession; | ||
| 75 | |||
| 76 | constructor( | ||
| 77 | readonly sessionId: string, | ||
| 78 | readonly cwd: string, | ||
| 79 | turns: ReadonlyArray<ProviderThreadTurnSnapshot>, | ||
| 80 | ) { | ||
| 81 | const now = new Date().toISOString(); | ||
| 82 | this.session = { | ||
| 83 | sessionId, | ||
| 84 | provider: "codex", | ||
| 85 | status: "ready", | ||
| 86 | threadId: "thread-1", | ||
| 87 | cwd, | ||
| 88 | createdAt: now, | ||
| 89 | updatedAt: now, | ||
| 90 | }; | ||
| 91 | this.snapshot = { | ||
| 92 | threadId: this.session.threadId ?? "thread-1", | ||
| 93 | turns, | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | setTurns(turns: ReadonlyArray<ProviderThreadTurnSnapshot>): void { | ||
| 98 | this.snapshot = { | ||
| 99 | threadId: this.snapshot.threadId, | ||
| 100 | turns, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | getTurns(): ReadonlyArray<ProviderThreadTurnSnapshot> { | ||
| 105 | return this.snapshot.turns; | ||
| 106 | } | ||
| 107 | |||
| 108 | readonly startSession = ( | ||
| 109 | _input: ProviderSessionStartInput, | ||
| 110 | ): Effect.Effect<ProviderSession, ProviderAdapterError> => | ||
| 111 | Effect.fail( | ||
| 112 | new ProviderAdapterValidationError({ | ||
| 113 | provider: this.provider, | ||
| 114 | operation: "startSession", | ||
| 115 | issue: "not implemented in test adapter", | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | |||
| 119 | readonly sendTurn = ( | ||
| 120 | _input: ProviderSendTurnInput, | ||
| 121 | ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => | ||
| 122 | Effect.fail( | ||
| 123 | new ProviderAdapterValidationError({ | ||
| 124 | provider: this.provider, | ||
| 125 | operation: "sendTurn", | ||
| 126 | issue: "not implemented in test adapter", | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | |||
| 130 | readonly interruptTurn = ( | ||
| 131 | _sessionId: string, | ||
| 132 | _turnId?: string, | ||
| 133 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void; | ||
| 134 | |||
| 135 | readonly respondToRequest = ( | ||
| 136 | _sessionId: string, | ||
| 137 | _requestId: string, | ||
| 138 | _decision: ProviderApprovalDecision, | ||
| 139 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void; | ||
| 140 | |||
| 141 | readonly stopSession = (sessionId: string): Effect.Effect<void, ProviderAdapterError> => | ||
| 142 | Effect.sync(() => { | ||
| 143 | if (sessionId === this.sessionId) { | ||
| 144 | this.active = false; | ||
| 145 | } | ||
| 146 | }); | ||
| 147 | |||
| 148 | readonly listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> => | ||
| 149 | Effect.sync(() => (this.active ? [this.session] : [])); | ||
| 150 | |||
| 151 | readonly hasSession = (sessionId: string): Effect.Effect<boolean> => | ||
| 152 | Effect.succeed(this.active && sessionId === this.sessionId); | ||
| 153 | |||
| 154 | readonly readThread = ( | ||
| 155 | sessionId: string, | ||
| 156 | ): Effect.Effect<ProviderThreadSnapshot, ProviderAdapterError> => | ||
| 157 | this.hasSession(sessionId).pipe( | ||
| 158 | Effect.flatMap((hasSession) => | ||
| 159 | hasSession | ||
| 160 | ? Effect.succeed(this.snapshot) | ||
| 161 | : Effect.fail( | ||
| 162 | new ProviderAdapterSessionNotFoundError({ | ||
| 163 | provider: this.provider, | ||
| 164 | sessionId, | ||
| 165 | }), | ||
| 166 | ), | ||
| 167 | ), | ||
| 168 | ); | ||
| 169 | |||
| 170 | readonly rollbackThread = ( | ||
| 171 | sessionId: string, | ||
| 172 | numTurns: number, | ||
| 173 | ): Effect.Effect<ProviderThreadSnapshot, ProviderAdapterError> => | ||
| 174 | this.readThread(sessionId).pipe( | ||
| 175 | Effect.flatMap((snapshot) => { | ||
| 176 | if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > snapshot.turns.length) { | ||
| 177 | return Effect.fail( | ||
| 178 | new ProviderAdapterValidationError({ | ||
| 179 | provider: this.provider, | ||
| 180 | operation: "rollbackThread", | ||
| 181 | issue: "invalid rollback input", | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | const next = { | ||
| 186 | threadId: snapshot.threadId, | ||
| 187 | turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns), | ||
| 188 | } satisfies ProviderThreadSnapshot; | ||
| 189 | this.snapshot = next; | ||
| 190 | return Effect.succeed(next); | ||
| 191 | }), | ||
| 192 | ); | ||
| 193 | |||
| 194 | readonly stopAll = (): Effect.Effect<void, ProviderAdapterError> => | ||
| 195 | Effect.sync(() => { | ||
| 196 | this.active = false; | ||
| 197 | }); | ||
| 198 | |||
| 199 | readonly subscribeToEvents = ( | ||
| 200 | _callback: (event: ProviderEvent) => void, | ||
| 201 | ): Effect.Effect<() => void, ProviderAdapterError> => Effect.succeed(() => undefined); | ||
| 202 | } | ||
| 203 | |||
| 204 | function makeFixture(turns: ReadonlyArray<ProviderThreadTurnSnapshot>) { | ||
| 205 | return Effect.gen(function* () { | ||
| 206 | const cwd = yield* createRepo(); | ||
| 207 | const stateDir = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-state-"); | ||
| 208 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | ||
| 209 | |||
| 210 | const sessionId = "sess-1"; | ||
| 211 | const adapter = new InMemoryProviderAdapter(sessionId, cwd, turns); | ||
| 212 | |||
| 213 | const registry: typeof ProviderAdapterRegistry.Service = { | ||
| 214 | getByProvider: (provider: ProviderKind) => | ||
| 215 | provider === "codex" | ||
| 216 | ? Effect.succeed(adapter) | ||
| 217 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 218 | listProviders: () => Effect.succeed(["codex"]), | ||
| 219 | }; | ||
| 220 | |||
| 221 | const dependencies = Layer.mergeAll( | ||
| 222 | Layer.provide(CheckpointStoreLive, NodeServices.layer), | ||
| 223 | makeSqliteCheckpointRepositoryLive(dbPath), | ||
| 224 | Layer.succeed(ProviderAdapterRegistry, registry), | ||
| 225 | ProviderSessionDirectoryLive, | ||
| 226 | ); | ||
| 227 | |||
| 228 | const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies)); | ||
| 229 | |||
| 230 | return { | ||
| 231 | layer, | ||
| 232 | adapter, | ||
| 233 | sessionId, | ||
| 234 | cwd, | ||
| 235 | }; | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | it.effect( | ||
| 240 | "CheckpointServiceLive captures checkpoints, diffs refs, and reverts workspace + turns", | ||
| 241 | () => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const firstTurn: ProviderThreadTurnSnapshot = { | ||
| 244 | id: "turn-1", | ||
| 245 | items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], | ||
| 246 | }; | ||
| 247 | const secondTurn: ProviderThreadTurnSnapshot = { | ||
| 248 | id: "turn-2", | ||
| 249 | items: [{ type: "agentMessage", text: "second" }], | ||
| 250 | }; | ||
| 251 | |||
| 252 | const fixture = yield* makeFixture([]); | ||
| 253 | |||
| 254 | yield* Effect.gen(function* () { | ||
| 255 | const service = yield* CheckpointService; | ||
| 256 | const directory = yield* ProviderSessionDirectory; | ||
| 257 | |||
| 258 | yield* directory.upsert({ | ||
| 259 | sessionId: fixture.sessionId, | ||
| 260 | provider: "codex", | ||
| 261 | threadId: "thread-1", | ||
| 262 | }); | ||
| 263 | |||
| 264 | yield* service.initializeForSession({ | ||
| 265 | providerSessionId: fixture.sessionId, | ||
| 266 | cwd: fixture.cwd, | ||
| 267 | }); | ||
| 268 | |||
| 269 | yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); | ||
| 270 | fixture.adapter.setTurns([firstTurn]); | ||
| 271 | |||
| 272 | yield* service.captureCurrentTurn({ | ||
| 273 | providerSessionId: fixture.sessionId, | ||
| 274 | }); | ||
| 275 | |||
| 276 | const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); | ||
| 277 | assert.equal(listed.checkpoints.length, 2); | ||
| 278 | assert.equal(listed.checkpoints[1]?.id, "turn-1"); | ||
| 279 | assert.equal(listed.checkpoints[1]?.isCurrent, true); | ||
| 280 | |||
| 281 | const diff = yield* service.getCheckpointDiff({ | ||
| 282 | sessionId: fixture.sessionId, | ||
| 283 | fromTurnCount: 0, | ||
| 284 | toTurnCount: 1, | ||
| 285 | }); | ||
| 286 | assert.equal(diff.threadId, "thread-1"); | ||
| 287 | assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); | ||
| 288 | assert.equal(diff.diff.includes("-v1"), true); | ||
| 289 | assert.equal(diff.diff.includes("+v2"), true); | ||
| 290 | |||
| 291 | yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v3\n")); | ||
| 292 | fixture.adapter.setTurns([firstTurn, secondTurn]); | ||
| 293 | |||
| 294 | yield* service.captureCurrentTurn({ | ||
| 295 | providerSessionId: fixture.sessionId, | ||
| 296 | }); | ||
| 297 | |||
| 298 | const beforeRevert = yield* Effect.promise(() => | ||
| 299 | readFile(path.join(fixture.cwd, "README.md"), "utf8"), | ||
| 300 | ); | ||
| 301 | assert.equal(beforeRevert, "v3\n"); | ||
| 302 | |||
| 303 | const reverted = yield* service.revertToCheckpoint({ | ||
| 304 | sessionId: fixture.sessionId, | ||
| 305 | turnCount: 1, | ||
| 306 | }); | ||
| 307 | |||
| 308 | const afterRevert = yield* Effect.promise(() => | ||
| 309 | readFile(path.join(fixture.cwd, "README.md"), "utf8"), | ||
| 310 | ); | ||
| 311 | assert.equal(afterRevert, "v2\n"); | ||
| 312 | assert.equal(reverted.turnCount, 1); | ||
| 313 | assert.equal(reverted.rolledBackTurns, 1); | ||
| 314 | assert.equal(reverted.checkpoints.length, 2); | ||
| 315 | assert.equal(reverted.checkpoints[1]?.id, "turn-1"); | ||
| 316 | assert.equal(reverted.checkpoints[1]?.isCurrent, true); | ||
| 317 | assert.equal(fixture.adapter.getTurns().length, 1); | ||
| 318 | }).pipe(Effect.provide(fixture.layer)); | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | |||
| 322 | it.effect( | ||
| 323 | "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions", | ||
| 324 | () => | ||
| 325 | Effect.gen(function* () { | ||
| 326 | const fixture = yield* makeFixture([]); | ||
| 327 | |||
| 328 | yield* Effect.gen(function* () { | ||
| 329 | const service = yield* CheckpointService; | ||
| 330 | const result = yield* service | ||
| 331 | .listCheckpoints({ | ||
| 332 | sessionId: "missing", | ||
| 333 | }) | ||
| 334 | .pipe(Effect.result); | ||
| 335 | |||
| 336 | assert.equal(result._tag, "Failure"); | ||
| 337 | if (result._tag === "Failure") { | ||
| 338 | assert.equal(result.failure._tag, "CheckpointSessionNotFoundError"); | ||
| 339 | } | ||
| 340 | }).pipe(Effect.provide(fixture.layer)); | ||
| 341 | }), | ||
| 342 | ); | ||
| 343 | |||
| 344 | it.effect( | ||
| 345 | "CheckpointServiceLive fails with CheckpointUnavailableError when a checkpoint ref is missing", | ||
| 346 | () => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const firstTurn: ProviderThreadTurnSnapshot = { | ||
| 349 | id: "turn-1", | ||
| 350 | items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], | ||
| 351 | }; | ||
| 352 | |||
| 353 | const fixture = yield* makeFixture([]); | ||
| 354 | |||
| 355 | yield* Effect.gen(function* () { | ||
| 356 | const service = yield* CheckpointService; | ||
| 357 | const store = yield* CheckpointStore; | ||
| 358 | const checkpointRepository = yield* CheckpointRepository; | ||
| 359 | const directory = yield* ProviderSessionDirectory; | ||
| 360 | |||
| 361 | yield* directory.upsert({ | ||
| 362 | sessionId: fixture.sessionId, | ||
| 363 | provider: "codex", | ||
| 364 | threadId: "thread-1", | ||
| 365 | }); | ||
| 366 | |||
| 367 | yield* service.initializeForSession({ | ||
| 368 | providerSessionId: fixture.sessionId, | ||
| 369 | cwd: fixture.cwd, | ||
| 370 | }); | ||
| 371 | |||
| 372 | yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); | ||
| 373 | fixture.adapter.setTurns([firstTurn]); | ||
| 374 | yield* service.captureCurrentTurn({ | ||
| 375 | providerSessionId: fixture.sessionId, | ||
| 376 | }); | ||
| 377 | |||
| 378 | const checkpoint = yield* checkpointRepository.getCheckpoint({ | ||
| 379 | providerSessionId: fixture.sessionId, | ||
| 380 | turnCount: 1, | ||
| 381 | }); | ||
| 382 | if (checkpoint._tag !== "Some") { | ||
| 383 | assert.fail("Expected turn-1 checkpoint row to exist."); | ||
| 384 | } | ||
| 385 | |||
| 386 | yield* store.deleteCheckpointRefs({ | ||
| 387 | cwd: fixture.cwd, | ||
| 388 | checkpointRefs: [checkpoint.value.checkpointRef], | ||
| 389 | }); | ||
| 390 | |||
| 391 | const result = yield* service | ||
| 392 | .revertToCheckpoint({ | ||
| 393 | sessionId: fixture.sessionId, | ||
| 394 | turnCount: 1, | ||
| 395 | }) | ||
| 396 | .pipe(Effect.result); | ||
| 397 | |||
| 398 | assert.equal(result._tag, "Failure"); | ||
| 399 | if (result._tag === "Failure") { | ||
| 400 | assert.equal(result.failure._tag, "CheckpointUnavailableError"); | ||
| 401 | } | ||
| 402 | }).pipe(Effect.provide(fixture.layer)); | ||
| 403 | }), | ||
| 404 | ); | ||
apps/server/src/checkpointing/Layers/CheckpointService.ts created+1008| ... | @@ -0,0 +1,1008 @@ | ||
| 1 | /** | ||
| 2 | * CheckpointServiceLive - Checkpoint workflow orchestration layer. | ||
| 3 | * | ||
| 4 | * Coordinates provider thread snapshots, filesystem checkpoint refs | ||
| 5 | * (`CheckpointStore`), and checkpoint metadata persistence | ||
| 6 | * (`CheckpointRepository`) for initialize/capture/list/diff/revert workflows. | ||
| 7 | * | ||
| 8 | * This layer owns checkpoint workflow rules and invariants. It does not execute | ||
| 9 | * provider protocol details directly (adapter concern), and it does not perform | ||
| 10 | * raw SQL or Git operations directly (catalog/store concerns). | ||
| 11 | * | ||
| 12 | * @module CheckpointServiceLive | ||
| 13 | */ | ||
| 14 | import type { | ||
| 15 | ProviderCheckpoint, | ||
| 16 | ProviderGetCheckpointDiffInput, | ||
| 17 | ProviderListCheckpointsInput, | ||
| 18 | ProviderRevertToCheckpointInput, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { | ||
| 21 | providerGetCheckpointDiffInputSchema, | ||
| 22 | providerListCheckpointsInputSchema, | ||
| 23 | providerRevertToCheckpointInputSchema, | ||
| 24 | } from "@t3tools/contracts"; | ||
| 25 | import { Effect, Layer, Option, Ref, Semaphore } from "effect"; | ||
| 26 | |||
| 27 | import { | ||
| 28 | CheckpointInvariantError, | ||
| 29 | CheckpointRepositoryError, | ||
| 30 | CheckpointServiceValidationError, | ||
| 31 | CheckpointSessionNotFoundError, | ||
| 32 | CheckpointUnavailableError, | ||
| 33 | type CheckpointServiceError, | ||
| 34 | } from "../Errors.ts"; | ||
| 35 | import { | ||
| 36 | CheckpointService, | ||
| 37 | type CaptureCurrentTurnInput, | ||
| 38 | type CheckpointServiceShape, | ||
| 39 | type InitializeCheckpointSessionInput, | ||
| 40 | } from "../Services/CheckpointService.ts"; | ||
| 41 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | ||
| 42 | import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts"; | ||
| 43 | import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; | ||
| 44 | import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; | ||
| 45 | import type { | ||
| 46 | ProviderAdapterShape, | ||
| 47 | ProviderThreadSnapshot, | ||
| 48 | ProviderThreadTurnSnapshot, | ||
| 49 | } from "../../provider/Services/ProviderAdapter.ts"; | ||
| 50 | import type { ProviderAdapterError } from "../../provider/Errors.ts"; | ||
| 51 | |||
| 52 | interface CheckpointCwdResolution { | ||
| 53 | readonly candidateCwd: string; | ||
| 54 | readonly checkpointCwd: Option.Option<string>; | ||
| 55 | } | ||
| 56 | |||
| 57 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | ||
| 58 | |||
| 59 | function checkpointRefThreadSegment(threadId: string): string { | ||
| 60 | return Buffer.from(threadId, "utf8").toString("base64url"); | ||
| 61 | } | ||
| 62 | |||
| 63 | function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { | ||
| 64 | return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; | ||
| 65 | } | ||
| 66 | |||
| 67 | function asObject(value: unknown): Record<string, unknown> | undefined { | ||
| 68 | if (!value || typeof value !== "object") { | ||
| 69 | return undefined; | ||
| 70 | } | ||
| 71 | return value as Record<string, unknown>; | ||
| 72 | } | ||
| 73 | |||
| 74 | function asString(value: unknown): string | undefined { | ||
| 75 | return typeof value === "string" ? value : undefined; | ||
| 76 | } | ||
| 77 | |||
| 78 | function asArray(value: unknown): unknown[] { | ||
| 79 | return Array.isArray(value) ? value : []; | ||
| 80 | } | ||
| 81 | |||
| 82 | function trimToPreview(value: string): string { | ||
| 83 | const normalized = value.replace(/\s+/g, " ").trim(); | ||
| 84 | if (normalized.length <= 120) { | ||
| 85 | return normalized; | ||
| 86 | } | ||
| 87 | return `${normalized.slice(0, 117)}...`; | ||
| 88 | } | ||
| 89 | |||
| 90 | function summarizeUserMessageContent(content: unknown[]): string | undefined { | ||
| 91 | const segments: string[] = []; | ||
| 92 | |||
| 93 | for (const part of content) { | ||
| 94 | const record = asObject(part); | ||
| 95 | const type = asString(record?.type); | ||
| 96 | if (!type) { | ||
| 97 | continue; | ||
| 98 | } | ||
| 99 | |||
| 100 | if (type === "text") { | ||
| 101 | const text = asString(record?.text); | ||
| 102 | if (text && text.trim().length > 0) { | ||
| 103 | segments.push(text.trim()); | ||
| 104 | } | ||
| 105 | continue; | ||
| 106 | } | ||
| 107 | |||
| 108 | if (type === "image") { | ||
| 109 | segments.push("[Image attachment]"); | ||
| 110 | continue; | ||
| 111 | } | ||
| 112 | |||
| 113 | if (type === "localImage") { | ||
| 114 | segments.push("[Local image attachment]"); | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | if (segments.length === 0) { | ||
| 119 | return undefined; | ||
| 120 | } | ||
| 121 | return trimToPreview(segments.join(" ")); | ||
| 122 | } | ||
| 123 | |||
| 124 | function summarizeTurn(turn: ProviderThreadTurnSnapshot): { | ||
| 125 | readonly messageCountDelta: number; | ||
| 126 | readonly preview?: string; | ||
| 127 | } { | ||
| 128 | let messageCountDelta = 0; | ||
| 129 | let preview: string | undefined; | ||
| 130 | |||
| 131 | for (const item of turn.items) { | ||
| 132 | const record = asObject(item); | ||
| 133 | const type = asString(record?.type); | ||
| 134 | if (!type) { | ||
| 135 | continue; | ||
| 136 | } | ||
| 137 | |||
| 138 | if (type === "userMessage") { | ||
| 139 | messageCountDelta += 1; | ||
| 140 | if (!preview) { | ||
| 141 | const content = asArray(record?.content); | ||
| 142 | preview = summarizeUserMessageContent(content); | ||
| 143 | } | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | |||
| 147 | if (type === "agentMessage") { | ||
| 148 | messageCountDelta += 1; | ||
| 149 | if (!preview) { | ||
| 150 | const text = asString(record?.text); | ||
| 151 | if (text && text.trim().length > 0) { | ||
| 152 | preview = trimToPreview(text); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | return { | ||
| 159 | messageCountDelta, | ||
| 160 | ...(preview ? { preview } : {}), | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 164 | function buildCheckpoints(turns: ReadonlyArray<ProviderThreadTurnSnapshot>): ProviderCheckpoint[] { | ||
| 165 | const checkpoints: ProviderCheckpoint[] = []; | ||
| 166 | let messageCount = 0; | ||
| 167 | const isEmpty = turns.length === 0; | ||
| 168 | |||
| 169 | checkpoints.push({ | ||
| 170 | id: "root", | ||
| 171 | turnCount: 0, | ||
| 172 | messageCount: 0, | ||
| 173 | label: "Start of conversation", | ||
| 174 | isCurrent: isEmpty, | ||
| 175 | }); | ||
| 176 | |||
| 177 | for (let index = 0; index < turns.length; index += 1) { | ||
| 178 | const turn = turns[index]; | ||
| 179 | if (!turn) { | ||
| 180 | continue; | ||
| 181 | } | ||
| 182 | |||
| 183 | const turnSummary = summarizeTurn(turn); | ||
| 184 | messageCount += turnSummary.messageCountDelta; | ||
| 185 | checkpoints.push({ | ||
| 186 | id: turn.id, | ||
| 187 | turnCount: index + 1, | ||
| 188 | messageCount, | ||
| 189 | label: `Turn ${index + 1}`, | ||
| 190 | ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), | ||
| 191 | isCurrent: index === turns.length - 1, | ||
| 192 | }); | ||
| 193 | } | ||
| 194 | |||
| 195 | return checkpoints; | ||
| 196 | } | ||
| 197 | |||
| 198 | function rootCheckpoint(): ProviderCheckpoint { | ||
| 199 | return { | ||
| 200 | id: "root", | ||
| 201 | turnCount: 0, | ||
| 202 | messageCount: 0, | ||
| 203 | label: "Start of conversation", | ||
| 204 | isCurrent: true, | ||
| 205 | }; | ||
| 206 | } | ||
| 207 | |||
| 208 | function validationError( | ||
| 209 | operation: string, | ||
| 210 | issue: string, | ||
| 211 | cause?: unknown, | ||
| 212 | ): CheckpointServiceValidationError { | ||
| 213 | return new CheckpointServiceValidationError({ | ||
| 214 | operation, | ||
| 215 | issue, | ||
| 216 | ...(cause !== undefined ? { cause } : {}), | ||
| 217 | }); | ||
| 218 | } | ||
| 219 | |||
| 220 | function invariantError( | ||
| 221 | operation: string, | ||
| 222 | detail: string, | ||
| 223 | cause?: unknown, | ||
| 224 | ): CheckpointInvariantError { | ||
| 225 | return new CheckpointInvariantError({ | ||
| 226 | operation, | ||
| 227 | detail, | ||
| 228 | ...(cause !== undefined ? { cause } : {}), | ||
| 229 | }); | ||
| 230 | } | ||
| 231 | |||
| 232 | function normalizeNonEmpty( | ||
| 233 | value: string, | ||
| 234 | operation: string, | ||
| 235 | field: string, | ||
| 236 | ): Effect.Effect<string, CheckpointServiceValidationError> { | ||
| 237 | const normalized = value.trim(); | ||
| 238 | if (normalized.length === 0) { | ||
| 239 | return Effect.fail(validationError(operation, `${field} must be a non-empty string.`)); | ||
| 240 | } | ||
| 241 | return Effect.succeed(normalized); | ||
| 242 | } | ||
| 243 | |||
| 244 | function decodeInitializeInput( | ||
| 245 | input: InitializeCheckpointSessionInput, | ||
| 246 | ): Effect.Effect<InitializeCheckpointSessionInput, CheckpointServiceValidationError> { | ||
| 247 | return Effect.gen(function* () { | ||
| 248 | const operation = "CheckpointService.initializeForSession"; | ||
| 249 | const providerSessionId = yield* normalizeNonEmpty( | ||
| 250 | input.providerSessionId, | ||
| 251 | operation, | ||
| 252 | "providerSessionId", | ||
| 253 | ); | ||
| 254 | const cwd = yield* normalizeNonEmpty(input.cwd, operation, "cwd"); | ||
| 255 | return { | ||
| 256 | providerSessionId, | ||
| 257 | cwd, | ||
| 258 | } satisfies InitializeCheckpointSessionInput; | ||
| 259 | }); | ||
| 260 | } | ||
| 261 | |||
| 262 | function decodeCaptureInput( | ||
| 263 | input: CaptureCurrentTurnInput, | ||
| 264 | ): Effect.Effect<CaptureCurrentTurnInput, CheckpointServiceValidationError> { | ||
| 265 | return Effect.gen(function* () { | ||
| 266 | const operation = "CheckpointService.captureCurrentTurn"; | ||
| 267 | const providerSessionId = yield* normalizeNonEmpty( | ||
| 268 | input.providerSessionId, | ||
| 269 | operation, | ||
| 270 | "providerSessionId", | ||
| 271 | ); | ||
| 272 | const turnId = input.turnId?.trim(); | ||
| 273 | const status = input.status?.trim(); | ||
| 274 | |||
| 275 | return { | ||
| 276 | providerSessionId, | ||
| 277 | ...(turnId ? { turnId } : {}), | ||
| 278 | ...(status ? { status } : {}), | ||
| 279 | } satisfies CaptureCurrentTurnInput; | ||
| 280 | }); | ||
| 281 | } | ||
| 282 | |||
| 283 | function decodeListInput( | ||
| 284 | input: ProviderListCheckpointsInput, | ||
| 285 | ): Effect.Effect<ProviderListCheckpointsInput, CheckpointServiceValidationError> { | ||
| 286 | const parsed = providerListCheckpointsInputSchema.safeParse(input); | ||
| 287 | if (!parsed.success) { | ||
| 288 | return Effect.fail( | ||
| 289 | validationError( | ||
| 290 | "CheckpointService.listCheckpoints", | ||
| 291 | parsed.error.message, | ||
| 292 | parsed.error.cause, | ||
| 293 | ), | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | return Effect.succeed(parsed.data); | ||
| 297 | } | ||
| 298 | |||
| 299 | function decodeDiffInput( | ||
| 300 | input: ProviderGetCheckpointDiffInput, | ||
| 301 | ): Effect.Effect<ProviderGetCheckpointDiffInput, CheckpointServiceValidationError> { | ||
| 302 | const parsed = providerGetCheckpointDiffInputSchema.safeParse(input); | ||
| 303 | if (!parsed.success) { | ||
| 304 | return Effect.fail( | ||
| 305 | validationError( | ||
| 306 | "CheckpointService.getCheckpointDiff", | ||
| 307 | parsed.error.message, | ||
| 308 | parsed.error.cause, | ||
| 309 | ), | ||
| 310 | ); | ||
| 311 | } | ||
| 312 | return Effect.succeed(parsed.data); | ||
| 313 | } | ||
| 314 | |||
| 315 | function decodeRevertInput( | ||
| 316 | input: ProviderRevertToCheckpointInput, | ||
| 317 | ): Effect.Effect<ProviderRevertToCheckpointInput, CheckpointServiceValidationError> { | ||
| 318 | const parsed = providerRevertToCheckpointInputSchema.safeParse(input); | ||
| 319 | if (!parsed.success) { | ||
| 320 | return Effect.fail( | ||
| 321 | validationError( | ||
| 322 | "CheckpointService.revertToCheckpoint", | ||
| 323 | parsed.error.message, | ||
| 324 | parsed.error.cause, | ||
| 325 | ), | ||
| 326 | ); | ||
| 327 | } | ||
| 328 | return Effect.succeed(parsed.data); | ||
| 329 | } | ||
| 330 | |||
| 331 | const makeCheckpointService = Effect.gen(function* () { | ||
| 332 | const store = yield* CheckpointStore; | ||
| 333 | const checkpointRepository = yield* CheckpointRepository; | ||
| 334 | const registry = yield* ProviderAdapterRegistry; | ||
| 335 | const directory = yield* ProviderSessionDirectory; | ||
| 336 | |||
| 337 | const sessionCheckpointCwdsRef = yield* Ref.make<Map<string, string>>(new Map<string, string>()); | ||
| 338 | const sessionSemaphoresRef = yield* Ref.make<Map<string, Semaphore.Semaphore>>( | ||
| 339 | new Map<string, Semaphore.Semaphore>(), | ||
| 340 | ); | ||
| 341 | |||
| 342 | const getSessionSemaphore = (sessionId: string) => | ||
| 343 | Ref.modify(sessionSemaphoresRef, (current) => { | ||
| 344 | const existing = current.get(sessionId); | ||
| 345 | if (existing) { | ||
| 346 | return [existing, current] as const; | ||
| 347 | } | ||
| 348 | |||
| 349 | const next = new Map(current); | ||
| 350 | const semaphore = Semaphore.makeUnsafe(1); | ||
| 351 | next.set(sessionId, semaphore); | ||
| 352 | return [semaphore, next] as const; | ||
| 353 | }); | ||
| 354 | |||
| 355 | const withSessionLock = <A>( | ||
| 356 | sessionId: string, | ||
| 357 | effect: Effect.Effect<A, CheckpointServiceError>, | ||
| 358 | ) => | ||
| 359 | getSessionSemaphore(sessionId).pipe( | ||
| 360 | Effect.flatMap((semaphore) => semaphore.withPermits(1)(effect)), | ||
| 361 | ); | ||
| 362 | |||
| 363 | const setTrackedCheckpointCwd = (sessionId: string, cwd: string) => | ||
| 364 | Ref.update(sessionCheckpointCwdsRef, (current) => { | ||
| 365 | const next = new Map(current); | ||
| 366 | next.set(sessionId, cwd); | ||
| 367 | return next; | ||
| 368 | }); | ||
| 369 | |||
| 370 | const clearTrackedCheckpointCwd = (sessionId: string) => | ||
| 371 | Ref.update(sessionCheckpointCwdsRef, (current) => { | ||
| 372 | if (!current.has(sessionId)) { | ||
| 373 | return current; | ||
| 374 | } | ||
| 375 | const next = new Map(current); | ||
| 376 | next.delete(sessionId); | ||
| 377 | return next; | ||
| 378 | }); | ||
| 379 | |||
| 380 | const getTrackedCheckpointCwd = (sessionId: string) => | ||
| 381 | Ref.get(sessionCheckpointCwdsRef).pipe( | ||
| 382 | Effect.map((current) => Option.fromNullishOr(current.get(sessionId))), | ||
| 383 | ); | ||
| 384 | |||
| 385 | const resolveAdapterForSession = ( | ||
| 386 | sessionId: string, | ||
| 387 | ): Effect.Effect<ProviderAdapterShape<ProviderAdapterError>, CheckpointServiceError> => | ||
| 388 | directory.getProvider(sessionId).pipe( | ||
| 389 | Effect.mapError( | ||
| 390 | (cause) => | ||
| 391 | new CheckpointSessionNotFoundError({ | ||
| 392 | sessionId, | ||
| 393 | cause, | ||
| 394 | }), | ||
| 395 | ), | ||
| 396 | Effect.flatMap((provider) => | ||
| 397 | registry | ||
| 398 | .getByProvider(provider) | ||
| 399 | .pipe( | ||
| 400 | Effect.mapError((cause) => | ||
| 401 | invariantError( | ||
| 402 | "CheckpointService.resolveAdapterForSession", | ||
| 403 | `Provider '${provider}' is not registered for session ${sessionId}.`, | ||
| 404 | cause, | ||
| 405 | ), | ||
| 406 | ), | ||
| 407 | ), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | |||
| 411 | const ensureSessionIsActive = ( | ||
| 412 | sessionId: string, | ||
| 413 | adapter: ProviderAdapterShape<ProviderAdapterError>, | ||
| 414 | ): Effect.Effect<void, CheckpointSessionNotFoundError> => | ||
| 415 | adapter.hasSession(sessionId).pipe( | ||
| 416 | Effect.flatMap((hasSession) => | ||
| 417 | hasSession | ||
| 418 | ? Effect.void | ||
| 419 | : Effect.fail( | ||
| 420 | new CheckpointSessionNotFoundError({ | ||
| 421 | sessionId, | ||
| 422 | }), | ||
| 423 | ), | ||
| 424 | ), | ||
| 425 | ); | ||
| 426 | |||
| 427 | const readThreadSnapshot = ( | ||
| 428 | sessionId: string, | ||
| 429 | adapter: ProviderAdapterShape<ProviderAdapterError>, | ||
| 430 | operation: string, | ||
| 431 | ): Effect.Effect<ProviderThreadSnapshot, CheckpointInvariantError> => | ||
| 432 | adapter | ||
| 433 | .readThread(sessionId) | ||
| 434 | .pipe( | ||
| 435 | Effect.mapError((cause) => | ||
| 436 | invariantError( | ||
| 437 | operation, | ||
| 438 | `Failed to read thread snapshot for session ${sessionId}.`, | ||
| 439 | cause, | ||
| 440 | ), | ||
| 441 | ), | ||
| 442 | ); | ||
| 443 | |||
| 444 | const rollbackThreadSnapshot = ( | ||
| 445 | sessionId: string, | ||
| 446 | adapter: ProviderAdapterShape<ProviderAdapterError>, | ||
| 447 | numTurns: number, | ||
| 448 | operation: string, | ||
| 449 | ): Effect.Effect<ProviderThreadSnapshot, CheckpointInvariantError> => | ||
| 450 | adapter | ||
| 451 | .rollbackThread(sessionId, numTurns) | ||
| 452 | .pipe( | ||
| 453 | Effect.mapError((cause) => | ||
| 454 | invariantError(operation, `Failed to rollback thread for session ${sessionId}.`, cause), | ||
| 455 | ), | ||
| 456 | ); | ||
| 457 | |||
| 458 | const persistCheckpointMetadata = (input: { | ||
| 459 | readonly providerSessionId: string; | ||
| 460 | readonly threadId: string; | ||
| 461 | readonly checkpoint: ProviderCheckpoint; | ||
| 462 | }): Effect.Effect<void, CheckpointServiceError> => | ||
| 463 | checkpointRepository.upsertCheckpoint({ | ||
| 464 | providerSessionId: input.providerSessionId, | ||
| 465 | threadId: input.threadId, | ||
| 466 | checkpointId: input.checkpoint.id, | ||
| 467 | checkpointRef: checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount), | ||
| 468 | turnCount: input.checkpoint.turnCount, | ||
| 469 | messageCount: input.checkpoint.messageCount, | ||
| 470 | label: input.checkpoint.label, | ||
| 471 | ...(input.checkpoint.preview !== undefined ? { preview: input.checkpoint.preview } : {}), | ||
| 472 | createdAt: new Date().toISOString(), | ||
| 473 | }); | ||
| 474 | |||
| 475 | const captureAndPersistCheckpoint = (input: { | ||
| 476 | readonly providerSessionId: string; | ||
| 477 | readonly cwd: string; | ||
| 478 | readonly threadId: string; | ||
| 479 | readonly checkpoint: ProviderCheckpoint; | ||
| 480 | }): Effect.Effect<void, CheckpointServiceError> => { | ||
| 481 | const checkpointRef = checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount); | ||
| 482 | return Effect.gen(function* () { | ||
| 483 | yield* store.captureCheckpoint({ | ||
| 484 | cwd: input.cwd, | ||
| 485 | checkpointRef, | ||
| 486 | }); | ||
| 487 | |||
| 488 | yield* persistCheckpointMetadata({ | ||
| 489 | providerSessionId: input.providerSessionId, | ||
| 490 | threadId: input.threadId, | ||
| 491 | checkpoint: input.checkpoint, | ||
| 492 | }).pipe( | ||
| 493 | Effect.catch((error) => | ||
| 494 | store | ||
| 495 | .deleteCheckpointRefs({ | ||
| 496 | cwd: input.cwd, | ||
| 497 | checkpointRefs: [checkpointRef], | ||
| 498 | }) | ||
| 499 | .pipe( | ||
| 500 | Effect.catch(() => Effect.void), | ||
| 501 | Effect.flatMap(() => Effect.fail(error)), | ||
| 502 | ), | ||
| 503 | ), | ||
| 504 | ); | ||
| 505 | }); | ||
| 506 | }; | ||
| 507 | |||
| 508 | const latestCheckpointFromSnapshot = ( | ||
| 509 | snapshot: ProviderThreadSnapshot, | ||
| 510 | operation: string, | ||
| 511 | ): Effect.Effect<ProviderCheckpoint, CheckpointInvariantError> => { | ||
| 512 | if (snapshot.turns.length === 0) { | ||
| 513 | return Effect.fail( | ||
| 514 | invariantError(operation, "Cannot derive latest checkpoint from an empty thread snapshot."), | ||
| 515 | ); | ||
| 516 | } | ||
| 517 | |||
| 518 | const checkpoints = buildCheckpoints(snapshot.turns); | ||
| 519 | const latest = checkpoints[checkpoints.length - 1]; | ||
| 520 | if (!latest || latest.turnCount !== snapshot.turns.length) { | ||
| 521 | return Effect.fail( | ||
| 522 | invariantError( | ||
| 523 | operation, | ||
| 524 | `Unable to derive checkpoint metadata for turn ${snapshot.turns.length}.`, | ||
| 525 | ), | ||
| 526 | ); | ||
| 527 | } | ||
| 528 | |||
| 529 | return Effect.succeed(latest); | ||
| 530 | }; | ||
| 531 | |||
| 532 | const ensureRootCheckpointMetadata = ( | ||
| 533 | providerSessionId: string, | ||
| 534 | operation: string, | ||
| 535 | ): Effect.Effect<void, CheckpointServiceError> => | ||
| 536 | checkpointRepository.getCheckpoint({ providerSessionId, turnCount: 0 }).pipe( | ||
| 537 | Effect.flatMap((rootEntry) => | ||
| 538 | Option.isSome(rootEntry) | ||
| 539 | ? Effect.void | ||
| 540 | : Effect.fail( | ||
| 541 | invariantError( | ||
| 542 | operation, | ||
| 543 | `Missing root checkpoint metadata for session ${providerSessionId}.`, | ||
| 544 | ), | ||
| 545 | ), | ||
| 546 | ), | ||
| 547 | ); | ||
| 548 | |||
| 549 | const getCheckpointRefOrFail = ( | ||
| 550 | providerSessionId: string, | ||
| 551 | threadId: string, | ||
| 552 | turnCount: number, | ||
| 553 | operation: string, | ||
| 554 | ): Effect.Effect<string, CheckpointServiceError> => | ||
| 555 | checkpointRepository.getCheckpoint({ providerSessionId, turnCount }).pipe( | ||
| 556 | Effect.mapError((error): CheckpointServiceError => error), | ||
| 557 | Effect.flatMap((checkpoint): Effect.Effect<string, CheckpointServiceError> => { | ||
| 558 | if (Option.isNone(checkpoint)) { | ||
| 559 | return Effect.fail( | ||
| 560 | new CheckpointUnavailableError({ | ||
| 561 | threadId, | ||
| 562 | turnCount, | ||
| 563 | detail: `Filesystem checkpoint is unavailable for turn ${turnCount} in thread ${threadId}.`, | ||
| 564 | }), | ||
| 565 | ); | ||
| 566 | } | ||
| 567 | |||
| 568 | const checkpointRef = checkpoint.value.checkpointRef.trim(); | ||
| 569 | if (checkpointRef.length === 0) { | ||
| 570 | return Effect.fail( | ||
| 571 | invariantError( | ||
| 572 | operation, | ||
| 573 | `Checkpoint metadata for turn ${turnCount} is missing checkpointRef.`, | ||
| 574 | ), | ||
| 575 | ); | ||
| 576 | } | ||
| 577 | |||
| 578 | return Effect.succeed(checkpointRef); | ||
| 579 | }), | ||
| 580 | ); | ||
| 581 | |||
| 582 | const resolveCandidateCwd = ( | ||
| 583 | sessionId: string, | ||
| 584 | adapter: ProviderAdapterShape<ProviderAdapterError>, | ||
| 585 | ): Effect.Effect<string> => | ||
| 586 | adapter.listSessions().pipe( | ||
| 587 | Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.cwd), | ||
| 588 | Effect.map((cwd) => { | ||
| 589 | const candidate = cwd?.trim() || process.cwd(); | ||
| 590 | if (candidate.length > 0) { | ||
| 591 | return candidate; | ||
| 592 | } | ||
| 593 | return process.cwd(); | ||
| 594 | }), | ||
| 595 | ); | ||
| 596 | |||
| 597 | const resolveCheckpointCwd = ( | ||
| 598 | sessionId: string, | ||
| 599 | adapter: ProviderAdapterShape<ProviderAdapterError>, | ||
| 600 | ): Effect.Effect<CheckpointCwdResolution, CheckpointServiceError> => | ||
| 601 | Effect.gen(function* () { | ||
| 602 | const tracked = yield* getTrackedCheckpointCwd(sessionId); | ||
| 603 | if (Option.isSome(tracked)) { | ||
| 604 | return { | ||
| 605 | candidateCwd: tracked.value, | ||
| 606 | checkpointCwd: tracked, | ||
| 607 | } satisfies CheckpointCwdResolution; | ||
| 608 | } | ||
| 609 | |||
| 610 | const candidateCwd = yield* resolveCandidateCwd(sessionId, adapter); | ||
| 611 | const supportsGit = yield* store.isGitRepository(candidateCwd); | ||
| 612 | if (!supportsGit) { | ||
| 613 | yield* clearTrackedCheckpointCwd(sessionId); | ||
| 614 | return { | ||
| 615 | candidateCwd, | ||
| 616 | checkpointCwd: Option.none(), | ||
| 617 | } satisfies CheckpointCwdResolution; | ||
| 618 | } | ||
| 619 | |||
| 620 | yield* setTrackedCheckpointCwd(sessionId, candidateCwd); | ||
| 621 | |||
| 622 | return { | ||
| 623 | candidateCwd, | ||
| 624 | checkpointCwd: Option.some(candidateCwd), | ||
| 625 | } satisfies CheckpointCwdResolution; | ||
| 626 | }); | ||
| 627 | |||
| 628 | const initializeForSession: CheckpointServiceShape["initializeForSession"] = (rawInput) => | ||
| 629 | decodeInitializeInput(rawInput).pipe( | ||
| 630 | Effect.flatMap((input) => | ||
| 631 | withSessionLock( | ||
| 632 | input.providerSessionId, | ||
| 633 | Effect.gen(function* () { | ||
| 634 | const adapter = yield* resolveAdapterForSession(input.providerSessionId); | ||
| 635 | yield* ensureSessionIsActive(input.providerSessionId, adapter); | ||
| 636 | const snapshot = yield* readThreadSnapshot( | ||
| 637 | input.providerSessionId, | ||
| 638 | adapter, | ||
| 639 | "CheckpointService.initializeForSession:readThread", | ||
| 640 | ); | ||
| 641 | if (snapshot.turns.length !== 0) { | ||
| 642 | return yield* Effect.fail( | ||
| 643 | invariantError( | ||
| 644 | "CheckpointService.initializeForSession", | ||
| 645 | `Expected empty thread snapshot during initialization, received ${snapshot.turns.length} turn(s).`, | ||
| 646 | ), | ||
| 647 | ); | ||
| 648 | } | ||
| 649 | |||
| 650 | const supportsGit = yield* store.isGitRepository(input.cwd); | ||
| 651 | if (!supportsGit) { | ||
| 652 | yield* clearTrackedCheckpointCwd(input.providerSessionId); | ||
| 653 | return; | ||
| 654 | } | ||
| 655 | |||
| 656 | const existingRoot = yield* checkpointRepository.getCheckpoint({ | ||
| 657 | providerSessionId: input.providerSessionId, | ||
| 658 | turnCount: 0, | ||
| 659 | }); | ||
| 660 | if (Option.isSome(existingRoot)) { | ||
| 661 | const existingRef = existingRoot.value.checkpointRef.trim(); | ||
| 662 | if (existingRef.length === 0) { | ||
| 663 | return yield* Effect.fail( | ||
| 664 | invariantError( | ||
| 665 | "CheckpointService.initializeForSession", | ||
| 666 | "Existing root checkpoint metadata is missing checkpointRef.", | ||
| 667 | ), | ||
| 668 | ); | ||
| 669 | } | ||
| 670 | const exists = yield* store.hasCheckpointRef({ | ||
| 671 | cwd: input.cwd, | ||
| 672 | checkpointRef: existingRef, | ||
| 673 | }); | ||
| 674 | if (!exists) { | ||
| 675 | return yield* Effect.fail( | ||
| 676 | invariantError( | ||
| 677 | "CheckpointService.initializeForSession", | ||
| 678 | "Root checkpoint metadata exists but filesystem ref is missing.", | ||
| 679 | ), | ||
| 680 | ); | ||
| 681 | } | ||
| 682 | yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); | ||
| 683 | return; | ||
| 684 | } | ||
| 685 | |||
| 686 | yield* captureAndPersistCheckpoint({ | ||
| 687 | providerSessionId: input.providerSessionId, | ||
| 688 | cwd: input.cwd, | ||
| 689 | threadId: snapshot.threadId, | ||
| 690 | checkpoint: rootCheckpoint(), | ||
| 691 | }); | ||
| 692 | yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); | ||
| 693 | }), | ||
| 694 | ), | ||
| 695 | ), | ||
| 696 | ); | ||
| 697 | |||
| 698 | const captureCurrentTurn: CheckpointServiceShape["captureCurrentTurn"] = (rawInput) => | ||
| 699 | decodeCaptureInput(rawInput).pipe( | ||
| 700 | Effect.flatMap((input) => | ||
| 701 | withSessionLock( | ||
| 702 | input.providerSessionId, | ||
| 703 | Effect.gen(function* () { | ||
| 704 | const adapter = yield* resolveAdapterForSession(input.providerSessionId); | ||
| 705 | yield* ensureSessionIsActive(input.providerSessionId, adapter); | ||
| 706 | const snapshot = yield* readThreadSnapshot( | ||
| 707 | input.providerSessionId, | ||
| 708 | adapter, | ||
| 709 | "CheckpointService.captureCurrentTurn:readThread", | ||
| 710 | ); | ||
| 711 | if (snapshot.turns.length === 0) { | ||
| 712 | return; | ||
| 713 | } | ||
| 714 | |||
| 715 | const resolution = yield* resolveCheckpointCwd(input.providerSessionId, adapter); | ||
| 716 | if (Option.isNone(resolution.checkpointCwd)) { | ||
| 717 | return; | ||
| 718 | } | ||
| 719 | |||
| 720 | const checkpoint = yield* latestCheckpointFromSnapshot( | ||
| 721 | snapshot, | ||
| 722 | "CheckpointService.captureCurrentTurn", | ||
| 723 | ); | ||
| 724 | |||
| 725 | yield* ensureRootCheckpointMetadata( | ||
| 726 | input.providerSessionId, | ||
| 727 | "CheckpointService.captureCurrentTurn", | ||
| 728 | ); | ||
| 729 | |||
| 730 | const existing = yield* checkpointRepository.getCheckpoint({ | ||
| 731 | providerSessionId: input.providerSessionId, | ||
| 732 | turnCount: checkpoint.turnCount, | ||
| 733 | }); | ||
| 734 | if (Option.isSome(existing)) { | ||
| 735 | const checkpointRef = existing.value.checkpointRef.trim(); | ||
| 736 | if (checkpointRef.length === 0) { | ||
| 737 | return yield* Effect.fail( | ||
| 738 | invariantError( | ||
| 739 | "CheckpointService.captureCurrentTurn", | ||
| 740 | `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`, | ||
| 741 | ), | ||
| 742 | ); | ||
| 743 | } | ||
| 744 | const exists = yield* store.hasCheckpointRef({ | ||
| 745 | cwd: resolution.checkpointCwd.value, | ||
| 746 | checkpointRef, | ||
| 747 | }); | ||
| 748 | if (!exists) { | ||
| 749 | return yield* Effect.fail( | ||
| 750 | invariantError( | ||
| 751 | "CheckpointService.captureCurrentTurn", | ||
| 752 | `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`, | ||
| 753 | ), | ||
| 754 | ); | ||
| 755 | } | ||
| 756 | return; | ||
| 757 | } | ||
| 758 | |||
| 759 | yield* captureAndPersistCheckpoint({ | ||
| 760 | providerSessionId: input.providerSessionId, | ||
| 761 | cwd: resolution.checkpointCwd.value, | ||
| 762 | threadId: snapshot.threadId, | ||
| 763 | checkpoint, | ||
| 764 | }); | ||
| 765 | }), | ||
| 766 | ), | ||
| 767 | ), | ||
| 768 | ); | ||
| 769 | |||
| 770 | const listCheckpoints: CheckpointServiceShape["listCheckpoints"] = (rawInput) => | ||
| 771 | decodeListInput(rawInput).pipe( | ||
| 772 | Effect.flatMap((input) => | ||
| 773 | withSessionLock( | ||
| 774 | input.sessionId, | ||
| 775 | Effect.gen(function* () { | ||
| 776 | const adapter = yield* resolveAdapterForSession(input.sessionId); | ||
| 777 | yield* ensureSessionIsActive(input.sessionId, adapter); | ||
| 778 | const snapshot = yield* readThreadSnapshot( | ||
| 779 | input.sessionId, | ||
| 780 | adapter, | ||
| 781 | "CheckpointService.listCheckpoints:readThread", | ||
| 782 | ); | ||
| 783 | |||
| 784 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); | ||
| 785 | if (Option.isNone(resolution.checkpointCwd)) { | ||
| 786 | return yield* Effect.fail( | ||
| 787 | new CheckpointRepositoryError({ | ||
| 788 | cwd: resolution.candidateCwd, | ||
| 789 | detail: "Filesystem checkpoints are unavailable for this session.", | ||
| 790 | }), | ||
| 791 | ); | ||
| 792 | } | ||
| 793 | |||
| 794 | const checkpoints = yield* checkpointRepository.listCheckpoints({ | ||
| 795 | providerSessionId: input.sessionId, | ||
| 796 | }); | ||
| 797 | if (checkpoints.length === 0) { | ||
| 798 | return yield* Effect.fail( | ||
| 799 | invariantError( | ||
| 800 | "CheckpointService.listCheckpoints", | ||
| 801 | `Missing checkpoint metadata for active session ${input.sessionId}.`, | ||
| 802 | ), | ||
| 803 | ); | ||
| 804 | } | ||
| 805 | |||
| 806 | return { | ||
| 807 | threadId: snapshot.threadId, | ||
| 808 | checkpoints: Array.from(checkpoints), | ||
| 809 | }; | ||
| 810 | }), | ||
| 811 | ), | ||
| 812 | ), | ||
| 813 | ); | ||
| 814 | |||
| 815 | const getCheckpointDiff: CheckpointServiceShape["getCheckpointDiff"] = (rawInput) => | ||
| 816 | decodeDiffInput(rawInput).pipe( | ||
| 817 | Effect.flatMap((input) => | ||
| 818 | withSessionLock( | ||
| 819 | input.sessionId, | ||
| 820 | Effect.gen(function* () { | ||
| 821 | const adapter = yield* resolveAdapterForSession(input.sessionId); | ||
| 822 | yield* ensureSessionIsActive(input.sessionId, adapter); | ||
| 823 | const snapshot = yield* readThreadSnapshot( | ||
| 824 | input.sessionId, | ||
| 825 | adapter, | ||
| 826 | "CheckpointService.getCheckpointDiff:readThread", | ||
| 827 | ); | ||
| 828 | |||
| 829 | if (input.toTurnCount > snapshot.turns.length) { | ||
| 830 | return yield* Effect.fail( | ||
| 831 | validationError( | ||
| 832 | "CheckpointService.getCheckpointDiff", | ||
| 833 | `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, | ||
| 834 | ), | ||
| 835 | ); | ||
| 836 | } | ||
| 837 | |||
| 838 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); | ||
| 839 | if (Option.isNone(resolution.checkpointCwd)) { | ||
| 840 | return yield* Effect.fail( | ||
| 841 | new CheckpointRepositoryError({ | ||
| 842 | cwd: resolution.candidateCwd, | ||
| 843 | detail: "Filesystem checkpoints are unavailable for this session.", | ||
| 844 | }), | ||
| 845 | ); | ||
| 846 | } | ||
| 847 | |||
| 848 | const fromCheckpointRef = yield* getCheckpointRefOrFail( | ||
| 849 | input.sessionId, | ||
| 850 | snapshot.threadId, | ||
| 851 | input.fromTurnCount, | ||
| 852 | "CheckpointService.getCheckpointDiff:fromCheckpoint", | ||
| 853 | ); | ||
| 854 | const toCheckpointRef = yield* getCheckpointRefOrFail( | ||
| 855 | input.sessionId, | ||
| 856 | snapshot.threadId, | ||
| 857 | input.toTurnCount, | ||
| 858 | "CheckpointService.getCheckpointDiff:toCheckpoint", | ||
| 859 | ); | ||
| 860 | |||
| 861 | const diff = yield* store.diffCheckpoints({ | ||
| 862 | cwd: resolution.checkpointCwd.value, | ||
| 863 | fromCheckpointRef, | ||
| 864 | toCheckpointRef, | ||
| 865 | fallbackFromToHead: input.fromTurnCount === 0, | ||
| 866 | }); | ||
| 867 | |||
| 868 | return { | ||
| 869 | threadId: snapshot.threadId, | ||
| 870 | fromTurnCount: input.fromTurnCount, | ||
| 871 | toTurnCount: input.toTurnCount, | ||
| 872 | diff, | ||
| 873 | }; | ||
| 874 | }), | ||
| 875 | ), | ||
| 876 | ), | ||
| 877 | ); | ||
| 878 | |||
| 879 | const revertToCheckpoint: CheckpointServiceShape["revertToCheckpoint"] = (rawInput) => | ||
| 880 | decodeRevertInput(rawInput).pipe( | ||
| 881 | Effect.flatMap((input) => | ||
| 882 | withSessionLock( | ||
| 883 | input.sessionId, | ||
| 884 | Effect.gen(function* () { | ||
| 885 | const adapter = yield* resolveAdapterForSession(input.sessionId); | ||
| 886 | yield* ensureSessionIsActive(input.sessionId, adapter); | ||
| 887 | const beforeSnapshot = yield* readThreadSnapshot( | ||
| 888 | input.sessionId, | ||
| 889 | adapter, | ||
| 890 | "CheckpointService.revertToCheckpoint:readThread", | ||
| 891 | ); | ||
| 892 | const currentTurnCount = beforeSnapshot.turns.length; | ||
| 893 | |||
| 894 | if (input.turnCount > currentTurnCount) { | ||
| 895 | return yield* Effect.fail( | ||
| 896 | validationError( | ||
| 897 | "CheckpointService.revertToCheckpoint", | ||
| 898 | `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, | ||
| 899 | ), | ||
| 900 | ); | ||
| 901 | } | ||
| 902 | |||
| 903 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); | ||
| 904 | if (Option.isNone(resolution.checkpointCwd)) { | ||
| 905 | return yield* Effect.fail( | ||
| 906 | new CheckpointRepositoryError({ | ||
| 907 | cwd: resolution.candidateCwd, | ||
| 908 | detail: "Filesystem checkpoints are unavailable for this session.", | ||
| 909 | }), | ||
| 910 | ); | ||
| 911 | } | ||
| 912 | |||
| 913 | const checkpointCwd = resolution.checkpointCwd.value; | ||
| 914 | const checkpointRef = yield* getCheckpointRefOrFail( | ||
| 915 | input.sessionId, | ||
| 916 | beforeSnapshot.threadId, | ||
| 917 | input.turnCount, | ||
| 918 | "CheckpointService.revertToCheckpoint:checkpointLookup", | ||
| 919 | ); | ||
| 920 | |||
| 921 | const restored = yield* store.restoreCheckpoint({ | ||
| 922 | cwd: checkpointCwd, | ||
| 923 | checkpointRef, | ||
| 924 | fallbackToHead: input.turnCount === 0, | ||
| 925 | }); | ||
| 926 | if (!restored) { | ||
| 927 | return yield* Effect.fail( | ||
| 928 | new CheckpointUnavailableError({ | ||
| 929 | threadId: beforeSnapshot.threadId, | ||
| 930 | turnCount: input.turnCount, | ||
| 931 | detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | ||
| 932 | }), | ||
| 933 | ); | ||
| 934 | } | ||
| 935 | |||
| 936 | const requestedRollbackTurns = currentTurnCount - input.turnCount; | ||
| 937 | const afterSnapshot = | ||
| 938 | requestedRollbackTurns > 0 | ||
| 939 | ? yield* rollbackThreadSnapshot( | ||
| 940 | input.sessionId, | ||
| 941 | adapter, | ||
| 942 | requestedRollbackTurns, | ||
| 943 | "CheckpointService.revertToCheckpoint:rollbackThread", | ||
| 944 | ) | ||
| 945 | : beforeSnapshot; | ||
| 946 | |||
| 947 | const staleTurnCounts: number[] = []; | ||
| 948 | for (let turn = input.turnCount + 1; turn <= currentTurnCount; turn += 1) { | ||
| 949 | staleTurnCounts.push(turn); | ||
| 950 | } | ||
| 951 | |||
| 952 | const staleCheckpointRefs = yield* Effect.forEach( | ||
| 953 | staleTurnCounts, | ||
| 954 | (turnCount) => | ||
| 955 | checkpointRepository.getCheckpoint({ | ||
| 956 | providerSessionId: input.sessionId, | ||
| 957 | turnCount, | ||
| 958 | }), | ||
| 959 | { discard: false }, | ||
| 960 | ).pipe( | ||
| 961 | Effect.map((entries) => | ||
| 962 | entries | ||
| 963 | .flatMap((entry) => | ||
| 964 | Option.isSome(entry) ? [entry.value.checkpointRef.trim()] : [], | ||
| 965 | ) | ||
| 966 | .filter((ref) => ref.length > 0), | ||
| 967 | ), | ||
| 968 | ); | ||
| 969 | |||
| 970 | yield* store.deleteCheckpointRefs({ | ||
| 971 | cwd: checkpointCwd, | ||
| 972 | checkpointRefs: staleCheckpointRefs, | ||
| 973 | }); | ||
| 974 | yield* checkpointRepository.deleteAfterTurn({ | ||
| 975 | providerSessionId: input.sessionId, | ||
| 976 | maxTurnCount: input.turnCount, | ||
| 977 | }); | ||
| 978 | const checkpoints = yield* checkpointRepository.listCheckpoints({ | ||
| 979 | providerSessionId: input.sessionId, | ||
| 980 | }); | ||
| 981 | const currentCheckpoint = | ||
| 982 | checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | ||
| 983 | checkpoints[checkpoints.length - 1] ?? | ||
| 984 | checkpoints[0]; | ||
| 985 | const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); | ||
| 986 | |||
| 987 | return { | ||
| 988 | threadId: afterSnapshot.threadId, | ||
| 989 | turnCount: currentCheckpoint?.turnCount ?? 0, | ||
| 990 | messageCount: currentCheckpoint?.messageCount ?? 0, | ||
| 991 | rolledBackTurns, | ||
| 992 | checkpoints: Array.from(checkpoints), | ||
| 993 | }; | ||
| 994 | }), | ||
| 995 | ), | ||
| 996 | ), | ||
| 997 | ); | ||
| 998 | |||
| 999 | return { | ||
| 1000 | initializeForSession, | ||
| 1001 | captureCurrentTurn, | ||
| 1002 | listCheckpoints, | ||
| 1003 | getCheckpointDiff, | ||
| 1004 | revertToCheckpoint, | ||
| 1005 | } satisfies CheckpointServiceShape; | ||
| 1006 | }); | ||
| 1007 | |||
| 1008 | export const CheckpointServiceLive = Layer.effect(CheckpointService, makeCheckpointService); | ||
apps/server/src/checkpointing/Layers/CheckpointStore.ts created+418| ... | @@ -0,0 +1,418 @@ | ||
| 1 | /** | ||
| 2 | * CheckpointStoreLive - Filesystem checkpoint store adapter layer. | ||
| 3 | * | ||
| 4 | * Implements hidden Git-ref checkpoint capture/restore directly with | ||
| 5 | * Effect-native child process execution (`effect/unstable/process`). | ||
| 6 | * | ||
| 7 | * This layer owns filesystem/Git interactions only; it does not persist | ||
| 8 | * checkpoint metadata and does not coordinate provider rollback semantics. | ||
| 9 | * | ||
| 10 | * @module CheckpointStoreLive | ||
| 11 | */ | ||
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | ||
| 13 | import { randomUUID } from "node:crypto"; | ||
| 14 | import os from "node:os"; | ||
| 15 | import path from "node:path"; | ||
| 16 | |||
| 17 | import { Effect, Layer } from "effect"; | ||
| 18 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 19 | |||
| 20 | import { | ||
| 21 | CheckpointGitCommandError, | ||
| 22 | CheckpointRepositoryError, | ||
| 23 | CheckpointUnavailableError, | ||
| 24 | type CheckpointStoreError, | ||
| 25 | } from "../Errors.ts"; | ||
| 26 | import { GitCommandError } from "../../git/Errors.ts"; | ||
| 27 | import { runGit, RunGitInput } from "../../git/Process.ts"; | ||
| 28 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | ||
| 29 | |||
| 30 | function causeDetail(cause: unknown, fallback: string): string { | ||
| 31 | if (cause instanceof GitCommandError) { | ||
| 32 | return cause.detail; | ||
| 33 | } | ||
| 34 | if (cause instanceof Error && cause.message.length > 0) { | ||
| 35 | return cause.message; | ||
| 36 | } | ||
| 37 | return fallback; | ||
| 38 | } | ||
| 39 | |||
| 40 | function mapFilesystemFailure( | ||
| 41 | operation: string, | ||
| 42 | cwd: string, | ||
| 43 | cause: unknown, | ||
| 44 | ): CheckpointStoreError { | ||
| 45 | const detail = causeDetail(cause, `${operation} failed.`); | ||
| 46 | |||
| 47 | const unavailable = | ||
| 48 | /^Filesystem checkpoint is unavailable for turn (\d+) in thread (.+)\.?$/i.exec(detail); | ||
| 49 | if (unavailable) { | ||
| 50 | const [, rawTurnCount, threadId] = unavailable; | ||
| 51 | const turnCount = Number.parseInt(rawTurnCount ?? "", 10); | ||
| 52 | if (threadId && Number.isInteger(turnCount) && turnCount >= 0) { | ||
| 53 | return new CheckpointUnavailableError({ | ||
| 54 | threadId, | ||
| 55 | turnCount, | ||
| 56 | detail, | ||
| 57 | cause, | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | const lowered = detail.toLowerCase(); | ||
| 63 | if (lowered.includes("not a git repository")) { | ||
| 64 | return new CheckpointRepositoryError({ | ||
| 65 | cwd, | ||
| 66 | detail, | ||
| 67 | cause, | ||
| 68 | }); | ||
| 69 | } | ||
| 70 | |||
| 71 | return new CheckpointGitCommandError({ | ||
| 72 | operation, | ||
| 73 | command: cause instanceof GitCommandError ? cause.command : "git", | ||
| 74 | cwd, | ||
| 75 | detail, | ||
| 76 | cause, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 80 | function resolveHeadCommit( | ||
| 81 | cwd: string, | ||
| 82 | ): Effect.Effect<string | null, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 83 | return runGit({ | ||
| 84 | operation: "CheckpointStore.resolveHeadCommit", | ||
| 85 | cwd, | ||
| 86 | args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], | ||
| 87 | allowNonZeroExit: true, | ||
| 88 | }).pipe( | ||
| 89 | Effect.map((result) => { | ||
| 90 | if (result.code !== 0) { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | const commit = result.stdout.trim(); | ||
| 94 | return commit.length > 0 ? commit : null; | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | function hasHeadCommit( | ||
| 100 | cwd: string, | ||
| 101 | ): Effect.Effect<boolean, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 102 | return runGit({ | ||
| 103 | operation: "CheckpointStore.hasHeadCommit", | ||
| 104 | cwd, | ||
| 105 | args: ["rev-parse", "--verify", "HEAD"], | ||
| 106 | allowNonZeroExit: true, | ||
| 107 | }).pipe(Effect.map((result) => result.code === 0)); | ||
| 108 | } | ||
| 109 | |||
| 110 | function resolveCheckpointCommit( | ||
| 111 | cwd: string, | ||
| 112 | checkpointRef: string, | ||
| 113 | ): Effect.Effect<string | null, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 114 | return runGit({ | ||
| 115 | operation: "CheckpointStore.resolveCheckpointCommit", | ||
| 116 | cwd, | ||
| 117 | args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], | ||
| 118 | allowNonZeroExit: true, | ||
| 119 | }).pipe( | ||
| 120 | Effect.map((result) => { | ||
| 121 | if (result.code !== 0) { | ||
| 122 | return null; | ||
| 123 | } | ||
| 124 | const commit = result.stdout.trim(); | ||
| 125 | return commit.length > 0 ? commit : null; | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | |||
| 130 | function removeDirectory(pathname: string): Effect.Effect<void, never> { | ||
| 131 | return Effect.tryPromise({ | ||
| 132 | try: () => rm(pathname, { recursive: true, force: true }), | ||
| 133 | catch: (cause) => cause, | ||
| 134 | }).pipe(Effect.orDie); | ||
| 135 | } | ||
| 136 | |||
| 137 | const makeCheckpointStore = Effect.gen(function* () { | ||
| 138 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 139 | const runGitWithSpawner = (input: RunGitInput) => | ||
| 140 | runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | ||
| 141 | const resolveHeadCommitWithSpawner = (cwd: string) => | ||
| 142 | resolveHeadCommit(cwd).pipe( | ||
| 143 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 144 | ); | ||
| 145 | const hasHeadCommitWithSpawner = (cwd: string) => | ||
| 146 | hasHeadCommit(cwd).pipe( | ||
| 147 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 148 | ); | ||
| 149 | const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) => | ||
| 150 | resolveCheckpointCommit(cwd, checkpointRef).pipe( | ||
| 151 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 152 | ); | ||
| 153 | |||
| 154 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | ||
| 155 | Effect.gen(function* () { | ||
| 156 | const normalizedCwd = cwd.trim(); | ||
| 157 | |||
| 158 | return yield* runGitWithSpawner({ | ||
| 159 | operation: "CheckpointStore.isGitRepository", | ||
| 160 | cwd: normalizedCwd, | ||
| 161 | args: ["rev-parse", "--is-inside-work-tree"], | ||
| 162 | allowNonZeroExit: true, | ||
| 163 | }).pipe( | ||
| 164 | Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), | ||
| 165 | Effect.catch(() => Effect.succeed(false)), | ||
| 166 | ); | ||
| 167 | }); | ||
| 168 | |||
| 169 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => | ||
| 170 | Effect.gen(function* () { | ||
| 171 | const operation = "CheckpointStore.captureCheckpoint"; | ||
| 172 | const normalizedCwd = input.cwd.trim(); | ||
| 173 | const checkpointRef = input.checkpointRef.trim(); | ||
| 174 | |||
| 175 | yield* Effect.acquireUseRelease( | ||
| 176 | Effect.tryPromise({ | ||
| 177 | try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")), | ||
| 178 | catch: (cause) => cause, | ||
| 179 | }), | ||
| 180 | (tempDir) => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | ||
| 183 | const commitEnv: NodeJS.ProcessEnv = { | ||
| 184 | ...process.env, | ||
| 185 | GIT_INDEX_FILE: tempIndexPath, | ||
| 186 | GIT_AUTHOR_NAME: "T3 Code", | ||
| 187 | GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", | ||
| 188 | GIT_COMMITTER_NAME: "T3 Code", | ||
| 189 | GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", | ||
| 190 | }; | ||
| 191 | |||
| 192 | const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd); | ||
| 193 | if (headExists) { | ||
| 194 | yield* runGitWithSpawner({ | ||
| 195 | operation, | ||
| 196 | cwd: normalizedCwd, | ||
| 197 | args: ["read-tree", "HEAD"], | ||
| 198 | env: commitEnv, | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | |||
| 202 | yield* runGitWithSpawner({ | ||
| 203 | operation, | ||
| 204 | cwd: normalizedCwd, | ||
| 205 | args: ["add", "-A", "--", "."], | ||
| 206 | env: commitEnv, | ||
| 207 | }); | ||
| 208 | |||
| 209 | const writeTreeResult = yield* runGitWithSpawner({ | ||
| 210 | operation, | ||
| 211 | cwd: normalizedCwd, | ||
| 212 | args: ["write-tree"], | ||
| 213 | env: commitEnv, | ||
| 214 | }); | ||
| 215 | const treeOid = writeTreeResult.stdout.trim(); | ||
| 216 | if (treeOid.length === 0) { | ||
| 217 | return yield* Effect.fail( | ||
| 218 | new CheckpointGitCommandError({ | ||
| 219 | operation, | ||
| 220 | command: "git write-tree", | ||
| 221 | cwd: normalizedCwd, | ||
| 222 | detail: "git write-tree returned an empty tree oid.", | ||
| 223 | }), | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | |||
| 227 | const message = `t3 checkpoint ref=${checkpointRef}`; | ||
| 228 | const commitTreeResult = yield* runGitWithSpawner({ | ||
| 229 | operation, | ||
| 230 | cwd: normalizedCwd, | ||
| 231 | args: ["commit-tree", treeOid, "-m", message], | ||
| 232 | env: commitEnv, | ||
| 233 | }); | ||
| 234 | const commitOid = commitTreeResult.stdout.trim(); | ||
| 235 | if (commitOid.length === 0) { | ||
| 236 | return yield* Effect.fail( | ||
| 237 | new CheckpointGitCommandError({ | ||
| 238 | operation, | ||
| 239 | command: "git commit-tree", | ||
| 240 | cwd: normalizedCwd, | ||
| 241 | detail: "git commit-tree returned an empty commit oid.", | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | } | ||
| 245 | |||
| 246 | yield* runGitWithSpawner({ | ||
| 247 | operation, | ||
| 248 | cwd: normalizedCwd, | ||
| 249 | args: ["update-ref", checkpointRef, commitOid], | ||
| 250 | }); | ||
| 251 | }), | ||
| 252 | removeDirectory, | ||
| 253 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 254 | }); | ||
| 255 | |||
| 256 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => | ||
| 257 | Effect.gen(function* () { | ||
| 258 | const operation = "CheckpointStore.hasCheckpointRef"; | ||
| 259 | const normalizedCwd = input.cwd.trim(); | ||
| 260 | const checkpointRef = input.checkpointRef.trim(); | ||
| 261 | |||
| 262 | return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | ||
| 263 | Effect.map((commit) => commit !== null), | ||
| 264 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 265 | ); | ||
| 266 | }); | ||
| 267 | |||
| 268 | const ensureRootCheckpoint: CheckpointStoreShape["ensureRootCheckpoint"] = (input) => | ||
| 269 | Effect.gen(function* () { | ||
| 270 | const operation = "CheckpointStore.ensureRootCheckpoint"; | ||
| 271 | const normalizedCwd = input.cwd.trim(); | ||
| 272 | const checkpointRef = input.checkpointRef.trim(); | ||
| 273 | |||
| 274 | const existing = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | ||
| 275 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 276 | ); | ||
| 277 | if (existing) { | ||
| 278 | return true; | ||
| 279 | } | ||
| 280 | |||
| 281 | const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | ||
| 282 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 283 | ); | ||
| 284 | if (!headCommit) { | ||
| 285 | return false; | ||
| 286 | } | ||
| 287 | |||
| 288 | yield* runGitWithSpawner({ | ||
| 289 | operation, | ||
| 290 | cwd: normalizedCwd, | ||
| 291 | args: ["update-ref", checkpointRef, headCommit], | ||
| 292 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 293 | return true; | ||
| 294 | }); | ||
| 295 | |||
| 296 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => | ||
| 297 | Effect.gen(function* () { | ||
| 298 | const operation = "CheckpointStore.restoreCheckpoint"; | ||
| 299 | const normalizedCwd = input.cwd.trim(); | ||
| 300 | const checkpointRef = input.checkpointRef.trim(); | ||
| 301 | |||
| 302 | let commitOid = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | ||
| 303 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 304 | ); | ||
| 305 | |||
| 306 | if (!commitOid && input.fallbackToHead === true) { | ||
| 307 | commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | ||
| 308 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 309 | ); | ||
| 310 | } | ||
| 311 | |||
| 312 | if (!commitOid) { | ||
| 313 | return false; | ||
| 314 | } | ||
| 315 | |||
| 316 | yield* runGitWithSpawner({ | ||
| 317 | operation, | ||
| 318 | cwd: normalizedCwd, | ||
| 319 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], | ||
| 320 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 321 | yield* runGitWithSpawner({ | ||
| 322 | operation, | ||
| 323 | cwd: normalizedCwd, | ||
| 324 | args: ["clean", "-fd", "--", "."], | ||
| 325 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 326 | |||
| 327 | const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe( | ||
| 328 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 329 | ); | ||
| 330 | if (headExists) { | ||
| 331 | yield* runGitWithSpawner({ | ||
| 332 | operation, | ||
| 333 | cwd: normalizedCwd, | ||
| 334 | args: ["reset", "--quiet", "--", "."], | ||
| 335 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 336 | } | ||
| 337 | |||
| 338 | return true; | ||
| 339 | }); | ||
| 340 | |||
| 341 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | const operation = "CheckpointStore.diffCheckpoints"; | ||
| 344 | const normalizedCwd = input.cwd.trim(); | ||
| 345 | const fromCheckpointRef = input.fromCheckpointRef.trim(); | ||
| 346 | const toCheckpointRef = input.toCheckpointRef.trim(); | ||
| 347 | |||
| 348 | let fromCommitOid = yield* resolveCheckpointCommitWithSpawner( | ||
| 349 | normalizedCwd, | ||
| 350 | fromCheckpointRef, | ||
| 351 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 352 | const toCommitOid = yield* resolveCheckpointCommitWithSpawner( | ||
| 353 | normalizedCwd, | ||
| 354 | toCheckpointRef, | ||
| 355 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 356 | |||
| 357 | if (!fromCommitOid && input.fallbackFromToHead === true) { | ||
| 358 | const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | ||
| 359 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 360 | ); | ||
| 361 | if (headCommit) { | ||
| 362 | fromCommitOid = headCommit; | ||
| 363 | } | ||
| 364 | } | ||
| 365 | |||
| 366 | if (!fromCommitOid || !toCommitOid) { | ||
| 367 | return yield* Effect.fail( | ||
| 368 | new CheckpointGitCommandError({ | ||
| 369 | operation, | ||
| 370 | command: "git diff", | ||
| 371 | cwd: normalizedCwd, | ||
| 372 | detail: "Checkpoint ref is unavailable for diff operation.", | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | } | ||
| 376 | |||
| 377 | const result = yield* runGitWithSpawner({ | ||
| 378 | operation, | ||
| 379 | cwd: normalizedCwd, | ||
| 380 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], | ||
| 381 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 382 | |||
| 383 | return result.stdout; | ||
| 384 | }); | ||
| 385 | |||
| 386 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => | ||
| 387 | Effect.gen(function* () { | ||
| 388 | const operation = "CheckpointStore.deleteCheckpointRefs"; | ||
| 389 | const normalizedCwd = input.cwd.trim(); | ||
| 390 | const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim()); | ||
| 391 | |||
| 392 | yield* Effect.forEach( | ||
| 393 | checkpointRefs, | ||
| 394 | (checkpointRef) => | ||
| 395 | runGitWithSpawner({ | ||
| 396 | operation, | ||
| 397 | cwd: normalizedCwd, | ||
| 398 | args: ["update-ref", "-d", checkpointRef], | ||
| 399 | allowNonZeroExit: true, | ||
| 400 | }).pipe( | ||
| 401 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 402 | ), | ||
| 403 | { discard: true }, | ||
| 404 | ); | ||
| 405 | }); | ||
| 406 | |||
| 407 | return { | ||
| 408 | isGitRepository, | ||
| 409 | captureCheckpoint, | ||
| 410 | hasCheckpointRef, | ||
| 411 | ensureRootCheckpoint, | ||
| 412 | restoreCheckpoint, | ||
| 413 | diffCheckpoints, | ||
| 414 | deleteCheckpointRefs, | ||
| 415 | } satisfies CheckpointStoreShape; | ||
| 416 | }); | ||
| 417 | |||
| 418 | export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); | ||
apps/server/src/checkpointing/Services/CheckpointCatalog.ts deleted-83| ... | @@ -1,83 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * CheckpointCatalog - Metadata repository for checkpoints. | ||
| 3 | * | ||
| 4 | * Stores user-facing checkpoint metadata used by list/revert APIs. It does not | ||
| 5 | * touch the filesystem or hidden Git refs; `CheckpointStore` owns that state. | ||
| 6 | * It also does not drive provider conversation rollback. | ||
| 7 | * | ||
| 8 | * @module CheckpointCatalog | ||
| 9 | */ | ||
| 10 | import type { ProviderCheckpoint } from "@t3tools/contracts"; | ||
| 11 | import { Option, ServiceMap } from "effect"; | ||
| 12 | import type { Effect } from "effect"; | ||
| 13 | |||
| 14 | import type { CheckpointCatalogError } from "../Errors.ts"; | ||
| 15 | |||
| 16 | export interface UpsertCheckpointInput { | ||
| 17 | readonly providerSessionId: string; | ||
| 18 | readonly threadId: string; | ||
| 19 | readonly turnCount: number; | ||
| 20 | readonly messageCount: number; | ||
| 21 | readonly label: string; | ||
| 22 | readonly preview?: string; | ||
| 23 | readonly createdAt: string; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface ListSessionCheckpointsInput { | ||
| 27 | readonly providerSessionId: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface GetCheckpointInput { | ||
| 31 | readonly providerSessionId: string; | ||
| 32 | readonly turnCount: number; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface DeleteAfterTurnInput { | ||
| 36 | readonly providerSessionId: string; | ||
| 37 | readonly maxTurnCount: number; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface DeleteAllForSessionInput { | ||
| 41 | readonly providerSessionId: string; | ||
| 42 | } | ||
| 43 | |||
| 44 | export interface CheckpointCatalogShape { | ||
| 45 | /** | ||
| 46 | * Insert or update one checkpoint metadata row. | ||
| 47 | */ | ||
| 48 | readonly upsertCheckpoint: (input: UpsertCheckpointInput) => Effect.Effect<void, CheckpointCatalogError>; | ||
| 49 | |||
| 50 | /** | ||
| 51 | * List checkpoints for a provider session. | ||
| 52 | */ | ||
| 53 | readonly listCheckpoints: ( | ||
| 54 | input: ListSessionCheckpointsInput, | ||
| 55 | ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointCatalogError>; | ||
| 56 | |||
| 57 | /** | ||
| 58 | * Read one checkpoint by turn count. | ||
| 59 | */ | ||
| 60 | readonly getCheckpoint: ( | ||
| 61 | input: GetCheckpointInput, | ||
| 62 | ) => Effect.Effect<Option.Option<ProviderCheckpoint>, CheckpointCatalogError>; | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Delete checkpoint metadata newer than the provided turn count. | ||
| 66 | */ | ||
| 67 | readonly deleteAfterTurn: (input: DeleteAfterTurnInput) => Effect.Effect<void, CheckpointCatalogError>; | ||
| 68 | |||
| 69 | /** | ||
| 70 | * Delete all checkpoint metadata for a provider session. | ||
| 71 | */ | ||
| 72 | readonly deleteAllForSession: ( | ||
| 73 | input: DeleteAllForSessionInput, | ||
| 74 | ) => Effect.Effect<void, CheckpointCatalogError>; | ||
| 75 | } | ||
| 76 | |||
| 77 | /** | ||
| 78 | * CheckpointCatalog - Service tag for checkpoint metadata persistence. | ||
| 79 | */ | ||
| 80 | export class CheckpointCatalog extends ServiceMap.Service< | ||
| 81 | CheckpointCatalog, | ||
| 82 | CheckpointCatalogShape | ||
| 83 | >()("checkpointing/CheckpointCatalog") {} | ||
apps/server/src/checkpointing/Services/CheckpointService.ts+4-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | /** | 1 | /** |
| 2 | * CheckpointService - Application orchestrator for checkpoint workflows. | 2 | * CheckpointService - Application orchestrator for checkpoint workflows. |
| 3 | * | 3 | * |
| 4 | * Coordinates `CheckpointStore` (Git refs), `CheckpointCatalog` (metadata), and | 4 | * Coordinates `CheckpointStore` (Git refs), `CheckpointRepository` (metadata), and |
| 5 | * provider timeline operations resolved through `ProviderSessionDirectory` and | 5 | * provider timeline operations resolved through `ProviderSessionDirectory` and |
| 6 | * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters). | 6 | * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters). |
| 7 | * | 7 | * |
| ... | @@ -45,7 +45,9 @@ export interface CheckpointServiceShape { | ... | @@ -45,7 +45,9 @@ export interface CheckpointServiceShape { |
| 45 | /** | 45 | /** |
| 46 | * Capture the current thread turn into checkpoint refs/metadata. | 46 | * Capture the current thread turn into checkpoint refs/metadata. |
| 47 | */ | 47 | */ |
| 48 | readonly captureCurrentTurn: (input: CaptureCurrentTurnInput) => Effect.Effect<void, CheckpointServiceError>; | 48 | readonly captureCurrentTurn: ( |
| 49 | input: CaptureCurrentTurnInput, | ||
| 50 | ) => Effect.Effect<void, CheckpointServiceError>; | ||
| 49 | 51 | ||
| 50 | /** | 52 | /** |
| 51 | * List checkpoints for a provider session. | 53 | * List checkpoints for a provider session. |
apps/server/src/checkpointing/Services/CheckpointStore.ts+28-23| ... | @@ -17,32 +17,30 @@ import type { CheckpointStoreError } from "../Errors.ts"; | ... | @@ -17,32 +17,30 @@ import type { CheckpointStoreError } from "../Errors.ts"; |
| 17 | 17 | ||
| 18 | export interface CaptureCheckpointInput { | 18 | export interface CaptureCheckpointInput { |
| 19 | readonly cwd: string; | 19 | readonly cwd: string; |
| 20 | readonly threadId: string; | 20 | readonly checkpointRef: string; |
| 21 | readonly turnCount: number; | ||
| 22 | } | 21 | } |
| 23 | 22 | ||
| 24 | export interface EnsureRootCheckpointInput { | 23 | export interface EnsureRootCheckpointInput { |
| 25 | readonly cwd: string; | 24 | readonly cwd: string; |
| 26 | readonly threadId: string; | 25 | readonly checkpointRef: string; |
| 27 | } | 26 | } |
| 28 | 27 | ||
| 29 | export interface RestoreCheckpointInput { | 28 | export interface RestoreCheckpointInput { |
| 30 | readonly cwd: string; | 29 | readonly cwd: string; |
| 31 | readonly threadId: string; | 30 | readonly checkpointRef: string; |
| 32 | readonly turnCount: number; | 31 | readonly fallbackToHead?: boolean; |
| 33 | } | 32 | } |
| 34 | 33 | ||
| 35 | export interface DiffCheckpointsInput { | 34 | export interface DiffCheckpointsInput { |
| 36 | readonly cwd: string; | 35 | readonly cwd: string; |
| 37 | readonly threadId: string; | 36 | readonly fromCheckpointRef: string; |
| 38 | readonly fromTurnCount: number; | 37 | readonly toCheckpointRef: string; |
| 39 | readonly toTurnCount: number; | 38 | readonly fallbackFromToHead?: boolean; |
| 40 | } | 39 | } |
| 41 | 40 | ||
| 42 | export interface PruneAfterTurnInput { | 41 | export interface DeleteCheckpointRefsInput { |
| 43 | readonly cwd: string; | 42 | readonly cwd: string; |
| 44 | readonly threadId: string; | 43 | readonly checkpointRefs: ReadonlyArray<string>; |
| 45 | readonly maxTurnCount: number; | ||
| 46 | } | 44 | } |
| 47 | 45 | ||
| 48 | export interface CheckpointStoreShape { | 46 | export interface CheckpointStoreShape { |
| ... | @@ -52,17 +50,21 @@ export interface CheckpointStoreShape { | ... | @@ -52,17 +50,21 @@ export interface CheckpointStoreShape { |
| 52 | readonly isGitRepository: (cwd: string) => Effect.Effect<boolean, CheckpointStoreError>; | 50 | readonly isGitRepository: (cwd: string) => Effect.Effect<boolean, CheckpointStoreError>; |
| 53 | 51 | ||
| 54 | /** | 52 | /** |
| 55 | * Capture a checkpoint for a thread at a turn count. | 53 | * Capture a checkpoint commit and store it at the provided checkpoint ref. |
| 56 | */ | 54 | */ |
| 57 | readonly captureCheckpoint: (input: CaptureCheckpointInput) => Effect.Effect<void, CheckpointStoreError>; | 55 | readonly captureCheckpoint: ( |
| 56 | input: CaptureCheckpointInput, | ||
| 57 | ) => Effect.Effect<void, CheckpointStoreError>; | ||
| 58 | 58 | ||
| 59 | /** | 59 | /** |
| 60 | * Check whether a checkpoint ref exists. | 60 | * Check whether a checkpoint ref exists. |
| 61 | */ | 61 | */ |
| 62 | readonly hasCheckpoint: (input: RestoreCheckpointInput) => Effect.Effect<boolean, CheckpointStoreError>; | 62 | readonly hasCheckpointRef: ( |
| 63 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, | ||
| 64 | ) => Effect.Effect<boolean, CheckpointStoreError>; | ||
| 63 | 65 | ||
| 64 | /** | 66 | /** |
| 65 | * Ensure a turn-0 checkpoint exists for a thread. | 67 | * Ensure a root checkpoint ref exists. |
| 66 | */ | 68 | */ |
| 67 | readonly ensureRootCheckpoint: ( | 69 | readonly ensureRootCheckpoint: ( |
| 68 | input: EnsureRootCheckpointInput, | 70 | input: EnsureRootCheckpointInput, |
| ... | @@ -76,20 +78,23 @@ export interface CheckpointStoreShape { | ... | @@ -76,20 +78,23 @@ export interface CheckpointStoreShape { |
| 76 | ) => Effect.Effect<boolean, CheckpointStoreError>; | 78 | ) => Effect.Effect<boolean, CheckpointStoreError>; |
| 77 | 79 | ||
| 78 | /** | 80 | /** |
| 79 | * Compute patch diff between two checkpoint turn counts. | 81 | * Compute patch diff between two checkpoint refs. |
| 80 | */ | 82 | */ |
| 81 | readonly diffCheckpoints: (input: DiffCheckpointsInput) => Effect.Effect<string, CheckpointStoreError>; | 83 | readonly diffCheckpoints: ( |
| 84 | input: DiffCheckpointsInput, | ||
| 85 | ) => Effect.Effect<string, CheckpointStoreError>; | ||
| 82 | 86 | ||
| 83 | /** | 87 | /** |
| 84 | * Delete checkpoints newer than the provided turn count. | 88 | * Delete the provided checkpoint refs. |
| 85 | */ | 89 | */ |
| 86 | readonly pruneAfterTurn: (input: PruneAfterTurnInput) => Effect.Effect<void, CheckpointStoreError>; | 90 | readonly deleteCheckpointRefs: ( |
| 91 | input: DeleteCheckpointRefsInput, | ||
| 92 | ) => Effect.Effect<void, CheckpointStoreError>; | ||
| 87 | } | 93 | } |
| 88 | 94 | ||
| 89 | /** | 95 | /** |
| 90 | * CheckpointStore - Service tag for checkpoint persistence and restore operations. | 96 | * CheckpointStore - Service tag for checkpoint persistence and restore operations. |
| 91 | */ | 97 | */ |
| 92 | export class CheckpointStore extends ServiceMap.Service< | 98 | export class CheckpointStore extends ServiceMap.Service<CheckpointStore, CheckpointStoreShape>()( |
| 93 | CheckpointStore, | 99 | "checkpointing/CheckpointStore", |
| 94 | CheckpointStoreShape | 100 | ) {} |
| 95 | >()("checkpointing/CheckpointStore") {} |
apps/server/src/git/Errors.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * GitCommandError - Git command execution failed. | ||
| 5 | */ | ||
| 6 | export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", { | ||
| 7 | operation: Schema.String, | ||
| 8 | command: Schema.String, | ||
| 9 | cwd: Schema.String, | ||
| 10 | detail: Schema.String, | ||
| 11 | cause: Schema.optional(Schema.Defect), | ||
| 12 | }) { | ||
| 13 | override get message(): string { | ||
| 14 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | ||
| 15 | } | ||
| 16 | } | ||
apps/server/src/git/process.test.ts created+51| ... | @@ -0,0 +1,51 @@ | ||
| 1 | import { NodeServices } from "@effect/platform-node"; | ||
| 2 | import { it, assert } from "@effect/vitest"; | ||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import { GitCommandError } from "./Errors.ts"; | ||
| 6 | import { runGit } from "./Process.ts"; | ||
| 7 | |||
| 8 | it.effect("runGit executes successful git commands", () => | ||
| 9 | Effect.gen(function* () { | ||
| 10 | const result = yield* runGit({ | ||
| 11 | operation: "GitProcess.test.version", | ||
| 12 | cwd: process.cwd(), | ||
| 13 | args: ["--version"], | ||
| 14 | }); | ||
| 15 | |||
| 16 | assert.equal(result.code, 0); | ||
| 17 | assert.ok(result.stdout.toLowerCase().includes("git version")); | ||
| 18 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 19 | ); | ||
| 20 | |||
| 21 | it.effect("runGit can return non-zero exit codes when allowed", () => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const result = yield* runGit({ | ||
| 24 | operation: "GitProcess.test.allowNonZero", | ||
| 25 | cwd: process.cwd(), | ||
| 26 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | ||
| 27 | allowNonZeroExit: true, | ||
| 28 | }); | ||
| 29 | |||
| 30 | assert.notEqual(result.code, 0); | ||
| 31 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 32 | ); | ||
| 33 | |||
| 34 | it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const result = yield* Effect.result( | ||
| 37 | runGit({ | ||
| 38 | operation: "GitProcess.test.failOnNonZero", | ||
| 39 | cwd: process.cwd(), | ||
| 40 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | ||
| 41 | }), | ||
| 42 | ); | ||
| 43 | |||
| 44 | assert.equal(result._tag, "Failure"); | ||
| 45 | if (result._tag === "Failure") { | ||
| 46 | assert.ok(result.failure instanceof GitCommandError); | ||
| 47 | assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); | ||
| 48 | assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); | ||
| 49 | } | ||
| 50 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 51 | ); | ||
apps/server/src/git/process.ts created+174| ... | @@ -0,0 +1,174 @@ | ||
| 1 | /** | ||
| 2 | * Git process helpers - Effect-native git execution with typed errors. | ||
| 3 | * | ||
| 4 | * Centralizes child-process git invocation for server modules. This module | ||
| 5 | * only executes git commands and reports structured failures. | ||
| 6 | * | ||
| 7 | * @module GitProcess | ||
| 8 | */ | ||
| 9 | import { Effect, Option, Stream } from "effect"; | ||
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 11 | import { GitCommandError } from "./Errors.ts"; | ||
| 12 | |||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 14 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | ||
| 15 | |||
| 16 | export interface RunGitInput { | ||
| 17 | readonly operation: string; | ||
| 18 | readonly cwd: string; | ||
| 19 | readonly args: ReadonlyArray<string>; | ||
| 20 | readonly env?: NodeJS.ProcessEnv; | ||
| 21 | readonly allowNonZeroExit?: boolean; | ||
| 22 | readonly timeoutMs?: number; | ||
| 23 | readonly maxOutputBytes?: number; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface GitRunResult { | ||
| 27 | readonly code: number; | ||
| 28 | readonly stdout: string; | ||
| 29 | readonly stderr: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | function quoteGitCommand(args: ReadonlyArray<string>): string { | ||
| 33 | return `git ${args.join(" ")}`; | ||
| 34 | } | ||
| 35 | |||
| 36 | function causeMessage(cause: unknown, fallback: string): string { | ||
| 37 | if (cause instanceof Error && cause.message.length > 0) { | ||
| 38 | return cause.message; | ||
| 39 | } | ||
| 40 | return fallback; | ||
| 41 | } | ||
| 42 | |||
| 43 | function gitError( | ||
| 44 | input: Pick<RunGitInput, "operation" | "cwd" | "args">, | ||
| 45 | detail: string, | ||
| 46 | cause?: unknown, | ||
| 47 | ): GitCommandError { | ||
| 48 | return new GitCommandError({ | ||
| 49 | operation: input.operation, | ||
| 50 | command: quoteGitCommand(input.args), | ||
| 51 | cwd: input.cwd, | ||
| 52 | detail, | ||
| 53 | ...(cause !== undefined ? { cause } : {}), | ||
| 54 | }); | ||
| 55 | } | ||
| 56 | |||
| 57 | function collectOutput( | ||
| 58 | input: Pick<RunGitInput, "operation" | "cwd" | "args">, | ||
| 59 | stream: Stream.Stream<Uint8Array, unknown>, | ||
| 60 | maxOutputBytes: number, | ||
| 61 | ): Effect.Effect<string, GitCommandError> { | ||
| 62 | return Effect.gen(function* () { | ||
| 63 | const decoder = new TextDecoder(); | ||
| 64 | let bytes = 0; | ||
| 65 | let text = ""; | ||
| 66 | |||
| 67 | yield* Stream.runForEach(stream, (chunk) => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | bytes += chunk.byteLength; | ||
| 70 | if (bytes > maxOutputBytes) { | ||
| 71 | return yield* Effect.fail( | ||
| 72 | gitError( | ||
| 73 | input, | ||
| 74 | `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | } | ||
| 78 | text += decoder.decode(chunk, { stream: true }); | ||
| 79 | }), | ||
| 80 | ); | ||
| 81 | |||
| 82 | text += decoder.decode(); | ||
| 83 | return text; | ||
| 84 | }).pipe( | ||
| 85 | Effect.mapError((cause) => | ||
| 86 | gitError( | ||
| 87 | input, | ||
| 88 | causeMessage(cause, `${quoteGitCommand(input.args)} output stream failed.`), | ||
| 89 | cause, | ||
| 90 | ), | ||
| 91 | ), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | export function runGit( | ||
| 96 | input: RunGitInput, | ||
| 97 | ): Effect.Effect<GitRunResult, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 98 | const normalizedCwd = input.cwd.trim(); | ||
| 99 | const commandInput = { | ||
| 100 | ...input, | ||
| 101 | cwd: normalizedCwd, | ||
| 102 | args: [...input.args], | ||
| 103 | } as const; | ||
| 104 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| 105 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | ||
| 106 | |||
| 107 | const commandEffect = Effect.gen(function* () { | ||
| 108 | const child = yield* ChildProcess.spawn( | ||
| 109 | ChildProcess.make("git", commandInput.args, { | ||
| 110 | cwd: commandInput.cwd, | ||
| 111 | ...(input.env ? { env: input.env } : {}), | ||
| 112 | }), | ||
| 113 | ).pipe( | ||
| 114 | Effect.mapError((cause) => | ||
| 115 | gitError( | ||
| 116 | commandInput, | ||
| 117 | causeMessage(cause, `Failed to spawn ${quoteGitCommand(commandInput.args)}.`), | ||
| 118 | cause, | ||
| 119 | ), | ||
| 120 | ), | ||
| 121 | ); | ||
| 122 | |||
| 123 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 124 | [ | ||
| 125 | collectOutput(commandInput, child.stdout, maxOutputBytes), | ||
| 126 | collectOutput(commandInput, child.stderr, maxOutputBytes), | ||
| 127 | child.exitCode.pipe( | ||
| 128 | Effect.map((value) => Number(value)), | ||
| 129 | Effect.mapError((cause) => | ||
| 130 | gitError( | ||
| 131 | commandInput, | ||
| 132 | causeMessage( | ||
| 133 | cause, | ||
| 134 | `${quoteGitCommand(commandInput.args)} failed to report exit code.`, | ||
| 135 | ), | ||
| 136 | cause, | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | ), | ||
| 140 | ], | ||
| 141 | { concurrency: "unbounded" }, | ||
| 142 | ); | ||
| 143 | |||
| 144 | if (!input.allowNonZeroExit && exitCode !== 0) { | ||
| 145 | const trimmedStderr = stderr.trim(); | ||
| 146 | return yield* Effect.fail( | ||
| 147 | gitError( | ||
| 148 | commandInput, | ||
| 149 | trimmedStderr.length > 0 | ||
| 150 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | ||
| 151 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | ||
| 152 | ), | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | |||
| 156 | return { | ||
| 157 | code: exitCode, | ||
| 158 | stdout, | ||
| 159 | stderr, | ||
| 160 | } satisfies GitRunResult; | ||
| 161 | }); | ||
| 162 | |||
| 163 | return commandEffect.pipe( | ||
| 164 | Effect.scoped, | ||
| 165 | Effect.timeoutOption(timeoutMs), | ||
| 166 | Effect.flatMap((result) => | ||
| 167 | Option.match(result, { | ||
| 168 | onNone: () => | ||
| 169 | Effect.fail(gitError(commandInput, `${quoteGitCommand(commandInput.args)} timed out.`)), | ||
| 170 | onSome: Effect.succeed, | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | ); | ||
| 174 | } | ||
apps/server/src/persistence/Errors.ts+77-35| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | import { Schema, SchemaIssue } from "effect"; | 1 | import { Schema, SchemaIssue } from "effect"; |
| 2 | 2 | ||
| 3 | // =============================== | ||
| 4 | // Core Persistence Errors | ||
| 5 | // =============================== | ||
| 6 | |||
| 3 | export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlError>()( | 7 | export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlError>()( |
| 4 | "PersistenceSqlError", | 8 | "PersistenceSqlError", |
| 5 | { | 9 | { |
| ... | @@ -39,6 +43,46 @@ export class PersistenceSerializationError extends Schema.TaggedErrorClass<Persi | ... | @@ -39,6 +43,46 @@ export class PersistenceSerializationError extends Schema.TaggedErrorClass<Persi |
| 39 | } | 43 | } |
| 40 | } | 44 | } |
| 41 | 45 | ||
| 46 | export function toPersistenceSqlError(operation: string) { | ||
| 47 | return (cause: unknown): PersistenceSqlError => | ||
| 48 | new PersistenceSqlError({ | ||
| 49 | operation, | ||
| 50 | detail: `Failed to execute ${operation}`, | ||
| 51 | cause, | ||
| 52 | }); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function toPersistenceDecodeError(operation: string) { | ||
| 56 | return (error: Schema.SchemaError): PersistenceDecodeError => | ||
| 57 | new PersistenceDecodeError({ | ||
| 58 | operation, | ||
| 59 | issue: SchemaIssue.makeFormatterDefault()(error.issue), | ||
| 60 | cause: error, | ||
| 61 | }); | ||
| 62 | } | ||
| 63 | |||
| 64 | export function toPersistenceDecodeCauseError(operation: string) { | ||
| 65 | return (cause: unknown): PersistenceDecodeError => | ||
| 66 | new PersistenceDecodeError({ | ||
| 67 | operation, | ||
| 68 | issue: `Failed to execute ${operation}`, | ||
| 69 | cause, | ||
| 70 | }); | ||
| 71 | } | ||
| 72 | |||
| 73 | export function toPersistenceSerializationError(operation: string) { | ||
| 74 | return (cause: unknown): PersistenceSerializationError => | ||
| 75 | new PersistenceSerializationError({ | ||
| 76 | operation, | ||
| 77 | detail: `Failed to execute ${operation}`, | ||
| 78 | cause, | ||
| 79 | }); | ||
| 80 | } | ||
| 81 | |||
| 82 | // =============================== | ||
| 83 | // Project Repository Errors | ||
| 84 | // =============================== | ||
| 85 | |||
| 42 | export class ProjectValidationError extends Schema.TaggedErrorClass<ProjectValidationError>()( | 86 | export class ProjectValidationError extends Schema.TaggedErrorClass<ProjectValidationError>()( |
| 43 | "ProjectValidationError", | 87 | "ProjectValidationError", |
| 44 | { | 88 | { |
| ... | @@ -76,6 +120,36 @@ export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoun | ... | @@ -76,6 +120,36 @@ export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoun |
| 76 | } | 120 | } |
| 77 | } | 121 | } |
| 78 | 122 | ||
| 123 | // =============================== | ||
| 124 | // Checkpoint Repository Errors | ||
| 125 | // =============================== | ||
| 126 | |||
| 127 | export class CheckpointRepositoryValidationError extends Schema.TaggedErrorClass<CheckpointRepositoryValidationError>()( | ||
| 128 | "CheckpointRepositoryValidationError", | ||
| 129 | { | ||
| 130 | operation: Schema.String, | ||
| 131 | issue: Schema.String, | ||
| 132 | cause: Schema.optional(Schema.Defect), | ||
| 133 | }, | ||
| 134 | ) { | ||
| 135 | override get message(): string { | ||
| 136 | return `Checkpoint repository validation failed in ${this.operation}: ${this.issue}`; | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | export class CheckpointRepositoryPersistenceError extends Schema.TaggedErrorClass<CheckpointRepositoryPersistenceError>()( | ||
| 141 | "CheckpointRepositoryPersistenceError", | ||
| 142 | { | ||
| 143 | operation: Schema.String, | ||
| 144 | detail: Schema.String, | ||
| 145 | cause: Schema.optional(Schema.Defect), | ||
| 146 | }, | ||
| 147 | ) { | ||
| 148 | override get message(): string { | ||
| 149 | return `Checkpoint repository persistence error in ${this.operation}: ${this.detail}`; | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 79 | export type ProjectRepositoryError = | 153 | export type ProjectRepositoryError = |
| 80 | | PersistenceSqlError | 154 | | PersistenceSqlError |
| 81 | | PersistenceDecodeError | 155 | | PersistenceDecodeError |
| ... | @@ -89,38 +163,6 @@ export type OrchestrationEventRepositoryError = | ... | @@ -89,38 +163,6 @@ export type OrchestrationEventRepositoryError = |
| 89 | | PersistenceDecodeError | 163 | | PersistenceDecodeError |
| 90 | | PersistenceSerializationError; | 164 | | PersistenceSerializationError; |
| 91 | 165 | ||
| 92 | export function toPersistenceSqlError(operation: string) { | 166 | export type CheckpointRepositoryError = |
| 93 | return (cause: unknown): PersistenceSqlError => | 167 | | CheckpointRepositoryValidationError |
| 94 | new PersistenceSqlError({ | 168 | | CheckpointRepositoryPersistenceError; |
| 95 | operation, | ||
| 96 | detail: `Failed to execute ${operation}`, | ||
| 97 | cause, | ||
| 98 | }); | ||
| 99 | } | ||
| 100 | |||
| 101 | export function toPersistenceDecodeError(operation: string) { | ||
| 102 | return (error: Schema.SchemaError): PersistenceDecodeError => | ||
| 103 | new PersistenceDecodeError({ | ||
| 104 | operation, | ||
| 105 | issue: SchemaIssue.makeFormatterDefault()(error.issue), | ||
| 106 | cause: error, | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | |||
| 110 | export function toPersistenceDecodeCauseError(operation: string) { | ||
| 111 | return (cause: unknown): PersistenceDecodeError => | ||
| 112 | new PersistenceDecodeError({ | ||
| 113 | operation, | ||
| 114 | issue: `Failed to execute ${operation}`, | ||
| 115 | cause, | ||
| 116 | }); | ||
| 117 | } | ||
| 118 | |||
| 119 | export function toPersistenceSerializationError(operation: string) { | ||
| 120 | return (cause: unknown): PersistenceSerializationError => | ||
| 121 | new PersistenceSerializationError({ | ||
| 122 | operation, | ||
| 123 | detail: `Failed to execute ${operation}`, | ||
| 124 | cause, | ||
| 125 | }); | ||
| 126 | } |
apps/server/src/persistence/Layers/Checkpoints.test.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { mkdtemp, rm } from "node:fs/promises"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { it, assert } from "@effect/vitest"; | ||
| 6 | import { Effect, Layer, Option } from "effect"; | ||
| 7 | |||
| 8 | import { CheckpointRepository } from "../Services/Checkpoints.ts"; | ||
| 9 | import { CheckpointRepositoryLive } from "./Checkpoints.ts"; | ||
| 10 | import { makeSqlitePersistenceLive } from "./Sqlite.ts"; | ||
| 11 | |||
| 12 | const makeTemporaryDirectoryScoped = (prefix: string) => | ||
| 13 | Effect.acquireRelease( | ||
| 14 | Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), | ||
| 15 | (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), | ||
| 16 | ); | ||
| 17 | |||
| 18 | function makeCheckpointRepositoryLayer(dbPath: string) { | ||
| 19 | return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); | ||
| 20 | } | ||
| 21 | |||
| 22 | it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const stateDir = yield* makeTemporaryDirectoryScoped("t3code-checkpoint-repository-"); | ||
| 25 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | ||
| 26 | |||
| 27 | yield* Effect.gen(function* () { | ||
| 28 | const repository = yield* CheckpointRepository; | ||
| 29 | |||
| 30 | yield* repository.upsertCheckpoint({ | ||
| 31 | providerSessionId: "sess-1", | ||
| 32 | threadId: "thread-1", | ||
| 33 | checkpointId: "root", | ||
| 34 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/0", | ||
| 35 | turnCount: 0, | ||
| 36 | messageCount: 0, | ||
| 37 | label: "Start of conversation", | ||
| 38 | createdAt: new Date().toISOString(), | ||
| 39 | }); | ||
| 40 | |||
| 41 | yield* repository.upsertCheckpoint({ | ||
| 42 | providerSessionId: "sess-1", | ||
| 43 | threadId: "thread-1", | ||
| 44 | checkpointId: "turn-1", | ||
| 45 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", | ||
| 46 | turnCount: 1, | ||
| 47 | messageCount: 2, | ||
| 48 | label: "Turn 1", | ||
| 49 | preview: "hello", | ||
| 50 | createdAt: new Date().toISOString(), | ||
| 51 | }); | ||
| 52 | |||
| 53 | yield* repository.upsertCheckpoint({ | ||
| 54 | providerSessionId: "sess-1", | ||
| 55 | threadId: "thread-1", | ||
| 56 | checkpointId: "turn-2", | ||
| 57 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", | ||
| 58 | turnCount: 2, | ||
| 59 | messageCount: 4, | ||
| 60 | label: "Turn 2", | ||
| 61 | preview: "done", | ||
| 62 | createdAt: new Date().toISOString(), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const checkpoints = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); | ||
| 66 | assert.equal(checkpoints.length, 3); | ||
| 67 | assert.equal(checkpoints[0]?.id, "root"); | ||
| 68 | assert.equal(checkpoints[0]?.isCurrent, false); | ||
| 69 | assert.equal(checkpoints[2]?.id, "turn-2"); | ||
| 70 | assert.equal(checkpoints[2]?.isCurrent, true); | ||
| 71 | |||
| 72 | const one = yield* repository.getCheckpoint({ providerSessionId: "sess-1", turnCount: 1 }); | ||
| 73 | assert.equal(Option.isSome(one), true); | ||
| 74 | if (Option.isSome(one)) { | ||
| 75 | assert.equal(one.value.id, "turn-1"); | ||
| 76 | assert.equal(one.value.isCurrent, false); | ||
| 77 | assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1"); | ||
| 78 | } | ||
| 79 | |||
| 80 | yield* repository.deleteAfterTurn({ | ||
| 81 | providerSessionId: "sess-1", | ||
| 82 | maxTurnCount: 1, | ||
| 83 | }); | ||
| 84 | |||
| 85 | const pruned = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); | ||
| 86 | assert.equal(pruned.length, 2); | ||
| 87 | assert.equal(pruned[1]?.turnCount, 1); | ||
| 88 | assert.equal(pruned[1]?.isCurrent, true); | ||
| 89 | |||
| 90 | yield* repository.deleteAllForSession({ providerSessionId: "sess-1" }); | ||
| 91 | const empty = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); | ||
| 92 | assert.equal(empty.length, 0); | ||
| 93 | }).pipe(Effect.provide(makeCheckpointRepositoryLayer(dbPath))); | ||
| 94 | }), | ||
| 95 | ); | ||
apps/server/src/persistence/Layers/Checkpoints.ts created+363| ... | @@ -0,0 +1,363 @@ | ||
| 1 | /** | ||
| 2 | * CheckpointRepositoryLive - SQLite-backed checkpoint metadata repository layer. | ||
| 3 | * | ||
| 4 | * Persists and queries checkpoint metadata (`turnCount`, labels, previews, | ||
| 5 | * message counts) and the associated hidden Git ref for each turn. | ||
| 6 | * | ||
| 7 | * This layer does not read/write Git refs and does not perform provider-thread | ||
| 8 | * rollback. Those responsibilities remain in `CheckpointStore` and | ||
| 9 | * `CheckpointService`. | ||
| 10 | * | ||
| 11 | * @module CheckpointRepositoryLive | ||
| 12 | */ | ||
| 13 | import type { ProviderCheckpoint } from "@t3tools/contracts"; | ||
| 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 15 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | ||
| 16 | import { Effect, Layer, Option, Schema } from "effect"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | CheckpointRepositoryPersistenceError, | ||
| 20 | CheckpointRepositoryValidationError, | ||
| 21 | type CheckpointRepositoryError, | ||
| 22 | } from "../Errors.ts"; | ||
| 23 | import { makeSqlitePersistenceLive } from "./Sqlite.ts"; | ||
| 24 | import { | ||
| 25 | CheckpointRepository, | ||
| 26 | type CheckpointRepositoryEntry, | ||
| 27 | type CheckpointRepositoryShape, | ||
| 28 | } from "../Services/Checkpoints.ts"; | ||
| 29 | |||
| 30 | const NonEmptyString = Schema.NonEmptyString; | ||
| 31 | |||
| 32 | const CheckpointRowSchema = Schema.Struct({ | ||
| 33 | providerSessionId: Schema.String, | ||
| 34 | threadId: Schema.String, | ||
| 35 | checkpointId: Schema.String, | ||
| 36 | checkpointRef: Schema.String, | ||
| 37 | turnCount: Schema.Number, | ||
| 38 | messageCount: Schema.Number, | ||
| 39 | label: Schema.String, | ||
| 40 | preview: Schema.NullOr(Schema.String), | ||
| 41 | createdAt: Schema.String, | ||
| 42 | }); | ||
| 43 | |||
| 44 | const SessionRequestSchema = Schema.Struct({ | ||
| 45 | providerSessionId: NonEmptyString, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const UpsertRequestSchema = Schema.Struct({ | ||
| 49 | providerSessionId: NonEmptyString, | ||
| 50 | threadId: NonEmptyString, | ||
| 51 | checkpointId: NonEmptyString, | ||
| 52 | checkpointRef: NonEmptyString, | ||
| 53 | turnCount: Schema.Int, | ||
| 54 | messageCount: Schema.Int, | ||
| 55 | label: NonEmptyString, | ||
| 56 | preview: Schema.NullOr(Schema.String), | ||
| 57 | createdAt: NonEmptyString, | ||
| 58 | }); | ||
| 59 | |||
| 60 | const GetCheckpointRequestSchema = Schema.Struct({ | ||
| 61 | providerSessionId: NonEmptyString, | ||
| 62 | turnCount: Schema.Int, | ||
| 63 | }); | ||
| 64 | |||
| 65 | const DeleteAfterTurnRequestSchema = Schema.Struct({ | ||
| 66 | providerSessionId: NonEmptyString, | ||
| 67 | maxTurnCount: Schema.Int, | ||
| 68 | }); | ||
| 69 | |||
| 70 | function errorMessage(cause: unknown, fallback: string): string { | ||
| 71 | if (cause instanceof Error && cause.message.length > 0) { | ||
| 72 | return cause.message; | ||
| 73 | } | ||
| 74 | return fallback; | ||
| 75 | } | ||
| 76 | |||
| 77 | function toValidationError(operation: string, cause: unknown): CheckpointRepositoryValidationError { | ||
| 78 | return new CheckpointRepositoryValidationError({ | ||
| 79 | operation, | ||
| 80 | issue: errorMessage(cause, "Invalid checkpoint repository input."), | ||
| 81 | cause, | ||
| 82 | }); | ||
| 83 | } | ||
| 84 | |||
| 85 | function decodeInput<A>( | ||
| 86 | schema: Schema.Schema<A>, | ||
| 87 | input: unknown, | ||
| 88 | operation: string, | ||
| 89 | ): Effect.Effect<A, CheckpointRepositoryValidationError> { | ||
| 90 | return Schema.decodeUnknownEffect(schema)(input).pipe( | ||
| 91 | Effect.mapError((cause) => toValidationError(operation, cause)), | ||
| 92 | ) as Effect.Effect<A, CheckpointRepositoryValidationError>; | ||
| 93 | } | ||
| 94 | |||
| 95 | function ensureNonNegativeInt( | ||
| 96 | operation: string, | ||
| 97 | field: string, | ||
| 98 | value: number, | ||
| 99 | ): Effect.Effect<number, CheckpointRepositoryValidationError> { | ||
| 100 | if (value < 0) { | ||
| 101 | return Effect.fail( | ||
| 102 | new CheckpointRepositoryValidationError({ | ||
| 103 | operation, | ||
| 104 | issue: `${field} must be an integer >= 0.`, | ||
| 105 | }), | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | return Effect.succeed(value); | ||
| 109 | } | ||
| 110 | |||
| 111 | function toPersistenceError( | ||
| 112 | operation: string, | ||
| 113 | cause: unknown, | ||
| 114 | ): CheckpointRepositoryPersistenceError { | ||
| 115 | return new CheckpointRepositoryPersistenceError({ | ||
| 116 | operation, | ||
| 117 | detail: `Failed to execute ${operation}.`, | ||
| 118 | cause, | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | |||
| 122 | function toCheckpointEntry( | ||
| 123 | row: Schema.Schema.Type<typeof CheckpointRowSchema>, | ||
| 124 | isCurrent: boolean, | ||
| 125 | ): CheckpointRepositoryEntry { | ||
| 126 | return { | ||
| 127 | id: row.checkpointId, | ||
| 128 | threadId: row.threadId, | ||
| 129 | checkpointRef: row.checkpointRef, | ||
| 130 | turnCount: row.turnCount, | ||
| 131 | messageCount: row.messageCount, | ||
| 132 | label: row.label, | ||
| 133 | ...(row.preview !== null ? { preview: row.preview } : {}), | ||
| 134 | createdAt: row.createdAt, | ||
| 135 | isCurrent, | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | function toProviderCheckpoint(entry: CheckpointRepositoryEntry): ProviderCheckpoint { | ||
| 140 | return { | ||
| 141 | id: entry.id, | ||
| 142 | turnCount: entry.turnCount, | ||
| 143 | messageCount: entry.messageCount, | ||
| 144 | label: entry.label, | ||
| 145 | ...(entry.preview !== undefined ? { preview: entry.preview } : {}), | ||
| 146 | isCurrent: entry.isCurrent, | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | const makeCheckpointRepository = Effect.gen(function* () { | ||
| 151 | const sql = yield* SqlClient.SqlClient; | ||
| 152 | |||
| 153 | const upsertCheckpointRow = SqlSchema.findOne({ | ||
| 154 | Request: UpsertRequestSchema, | ||
| 155 | Result: CheckpointRowSchema, | ||
| 156 | execute: (request) => | ||
| 157 | sql` | ||
| 158 | INSERT INTO provider_checkpoints ( | ||
| 159 | provider_session_id, | ||
| 160 | thread_id, | ||
| 161 | checkpoint_id, | ||
| 162 | checkpoint_ref, | ||
| 163 | turn_count, | ||
| 164 | message_count, | ||
| 165 | label, | ||
| 166 | preview, | ||
| 167 | created_at | ||
| 168 | ) | ||
| 169 | VALUES ( | ||
| 170 | ${request.providerSessionId}, | ||
| 171 | ${request.threadId}, | ||
| 172 | ${request.checkpointId}, | ||
| 173 | ${request.checkpointRef}, | ||
| 174 | ${request.turnCount}, | ||
| 175 | ${request.messageCount}, | ||
| 176 | ${request.label}, | ||
| 177 | ${request.preview}, | ||
| 178 | ${request.createdAt} | ||
| 179 | ) | ||
| 180 | ON CONFLICT (provider_session_id, turn_count) | ||
| 181 | DO UPDATE SET | ||
| 182 | thread_id = excluded.thread_id, | ||
| 183 | checkpoint_id = excluded.checkpoint_id, | ||
| 184 | checkpoint_ref = excluded.checkpoint_ref, | ||
| 185 | message_count = excluded.message_count, | ||
| 186 | label = excluded.label, | ||
| 187 | preview = excluded.preview, | ||
| 188 | created_at = excluded.created_at | ||
| 189 | RETURNING | ||
| 190 | provider_session_id AS "providerSessionId", | ||
| 191 | thread_id AS "threadId", | ||
| 192 | checkpoint_id AS "checkpointId", | ||
| 193 | checkpoint_ref AS "checkpointRef", | ||
| 194 | turn_count AS "turnCount", | ||
| 195 | message_count AS "messageCount", | ||
| 196 | label, | ||
| 197 | preview, | ||
| 198 | created_at AS "createdAt" | ||
| 199 | `, | ||
| 200 | }); | ||
| 201 | |||
| 202 | const listCheckpointRows = SqlSchema.findAll({ | ||
| 203 | Request: SessionRequestSchema, | ||
| 204 | Result: CheckpointRowSchema, | ||
| 205 | execute: ({ providerSessionId }) => | ||
| 206 | sql` | ||
| 207 | SELECT | ||
| 208 | provider_session_id AS "providerSessionId", | ||
| 209 | thread_id AS "threadId", | ||
| 210 | checkpoint_id AS "checkpointId", | ||
| 211 | checkpoint_ref AS "checkpointRef", | ||
| 212 | turn_count AS "turnCount", | ||
| 213 | message_count AS "messageCount", | ||
| 214 | label, | ||
| 215 | preview, | ||
| 216 | created_at AS "createdAt" | ||
| 217 | FROM provider_checkpoints | ||
| 218 | WHERE provider_session_id = ${providerSessionId} | ||
| 219 | ORDER BY turn_count ASC | ||
| 220 | `, | ||
| 221 | }); | ||
| 222 | |||
| 223 | const listForSession = ( | ||
| 224 | providerSessionId: string, | ||
| 225 | ): Effect.Effect<ReadonlyArray<CheckpointRepositoryEntry>, CheckpointRepositoryError> => | ||
| 226 | listCheckpointRows({ providerSessionId }).pipe( | ||
| 227 | Effect.mapError((cause) => | ||
| 228 | toPersistenceError("CheckpointRepository.listCheckpoints:query", cause), | ||
| 229 | ), | ||
| 230 | Effect.map((rows) => { | ||
| 231 | const currentTurnCount = rows[rows.length - 1]?.turnCount; | ||
| 232 | return rows.map((row) => toCheckpointEntry(row, row.turnCount === currentTurnCount)); | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | |||
| 236 | const upsertCheckpoint: CheckpointRepositoryShape["upsertCheckpoint"] = (input) => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | const operation = "CheckpointRepository.upsertCheckpoint"; | ||
| 239 | const parsed = yield* decodeInput( | ||
| 240 | UpsertRequestSchema, | ||
| 241 | { | ||
| 242 | providerSessionId: input.providerSessionId.trim(), | ||
| 243 | threadId: input.threadId.trim(), | ||
| 244 | checkpointId: input.checkpointId.trim(), | ||
| 245 | checkpointRef: input.checkpointRef.trim(), | ||
| 246 | turnCount: input.turnCount, | ||
| 247 | messageCount: input.messageCount, | ||
| 248 | label: input.label.trim(), | ||
| 249 | preview: input.preview?.trim() || null, | ||
| 250 | createdAt: input.createdAt.trim(), | ||
| 251 | }, | ||
| 252 | operation, | ||
| 253 | ); | ||
| 254 | yield* ensureNonNegativeInt(operation, "turnCount", parsed.turnCount); | ||
| 255 | yield* ensureNonNegativeInt(operation, "messageCount", parsed.messageCount); | ||
| 256 | |||
| 257 | yield* upsertCheckpointRow(parsed).pipe( | ||
| 258 | Effect.mapError((cause) => | ||
| 259 | toPersistenceError("CheckpointRepository.upsertCheckpoint:query", cause), | ||
| 260 | ), | ||
| 261 | Effect.asVoid, | ||
| 262 | ); | ||
| 263 | }); | ||
| 264 | |||
| 265 | const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const parsed = yield* decodeInput( | ||
| 268 | SessionRequestSchema, | ||
| 269 | { | ||
| 270 | providerSessionId: input.providerSessionId.trim(), | ||
| 271 | }, | ||
| 272 | "CheckpointRepository.listCheckpoints", | ||
| 273 | ); | ||
| 274 | |||
| 275 | const entries = yield* listForSession(parsed.providerSessionId); | ||
| 276 | return entries.map(toProviderCheckpoint); | ||
| 277 | }); | ||
| 278 | |||
| 279 | const getCheckpoint: CheckpointRepositoryShape["getCheckpoint"] = (input) => | ||
| 280 | Effect.gen(function* () { | ||
| 281 | const parsed = yield* decodeInput( | ||
| 282 | GetCheckpointRequestSchema, | ||
| 283 | { | ||
| 284 | providerSessionId: input.providerSessionId.trim(), | ||
| 285 | turnCount: input.turnCount, | ||
| 286 | }, | ||
| 287 | "CheckpointRepository.getCheckpoint", | ||
| 288 | ); | ||
| 289 | yield* ensureNonNegativeInt( | ||
| 290 | "CheckpointRepository.getCheckpoint", | ||
| 291 | "turnCount", | ||
| 292 | parsed.turnCount, | ||
| 293 | ); | ||
| 294 | |||
| 295 | const entries = yield* listForSession(parsed.providerSessionId); | ||
| 296 | const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount); | ||
| 297 | return Option.fromNullishOr(checkpoint); | ||
| 298 | }); | ||
| 299 | |||
| 300 | const deleteAfterTurn: CheckpointRepositoryShape["deleteAfterTurn"] = (input) => | ||
| 301 | Effect.gen(function* () { | ||
| 302 | const parsed = yield* decodeInput( | ||
| 303 | DeleteAfterTurnRequestSchema, | ||
| 304 | { | ||
| 305 | providerSessionId: input.providerSessionId.trim(), | ||
| 306 | maxTurnCount: input.maxTurnCount, | ||
| 307 | }, | ||
| 308 | "CheckpointRepository.deleteAfterTurn", | ||
| 309 | ); | ||
| 310 | yield* ensureNonNegativeInt( | ||
| 311 | "CheckpointRepository.deleteAfterTurn", | ||
| 312 | "maxTurnCount", | ||
| 313 | parsed.maxTurnCount, | ||
| 314 | ); | ||
| 315 | |||
| 316 | yield* sql` | ||
| 317 | DELETE FROM provider_checkpoints | ||
| 318 | WHERE provider_session_id = ${parsed.providerSessionId} | ||
| 319 | AND turn_count > ${parsed.maxTurnCount} | ||
| 320 | `.pipe( | ||
| 321 | Effect.mapError((cause) => | ||
| 322 | toPersistenceError("CheckpointRepository.deleteAfterTurn:query", cause), | ||
| 323 | ), | ||
| 324 | ); | ||
| 325 | }); | ||
| 326 | |||
| 327 | const deleteAllForSession: CheckpointRepositoryShape["deleteAllForSession"] = (input) => | ||
| 328 | Effect.gen(function* () { | ||
| 329 | const parsed = yield* decodeInput( | ||
| 330 | SessionRequestSchema, | ||
| 331 | { | ||
| 332 | providerSessionId: input.providerSessionId.trim(), | ||
| 333 | }, | ||
| 334 | "CheckpointRepository.deleteAllForSession", | ||
| 335 | ); | ||
| 336 | |||
| 337 | yield* sql` | ||
| 338 | DELETE FROM provider_checkpoints | ||
| 339 | WHERE provider_session_id = ${parsed.providerSessionId} | ||
| 340 | `.pipe( | ||
| 341 | Effect.mapError((cause) => | ||
| 342 | toPersistenceError("CheckpointRepository.deleteAllForSession:query", cause), | ||
| 343 | ), | ||
| 344 | ); | ||
| 345 | }); | ||
| 346 | |||
| 347 | return { | ||
| 348 | upsertCheckpoint, | ||
| 349 | listCheckpoints, | ||
| 350 | getCheckpoint, | ||
| 351 | deleteAfterTurn, | ||
| 352 | deleteAllForSession, | ||
| 353 | } satisfies CheckpointRepositoryShape; | ||
| 354 | }); | ||
| 355 | |||
| 356 | export const CheckpointRepositoryLive = Layer.effect( | ||
| 357 | CheckpointRepository, | ||
| 358 | makeCheckpointRepository, | ||
| 359 | ); | ||
| 360 | |||
| 361 | export function makeSqliteCheckpointRepositoryLive(dbPath: string) { | ||
| 362 | return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); | ||
| 363 | } | ||
apps/server/src/persistence/Migrations.ts+2| ... | @@ -14,6 +14,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -14,6 +14,7 @@ import * as Layer from "effect/Layer"; |
| 14 | // Import all migrations statically | 14 | // Import all migrations statically |
| 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| 16 | import Migration0002 from "./Migrations/002_Projects.ts"; | 16 | import Migration0002 from "./Migrations/002_Projects.ts"; |
| 17 | import Migration0003 from "./Migrations/003_Checkpoints.ts"; | ||
| 17 | 18 | ||
| 18 | /** | 19 | /** |
| 19 | * Migration loader with all migrations defined inline. | 20 | * Migration loader with all migrations defined inline. |
| ... | @@ -28,6 +29,7 @@ import Migration0002 from "./Migrations/002_Projects.ts"; | ... | @@ -28,6 +29,7 @@ import Migration0002 from "./Migrations/002_Projects.ts"; |
| 28 | const loader = Migrator.fromRecord({ | 29 | const loader = Migrator.fromRecord({ |
| 29 | "1_OrchestrationEvents": Migration0001, | 30 | "1_OrchestrationEvents": Migration0001, |
| 30 | "2_Projects": Migration0002, | 31 | "2_Projects": Migration0002, |
| 32 | "3_CheckpointCatalog": Migration0003, | ||
| 31 | }); | 33 | }); |
| 32 | 34 | ||
| 33 | /** | 35 | /** |
apps/server/src/persistence/Migrations/003_Checkpoints.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | yield* sql` | ||
| 8 | CREATE TABLE IF NOT EXISTS provider_checkpoints ( | ||
| 9 | provider_session_id TEXT NOT NULL, | ||
| 10 | thread_id TEXT NOT NULL, | ||
| 11 | checkpoint_id TEXT NOT NULL, | ||
| 12 | checkpoint_ref TEXT NOT NULL, | ||
| 13 | turn_count INTEGER NOT NULL, | ||
| 14 | message_count INTEGER NOT NULL, | ||
| 15 | label TEXT NOT NULL, | ||
| 16 | preview TEXT, | ||
| 17 | created_at TEXT NOT NULL, | ||
| 18 | PRIMARY KEY (provider_session_id, turn_count) | ||
| 19 | ) | ||
| 20 | `; | ||
| 21 | |||
| 22 | yield* sql` | ||
| 23 | CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session_turn | ||
| 24 | ON provider_checkpoints(provider_session_id, turn_count ASC) | ||
| 25 | `; | ||
| 26 | }); | ||
apps/server/src/persistence/Services/Checkpoints.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | /** | ||
| 2 | * CheckpointRepository - Repository interface for checkpoint metadata persistence. | ||
| 3 | * | ||
| 4 | * Stores and queries checkpoint metadata and checkpoint Git refs keyed by | ||
| 5 | * provider session + turn count. It does not read/write Git refs itself and it | ||
| 6 | * does not coordinate provider rollback workflows. | ||
| 7 | * | ||
| 8 | * @module CheckpointRepository | ||
| 9 | */ | ||
| 10 | import type { ProviderCheckpoint } from "@t3tools/contracts"; | ||
| 11 | import { Option, ServiceMap } from "effect"; | ||
| 12 | import type { Effect } from "effect"; | ||
| 13 | |||
| 14 | import type { CheckpointRepositoryError } from "../Errors.ts"; | ||
| 15 | |||
| 16 | export interface UpsertCheckpointInput { | ||
| 17 | readonly providerSessionId: string; | ||
| 18 | readonly threadId: string; | ||
| 19 | readonly checkpointId: string; | ||
| 20 | readonly checkpointRef: string; | ||
| 21 | readonly turnCount: number; | ||
| 22 | readonly messageCount: number; | ||
| 23 | readonly label: string; | ||
| 24 | readonly preview?: string; | ||
| 25 | readonly createdAt: string; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface CheckpointRepositoryEntry extends ProviderCheckpoint { | ||
| 29 | readonly threadId: string; | ||
| 30 | readonly checkpointRef: string; | ||
| 31 | readonly createdAt: string; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface ListSessionCheckpointsInput { | ||
| 35 | readonly providerSessionId: string; | ||
| 36 | } | ||
| 37 | |||
| 38 | export interface GetCheckpointInput { | ||
| 39 | readonly providerSessionId: string; | ||
| 40 | readonly turnCount: number; | ||
| 41 | } | ||
| 42 | |||
| 43 | export interface DeleteAfterTurnInput { | ||
| 44 | readonly providerSessionId: string; | ||
| 45 | readonly maxTurnCount: number; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface DeleteAllForSessionInput { | ||
| 49 | readonly providerSessionId: string; | ||
| 50 | } | ||
| 51 | |||
| 52 | export interface CheckpointRepositoryShape { | ||
| 53 | /** | ||
| 54 | * Insert or update one checkpoint metadata row. | ||
| 55 | */ | ||
| 56 | readonly upsertCheckpoint: ( | ||
| 57 | input: UpsertCheckpointInput, | ||
| 58 | ) => Effect.Effect<void, CheckpointRepositoryError>; | ||
| 59 | |||
| 60 | /** | ||
| 61 | * List user-facing checkpoints for a provider session. | ||
| 62 | */ | ||
| 63 | readonly listCheckpoints: ( | ||
| 64 | input: ListSessionCheckpointsInput, | ||
| 65 | ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointRepositoryError>; | ||
| 66 | |||
| 67 | /** | ||
| 68 | * Read one checkpoint record (including checkpoint ref) by turn count. | ||
| 69 | */ | ||
| 70 | readonly getCheckpoint: ( | ||
| 71 | input: GetCheckpointInput, | ||
| 72 | ) => Effect.Effect<Option.Option<CheckpointRepositoryEntry>, CheckpointRepositoryError>; | ||
| 73 | |||
| 74 | /** | ||
| 75 | * Delete checkpoint metadata newer than the provided turn count. | ||
| 76 | */ | ||
| 77 | readonly deleteAfterTurn: ( | ||
| 78 | input: DeleteAfterTurnInput, | ||
| 79 | ) => Effect.Effect<void, CheckpointRepositoryError>; | ||
| 80 | |||
| 81 | /** | ||
| 82 | * Delete all checkpoint metadata for a provider session. | ||
| 83 | */ | ||
| 84 | readonly deleteAllForSession: ( | ||
| 85 | input: DeleteAllForSessionInput, | ||
| 86 | ) => Effect.Effect<void, CheckpointRepositoryError>; | ||
| 87 | } | ||
| 88 | |||
| 89 | /** | ||
| 90 | * CheckpointRepository - Service tag for checkpoint metadata persistence. | ||
| 91 | */ | ||
| 92 | export class CheckpointRepository extends ServiceMap.Service< | ||
| 93 | CheckpointRepository, | ||
| 94 | CheckpointRepositoryShape | ||
| 95 | >()("persistence/CheckpointRepository") {} | ||
apps/server/src/provider/contracts.test.ts+27-18| ... | @@ -11,14 +11,18 @@ import { | ... | @@ -11,14 +11,18 @@ import { |
| 11 | type ProviderServiceError, | 11 | type ProviderServiceError, |
| 12 | } from "./Errors.ts"; | 12 | } from "./Errors.ts"; |
| 13 | import { | 13 | import { |
| 14 | CheckpointValidationError, | 14 | CheckpointGitCommandError, |
| 15 | type CheckpointCatalogError, | 15 | CheckpointServiceValidationError, |
| 16 | type CheckpointServiceError, | 16 | type CheckpointServiceError, |
| 17 | type CheckpointStoreError, | 17 | type CheckpointStoreError, |
| 18 | } from "../checkpointing/Errors.ts"; | 18 | } from "../checkpointing/Errors.ts"; |
| 19 | import { CheckpointCatalog } from "../checkpointing/Services/CheckpointCatalog.ts"; | 19 | import { |
| 20 | CheckpointRepositoryValidationError, | ||
| 21 | type CheckpointRepositoryError, | ||
| 22 | } from "../persistence/Errors.ts"; | ||
| 20 | import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts"; | 23 | import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts"; |
| 21 | import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; | 24 | import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; |
| 25 | import { CheckpointRepository } from "../persistence/Services/Checkpoints.ts"; | ||
| 22 | import { CodexAdapter } from "./Services/CodexAdapter.ts"; | 26 | import { CodexAdapter } from "./Services/CodexAdapter.ts"; |
| 23 | import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts"; | 27 | import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts"; |
| 24 | import { ProviderService } from "./Services/ProviderService.ts"; | 28 | import { ProviderService } from "./Services/ProviderService.ts"; |
| ... | @@ -107,20 +111,22 @@ describe("provider/checkpoint service contracts", () => { | ... | @@ -107,20 +111,22 @@ describe("provider/checkpoint service contracts", () => { |
| 107 | const checkpointStoreLive: typeof CheckpointStore.Service = { | 111 | const checkpointStoreLive: typeof CheckpointStore.Service = { |
| 108 | isGitRepository: () => Effect.succeed(false), | 112 | isGitRepository: () => Effect.succeed(false), |
| 109 | captureCheckpoint: () => Effect.void, | 113 | captureCheckpoint: () => Effect.void, |
| 110 | hasCheckpoint: () => Effect.succeed(false), | 114 | hasCheckpointRef: () => Effect.succeed(false), |
| 111 | ensureRootCheckpoint: () => Effect.succeed(false), | 115 | ensureRootCheckpoint: () => Effect.succeed(false), |
| 112 | restoreCheckpoint: () => Effect.succeed(false), | 116 | restoreCheckpoint: () => Effect.succeed(false), |
| 113 | diffCheckpoints: () => | 117 | diffCheckpoints: () => |
| 114 | Effect.fail<CheckpointStoreError>( | 118 | Effect.fail<CheckpointStoreError>( |
| 115 | new CheckpointValidationError({ | 119 | new CheckpointGitCommandError({ |
| 116 | operation: "diffCheckpoints", | 120 | operation: "diffCheckpoints", |
| 117 | issue: "not implemented", | 121 | command: "git diff", |
| 122 | cwd: "/tmp", | ||
| 123 | detail: "not implemented", | ||
| 118 | }), | 124 | }), |
| 119 | ), | 125 | ), |
| 120 | pruneAfterTurn: () => Effect.void, | 126 | deleteCheckpointRefs: () => Effect.void, |
| 121 | }; | 127 | }; |
| 122 | 128 | ||
| 123 | const checkpointCatalogLive: typeof CheckpointCatalog.Service = { | 129 | const checkpointRepositoryLive: typeof CheckpointRepository.Service = { |
| 124 | upsertCheckpoint: () => Effect.void, | 130 | upsertCheckpoint: () => Effect.void, |
| 125 | listCheckpoints: () => Effect.succeed([]), | 131 | listCheckpoints: () => Effect.succeed([]), |
| 126 | getCheckpoint: () => Effect.succeed(Option.none()), | 132 | getCheckpoint: () => Effect.succeed(Option.none()), |
| ... | @@ -133,18 +139,21 @@ describe("provider/checkpoint service contracts", () => { | ... | @@ -133,18 +139,21 @@ describe("provider/checkpoint service contracts", () => { |
| 133 | captureCurrentTurn: () => Effect.void, | 139 | captureCurrentTurn: () => Effect.void, |
| 134 | listCheckpoints: () => | 140 | listCheckpoints: () => |
| 135 | Effect.fail<CheckpointServiceError>( | 141 | Effect.fail<CheckpointServiceError>( |
| 136 | new CheckpointValidationError({ operation: "listCheckpoints", issue: "not implemented" }), | 142 | new CheckpointServiceValidationError({ |
| 143 | operation: "listCheckpoints", | ||
| 144 | issue: "not implemented", | ||
| 145 | }), | ||
| 137 | ), | 146 | ), |
| 138 | getCheckpointDiff: () => | 147 | getCheckpointDiff: () => |
| 139 | Effect.fail<CheckpointServiceError>( | 148 | Effect.fail<CheckpointServiceError>( |
| 140 | new CheckpointValidationError({ | 149 | new CheckpointServiceValidationError({ |
| 141 | operation: "getCheckpointDiff", | 150 | operation: "getCheckpointDiff", |
| 142 | issue: "not implemented", | 151 | issue: "not implemented", |
| 143 | }), | 152 | }), |
| 144 | ), | 153 | ), |
| 145 | revertToCheckpoint: () => | 154 | revertToCheckpoint: () => |
| 146 | Effect.fail<CheckpointServiceError>( | 155 | Effect.fail<CheckpointServiceError>( |
| 147 | new CheckpointValidationError({ | 156 | new CheckpointServiceValidationError({ |
| 148 | operation: "revertToCheckpoint", | 157 | operation: "revertToCheckpoint", |
| 149 | issue: "not implemented", | 158 | issue: "not implemented", |
| 150 | }), | 159 | }), |
| ... | @@ -174,7 +183,7 @@ describe("provider/checkpoint service contracts", () => { | ... | @@ -174,7 +183,7 @@ describe("provider/checkpoint service contracts", () => { |
| 174 | Layer.succeed(ProviderService, providerLive), | 183 | Layer.succeed(ProviderService, providerLive), |
| 175 | Layer.succeed(CodexAdapter, codexLive), | 184 | Layer.succeed(CodexAdapter, codexLive), |
| 176 | Layer.succeed(CheckpointStore, checkpointStoreLive), | 185 | Layer.succeed(CheckpointStore, checkpointStoreLive), |
| 177 | Layer.succeed(CheckpointCatalog, checkpointCatalogLive), | 186 | Layer.succeed(CheckpointRepository, checkpointRepositoryLive), |
| 178 | Layer.succeed(CheckpointService, checkpointServiceLive), | 187 | Layer.succeed(CheckpointService, checkpointServiceLive), |
| 179 | Layer.succeed(ProviderAdapterRegistry, providerAdapterRegistryLive), | 188 | Layer.succeed(ProviderAdapterRegistry, providerAdapterRegistryLive), |
| 180 | Layer.succeed(ProviderSessionDirectory, providerSessionDirectoryLive), | 189 | Layer.succeed(ProviderSessionDirectory, providerSessionDirectoryLive), |
| ... | @@ -185,7 +194,7 @@ describe("provider/checkpoint service contracts", () => { | ... | @@ -185,7 +194,7 @@ describe("provider/checkpoint service contracts", () => { |
| 185 | provider, | 194 | provider, |
| 186 | codex, | 195 | codex, |
| 187 | checkpointStore, | 196 | checkpointStore, |
| 188 | checkpointCatalog, | 197 | checkpointRepository, |
| 189 | checkpointService, | 198 | checkpointService, |
| 190 | providerAdapterRegistry, | 199 | providerAdapterRegistry, |
| 191 | providerSessionDirectory, | 200 | providerSessionDirectory, |
| ... | @@ -193,7 +202,7 @@ describe("provider/checkpoint service contracts", () => { | ... | @@ -193,7 +202,7 @@ describe("provider/checkpoint service contracts", () => { |
| 193 | runtime.runPromise(Effect.service(ProviderService)), | 202 | runtime.runPromise(Effect.service(ProviderService)), |
| 194 | runtime.runPromise(Effect.service(CodexAdapter)), | 203 | runtime.runPromise(Effect.service(CodexAdapter)), |
| 195 | runtime.runPromise(Effect.service(CheckpointStore)), | 204 | runtime.runPromise(Effect.service(CheckpointStore)), |
| 196 | runtime.runPromise(Effect.service(CheckpointCatalog)), | 205 | runtime.runPromise(Effect.service(CheckpointRepository)), |
| 197 | runtime.runPromise(Effect.service(CheckpointService)), | 206 | runtime.runPromise(Effect.service(CheckpointService)), |
| 198 | runtime.runPromise(Effect.service(ProviderAdapterRegistry)), | 207 | runtime.runPromise(Effect.service(ProviderAdapterRegistry)), |
| 199 | runtime.runPromise(Effect.service(ProviderSessionDirectory)), | 208 | runtime.runPromise(Effect.service(ProviderSessionDirectory)), |
| ... | @@ -202,7 +211,7 @@ describe("provider/checkpoint service contracts", () => { | ... | @@ -202,7 +211,7 @@ describe("provider/checkpoint service contracts", () => { |
| 202 | expect(provider).toBe(providerLive); | 211 | expect(provider).toBe(providerLive); |
| 203 | expect(codex).toBe(codexLive); | 212 | expect(codex).toBe(codexLive); |
| 204 | expect(checkpointStore).toBe(checkpointStoreLive); | 213 | expect(checkpointStore).toBe(checkpointStoreLive); |
| 205 | expect(checkpointCatalog).toBe(checkpointCatalogLive); | 214 | expect(checkpointRepository).toBe(checkpointRepositoryLive); |
| 206 | expect(checkpointService).toBe(checkpointServiceLive); | 215 | expect(checkpointService).toBe(checkpointServiceLive); |
| 207 | expect(providerAdapterRegistry).toBe(providerAdapterRegistryLive); | 216 | expect(providerAdapterRegistry).toBe(providerAdapterRegistryLive); |
| 208 | expect(providerSessionDirectory).toBe(providerSessionDirectoryLive); | 217 | expect(providerSessionDirectory).toBe(providerSessionDirectoryLive); |
| ... | @@ -225,12 +234,12 @@ describe("provider/checkpoint errors", () => { | ... | @@ -225,12 +234,12 @@ describe("provider/checkpoint errors", () => { |
| 225 | expect(fsError.cause).toBe(fsCause); | 234 | expect(fsError.cause).toBe(fsCause); |
| 226 | }); | 235 | }); |
| 227 | 236 | ||
| 228 | it("accepts checkpoint catalog error channel type", () => { | 237 | it("accepts checkpoint repository error channel type", () => { |
| 229 | const error: CheckpointCatalogError = new CheckpointValidationError({ | 238 | const error: CheckpointRepositoryError = new CheckpointRepositoryValidationError({ |
| 230 | operation: "upsertCheckpoint", | 239 | operation: "upsertCheckpoint", |
| 231 | issue: "turnCount must be >= 0", | 240 | issue: "turnCount must be >= 0", |
| 232 | }); | 241 | }); |
| 233 | 242 | ||
| 234 | expect(error._tag).toBe("CheckpointValidationError"); | 243 | expect(error._tag).toBe("CheckpointRepositoryValidationError"); |
| 235 | }); | 244 | }); |
| 236 | }); | 245 | }); |