authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-22 16:12:48-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log54dd17e7b9f576f3219d2512497c492180b76fd1
treeb7cd4b4eb0710f54134efd537de2f90ae330fe7b
parent32942a5f20246ad6a0bd8f0e72e73c084676d45b

more effect:checkpointing


18 files changed, 2832 insertions(+), 183 deletions(-)

.plans/11-effect.md created+42
...@@ -0,0 +1,42 @@
1
2
3PR 1: Service contracts + error taxonomy
4Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs.
5Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each).
6No behavior change yet, just interfaces and compile-time wiring points.
7PR 2: CheckpointStore Effect adapter
8Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter).
9Map all thrown/Promise errors to tagged errors.
10Add service tests proving parity for isGitRepository, capture, restore, diff, prune.
11PR 3: CodexService Effect adapter
12Wrap current CodexAppServerManager behind CodexServiceLive (adapter).
13Convert public API to Effect return types with typed errors.
14Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API.
15PR 4: ProviderService Effect adapter
16Wrap current ProviderManager behind ProviderServiceLive (adapter).
17Provider methods become Effect methods with typed errors.
18Route emitted provider events through an Effect PubSub surface.
19PR 5: wsServer migration to Effect services
20Stop instantiating provider/codex classes directly in wsServer.
21Resolve ProviderService (and related services) from one runtime/layer graph.
22Keep WS contract behavior identical.
23PR 6: Native CheckpointStore implementation
24Refactor checkpoint internals from Promise/throws to native Effect.
25Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue).
26Keep adapter tests plus new failure-path tests.
27PR 7: Codex transport/RPC core as native Effect
28Split codex into scoped process layer + RPC request/response layer + session registry.
29Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics.
30Keep protocol behavior and ordering guarantees.
31PR 8: Codex protocol decoding hardening
32Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes.
33Map decode failures to typed tagged errors (with root cause).
34Add regression tests for malformed/partial protocol messages.
35PR 9: Native ProviderService orchestration
36Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies.
37Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services.
38Remove throw-based flow entirely from provider path.
39PR 10: Cleanup + deprecation removal
40Remove legacy class implementations/adapters once parity is proven.
41Finalize layer composition and startup graph docs.
42Add 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 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts";
23
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}
6970
70/**
71 * CheckpointCatalogPersistenceError - Metadata persistence/read failure.
72 */
73export 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}
132117
133export type CheckpointStoreError =118export type CheckpointStoreError =
134 | CheckpointValidationError
135 | CheckpointGitCommandError119 | CheckpointGitCommandError
136 | CheckpointUnavailableError120 | CheckpointUnavailableError
137 | CheckpointRepositoryError;121 | CheckpointRepositoryError;
138122
139export type CheckpointCatalogError =
140 | CheckpointValidationError
141 | CheckpointCatalogPersistenceError;
142
143export type CheckpointServiceError =123export type CheckpointServiceError =
144 | CheckpointStoreError124 | CheckpointStoreError
145 | CheckpointCatalogError125 | CheckpointMetadataRepositoryError
146 | CheckpointServiceValidationError126 | CheckpointServiceValidationError
147 | CheckpointSessionNotFoundError127 | CheckpointSessionNotFoundError
148 | CheckpointInvariantError;128 | CheckpointInvariantError;
apps/server/src/checkpointing/Layers/CheckpointService.test.ts created+404
...@@ -0,0 +1,404 @@
1import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2import os from "node:os";
3import path from "node:path";
4
5import type {
6 ProviderApprovalDecision,
7 ProviderEvent,
8 ProviderKind,
9 ProviderSendTurnInput,
10 ProviderSession,
11 ProviderSessionStartInput,
12 ProviderTurnStartResult,
13} from "@t3tools/contracts";
14import { NodeServices } from "@effect/platform-node";
15import { it, assert } from "@effect/vitest";
16import { Effect, Layer } from "effect";
17
18import { runGit as runGitProcess } from "../../git/Process.ts";
19import { CheckpointServiceLive } from "./CheckpointService.ts";
20import { CheckpointStoreLive } from "./CheckpointStore.ts";
21import { CheckpointService } from "../Services/CheckpointService.ts";
22import { CheckpointStore } from "../Services/CheckpointStore.ts";
23import {
24 ProviderAdapterSessionNotFoundError,
25 ProviderAdapterValidationError,
26 ProviderUnsupportedError,
27 type ProviderAdapterError,
28} from "../../provider/Errors.ts";
29import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts";
30import type {
31 ProviderAdapterShape,
32 ProviderThreadSnapshot,
33 ProviderThreadTurnSnapshot,
34} from "../../provider/Services/ProviderAdapter.ts";
35import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts";
36import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts";
37import { makeSqliteCheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts";
38import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts";
39
40const 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
46const 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
56function 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
69class 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
204function 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
239it.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
322it.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
344it.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 */
14import type {
15 ProviderCheckpoint,
16 ProviderGetCheckpointDiffInput,
17 ProviderListCheckpointsInput,
18 ProviderRevertToCheckpointInput,
19} from "@t3tools/contracts";
20import {
21 providerGetCheckpointDiffInputSchema,
22 providerListCheckpointsInputSchema,
23 providerRevertToCheckpointInputSchema,
24} from "@t3tools/contracts";
25import { Effect, Layer, Option, Ref, Semaphore } from "effect";
26
27import {
28 CheckpointInvariantError,
29 CheckpointRepositoryError,
30 CheckpointServiceValidationError,
31 CheckpointSessionNotFoundError,
32 CheckpointUnavailableError,
33 type CheckpointServiceError,
34} from "../Errors.ts";
35import {
36 CheckpointService,
37 type CaptureCurrentTurnInput,
38 type CheckpointServiceShape,
39 type InitializeCheckpointSessionInput,
40} from "../Services/CheckpointService.ts";
41import { CheckpointStore } from "../Services/CheckpointStore.ts";
42import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts";
43import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts";
44import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts";
45import type {
46 ProviderAdapterShape,
47 ProviderThreadSnapshot,
48 ProviderThreadTurnSnapshot,
49} from "../../provider/Services/ProviderAdapter.ts";
50import type { ProviderAdapterError } from "../../provider/Errors.ts";
51
52interface CheckpointCwdResolution {
53 readonly candidateCwd: string;
54 readonly checkpointCwd: Option.Option<string>;
55}
56
57const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
58
59function checkpointRefThreadSegment(threadId: string): string {
60 return Buffer.from(threadId, "utf8").toString("base64url");
61}
62
63function checkpointRefForThreadTurn(threadId: string, turnCount: number): string {
64 return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`;
65}
66
67function 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
74function asString(value: unknown): string | undefined {
75 return typeof value === "string" ? value : undefined;
76}
77
78function asArray(value: unknown): unknown[] {
79 return Array.isArray(value) ? value : [];
80}
81
82function 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
90function 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
124function 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
164function 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
198function rootCheckpoint(): ProviderCheckpoint {
199 return {
200 id: "root",
201 turnCount: 0,
202 messageCount: 0,
203 label: "Start of conversation",
204 isCurrent: true,
205 };
206}
207
208function 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
220function 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
232function 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
244function 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
262function 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
283function 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
299function 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
315function 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
331const 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
1008export 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 */
12import { mkdtemp, rm } from "node:fs/promises";
13import { randomUUID } from "node:crypto";
14import os from "node:os";
15import path from "node:path";
16
17import { Effect, Layer } from "effect";
18import { ChildProcessSpawner } from "effect/unstable/process";
19
20import {
21 CheckpointGitCommandError,
22 CheckpointRepositoryError,
23 CheckpointUnavailableError,
24 type CheckpointStoreError,
25} from "../Errors.ts";
26import { GitCommandError } from "../../git/Errors.ts";
27import { runGit, RunGitInput } from "../../git/Process.ts";
28import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
29
30function 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
40function 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
80function 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
99function 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
110function 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
130function 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
137const 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
418export 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 */
10import type { ProviderCheckpoint } from "@t3tools/contracts";
11import { Option, ServiceMap } from "effect";
12import type { Effect } from "effect";
13
14import type { CheckpointCatalogError } from "../Errors.ts";
15
16export 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
26export interface ListSessionCheckpointsInput {
27 readonly providerSessionId: string;
28}
29
30export interface GetCheckpointInput {
31 readonly providerSessionId: string;
32 readonly turnCount: number;
33}
34
35export interface DeleteAfterTurnInput {
36 readonly providerSessionId: string;
37 readonly maxTurnCount: number;
38}
39
40export interface DeleteAllForSessionInput {
41 readonly providerSessionId: string;
42}
43
44export 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 */
80export 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), and4 * Coordinates `CheckpointStore` (Git refs), `CheckpointRepository` (metadata), and
5 * provider timeline operations resolved through `ProviderSessionDirectory` and5 * 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>;
4951
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";
1717
18export interface CaptureCheckpointInput {18export interface CaptureCheckpointInput {
19 readonly cwd: string;19 readonly cwd: string;
20 readonly threadId: string;20 readonly checkpointRef: string;
21 readonly turnCount: number;
22}21}
2322
24export interface EnsureRootCheckpointInput {23export interface EnsureRootCheckpointInput {
25 readonly cwd: string;24 readonly cwd: string;
26 readonly threadId: string;25 readonly checkpointRef: string;
27}26}
2827
29export interface RestoreCheckpointInput {28export 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}
3433
35export interface DiffCheckpointsInput {34export 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}
4140
42export interface PruneAfterTurnInput {41export 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}
4745
48export interface CheckpointStoreShape {46export 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>;
5351
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>;
5858
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>;
6365
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>;
7779
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>;
8286
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}
8894
89/**95/**
90 * CheckpointStore - Service tag for checkpoint persistence and restore operations.96 * CheckpointStore - Service tag for checkpoint persistence and restore operations.
91 */97 */
92export class CheckpointStore extends ServiceMap.Service<98export class CheckpointStore extends ServiceMap.Service<CheckpointStore, CheckpointStoreShape>()(
93 CheckpointStore,99 "checkpointing/CheckpointStore",
94 CheckpointStoreShape100) {}
95>()("checkpointing/CheckpointStore") {}
apps/server/src/git/Errors.ts created+16
...@@ -0,0 +1,16 @@
1import { Schema } from "effect";
2
3/**
4 * GitCommandError - Git command execution failed.
5 */
6export 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 @@
1import { NodeServices } from "@effect/platform-node";
2import { it, assert } from "@effect/vitest";
3import { Effect } from "effect";
4
5import { GitCommandError } from "./Errors.ts";
6import { runGit } from "./Process.ts";
7
8it.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
21it.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
34it.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 */
9import { Effect, Option, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "./Errors.ts";
12
13const DEFAULT_TIMEOUT_MS = 30_000;
14const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
15
16export 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
26export interface GitRunResult {
27 readonly code: number;
28 readonly stdout: string;
29 readonly stderr: string;
30}
31
32function quoteGitCommand(args: ReadonlyArray<string>): string {
33 return `git ${args.join(" ")}`;
34}
35
36function 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
43function 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
57function 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
95export 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 @@
1import { Schema, SchemaIssue } from "effect";1import { Schema, SchemaIssue } from "effect";
22
3// ===============================
4// Core Persistence Errors
5// ===============================
6
3export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlError>()(7export 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}
4145
46export 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
55export 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
64export 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
73export 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
42export class ProjectValidationError extends Schema.TaggedErrorClass<ProjectValidationError>()(86export 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}
78122
123// ===============================
124// Checkpoint Repository Errors
125// ===============================
126
127export 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
140export 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
79export type ProjectRepositoryError =153export type ProjectRepositoryError =
80 | PersistenceSqlError154 | PersistenceSqlError
81 | PersistenceDecodeError155 | PersistenceDecodeError
...@@ -89,38 +163,6 @@ export type OrchestrationEventRepositoryError =...@@ -89,38 +163,6 @@ export type OrchestrationEventRepositoryError =
89 | PersistenceDecodeError163 | PersistenceDecodeError
90 | PersistenceSerializationError;164 | PersistenceSerializationError;
91165
92export function toPersistenceSqlError(operation: string) {166export 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
101export 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
110export 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
119export 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 @@
1import { mkdtemp, rm } from "node:fs/promises";
2import os from "node:os";
3import path from "node:path";
4
5import { it, assert } from "@effect/vitest";
6import { Effect, Layer, Option } from "effect";
7
8import { CheckpointRepository } from "../Services/Checkpoints.ts";
9import { CheckpointRepositoryLive } from "./Checkpoints.ts";
10import { makeSqlitePersistenceLive } from "./Sqlite.ts";
11
12const 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
18function makeCheckpointRepositoryLayer(dbPath: string) {
19 return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath)));
20}
21
22it.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 */
13import type { ProviderCheckpoint } from "@t3tools/contracts";
14import * as SqlClient from "effect/unstable/sql/SqlClient";
15import * as SqlSchema from "effect/unstable/sql/SqlSchema";
16import { Effect, Layer, Option, Schema } from "effect";
17
18import {
19 CheckpointRepositoryPersistenceError,
20 CheckpointRepositoryValidationError,
21 type CheckpointRepositoryError,
22} from "../Errors.ts";
23import { makeSqlitePersistenceLive } from "./Sqlite.ts";
24import {
25 CheckpointRepository,
26 type CheckpointRepositoryEntry,
27 type CheckpointRepositoryShape,
28} from "../Services/Checkpoints.ts";
29
30const NonEmptyString = Schema.NonEmptyString;
31
32const 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
44const SessionRequestSchema = Schema.Struct({
45 providerSessionId: NonEmptyString,
46});
47
48const 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
60const GetCheckpointRequestSchema = Schema.Struct({
61 providerSessionId: NonEmptyString,
62 turnCount: Schema.Int,
63});
64
65const DeleteAfterTurnRequestSchema = Schema.Struct({
66 providerSessionId: NonEmptyString,
67 maxTurnCount: Schema.Int,
68});
69
70function 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
77function 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
85function 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
95function 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
111function 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
122function 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
139function 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
150const 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
356export const CheckpointRepositoryLive = Layer.effect(
357 CheckpointRepository,
358 makeCheckpointRepository,
359);
360
361export 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 statically14// Import all migrations statically
15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
16import Migration0002 from "./Migrations/002_Projects.ts";16import Migration0002 from "./Migrations/002_Projects.ts";
17import Migration0003 from "./Migrations/003_Checkpoints.ts";
1718
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";
28const loader = Migrator.fromRecord({29const 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});
3234
33/**35/**
apps/server/src/persistence/Migrations/003_Checkpoints.ts created+26
...@@ -0,0 +1,26 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 */
10import type { ProviderCheckpoint } from "@t3tools/contracts";
11import { Option, ServiceMap } from "effect";
12import type { Effect } from "effect";
13
14import type { CheckpointRepositoryError } from "../Errors.ts";
15
16export 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
28export interface CheckpointRepositoryEntry extends ProviderCheckpoint {
29 readonly threadId: string;
30 readonly checkpointRef: string;
31 readonly createdAt: string;
32}
33
34export interface ListSessionCheckpointsInput {
35 readonly providerSessionId: string;
36}
37
38export interface GetCheckpointInput {
39 readonly providerSessionId: string;
40 readonly turnCount: number;
41}
42
43export interface DeleteAfterTurnInput {
44 readonly providerSessionId: string;
45 readonly maxTurnCount: number;
46}
47
48export interface DeleteAllForSessionInput {
49 readonly providerSessionId: string;
50}
51
52export 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 */
92export 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";
13import {13import {
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";
19import { CheckpointCatalog } from "../checkpointing/Services/CheckpointCatalog.ts";19import {
20 CheckpointRepositoryValidationError,
21 type CheckpointRepositoryError,
22} from "../persistence/Errors.ts";
20import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts";23import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts";
21import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts";24import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts";
25import { CheckpointRepository } from "../persistence/Services/Checkpoints.ts";
22import { CodexAdapter } from "./Services/CodexAdapter.ts";26import { CodexAdapter } from "./Services/CodexAdapter.ts";
23import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts";27import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts";
24import { ProviderService } from "./Services/ProviderService.ts";28import { 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 };
122128
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 });
227236
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 });
233242
234 expect(error._tag).toBe("CheckpointValidationError");243 expect(error._tag).toBe("CheckpointRepositoryValidationError");
235 });244 });
236});245});