| author | |
| committer | |
| log | 0488e47d9c0d0ca42595341b15c1d187ea7107c8 |
| tree | b169fc4300cff6445f944ded32c487296dc0e7ae |
| parent | 0eba548282b96ee420ad0b1a3f5d9d4487376b90 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 164 insertions(+), 52 deletions(-)
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+9-1| ... | ... | @@ -9,6 +9,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn |
| 9 | 9 | import { checkpointRefForThreadTurn } from "./Utils.ts"; |
| 10 | 10 | import * as CheckpointDiffQuery from "./CheckpointDiffQuery.ts"; |
| 11 | 11 | import * as CheckpointStore from "./CheckpointStore.ts"; |
| 12 | import { CheckpointThreadNotFoundError } from "./Errors.ts"; | |
| 12 | 13 | |
| 13 | 14 | function makeThreadCheckpointContext(input: { |
| 14 | 15 | readonly projectId: ProjectId; |
| ... | ... | @@ -412,7 +413,14 @@ describe("CheckpointDiffQuery.layer", () => { |
| 412 | 413 | }); |
| 413 | 414 | }).pipe(Effect.provide(layer), Effect.flip); |
| 414 | 415 | |
| 415 | expect(error.message).toContain("Thread 'thread-missing' not found."); | |
| 416 | expect(error).toBeInstanceOf(CheckpointThreadNotFoundError); | |
| 417 | expect(error).toMatchObject({ | |
| 418 | operation: "CheckpointDiffQuery.getTurnDiff", | |
| 419 | threadId, | |
| 420 | }); | |
| 421 | expect(error.message).toBe( | |
| 422 | "Checkpoint invariant violation in CheckpointDiffQuery.getTurnDiff: Thread 'thread-missing' not found.", | |
| 423 | ); | |
| 416 | 424 | }), |
| 417 | 425 | ); |
| 418 | 426 | }); |
apps/server/src/checkpointing/CheckpointDiffQuery.ts+40-29| ... | ... | @@ -22,7 +22,13 @@ import * as Option from "effect/Option"; |
| 22 | 22 | import * as Schema from "effect/Schema"; |
| 23 | 23 | |
| 24 | 24 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 25 | import { CheckpointInvariantError, CheckpointUnavailableError } from "./Errors.ts"; | |
| 25 | import { | |
| 26 | CheckpointDiffResultInvalidError, | |
| 27 | CheckpointRefUnavailableError, | |
| 28 | CheckpointThreadNotFoundError, | |
| 29 | CheckpointTurnRangeUnavailableError, | |
| 30 | CheckpointWorkspacePathMissingError, | |
| 31 | } from "./Errors.ts"; | |
| 26 | 32 | import type { CheckpointServiceError } from "./Errors.ts"; |
| 27 | 33 | import { checkpointRefForThreadTurn } from "./Utils.ts"; |
| 28 | 34 | import * as CheckpointStore from "./CheckpointStore.ts"; |
| ... | ... | @@ -92,9 +98,9 @@ export const make = Effect.gen(function* () { |
| 92 | 98 | diff: "", |
| 93 | 99 | }; |
| 94 | 100 | if (!isTurnDiffResult(emptyDiff)) { |
| 95 | return yield* new CheckpointInvariantError({ | |
| 101 | return yield* new CheckpointDiffResultInvalidError({ | |
| 96 | 102 | operation, |
| 97 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 103 | threadId: input.threadId, | |
| 98 | 104 | }); |
| 99 | 105 | } |
| 100 | 106 | return emptyDiff; |
| ... | ... | @@ -104,9 +110,9 @@ export const make = Effect.gen(function* () { |
| 104 | 110 | .getThreadCheckpointContext(input.threadId) |
| 105 | 111 | .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); |
| 106 | 112 | if (Option.isNone(threadContext)) { |
| 107 | return yield* new CheckpointInvariantError({ | |
| 113 | return yield* new CheckpointThreadNotFoundError({ | |
| 108 | 114 | operation, |
| 109 | detail: `Thread '${input.threadId}' not found.`, | |
| 115 | threadId: input.threadId, | |
| 110 | 116 | }); |
| 111 | 117 | } |
| 112 | 118 | |
| ... | ... | @@ -115,18 +121,19 @@ export const make = Effect.gen(function* () { |
| 115 | 121 | 0, |
| 116 | 122 | ); |
| 117 | 123 | if (input.toTurnCount > maxTurnCount) { |
| 118 | return yield* new CheckpointUnavailableError({ | |
| 124 | return yield* new CheckpointTurnRangeUnavailableError({ | |
| 125 | operation, | |
| 119 | 126 | threadId: input.threadId, |
| 120 | turnCount: input.toTurnCount, | |
| 121 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, | |
| 127 | requestedTurnCount: input.toTurnCount, | |
| 128 | availableTurnCount: maxTurnCount, | |
| 122 | 129 | }); |
| 123 | 130 | } |
| 124 | 131 | |
| 125 | 132 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; |
| 126 | 133 | if (!workspaceCwd) { |
| 127 | return yield* new CheckpointInvariantError({ | |
| 134 | return yield* new CheckpointWorkspacePathMissingError({ | |
| 128 | 135 | operation, |
| 129 | detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, | |
| 136 | threadId: input.threadId, | |
| 130 | 137 | }); |
| 131 | 138 | } |
| 132 | 139 | |
| ... | ... | @@ -137,10 +144,11 @@ export const make = Effect.gen(function* () { |
| 137 | 144 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, |
| 138 | 145 | )?.checkpointRef; |
| 139 | 146 | if (!fromCheckpointRef) { |
| 140 | return yield* new CheckpointUnavailableError({ | |
| 147 | return yield* new CheckpointRefUnavailableError({ | |
| 148 | operation, | |
| 141 | 149 | threadId: input.threadId, |
| 142 | 150 | turnCount: input.fromTurnCount, |
| 143 | detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, | |
| 151 | checkpoint: "from", | |
| 144 | 152 | }); |
| 145 | 153 | } |
| 146 | 154 | |
| ... | ... | @@ -148,10 +156,11 @@ export const make = Effect.gen(function* () { |
| 148 | 156 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, |
| 149 | 157 | )?.checkpointRef; |
| 150 | 158 | if (!toCheckpointRef) { |
| 151 | return yield* new CheckpointUnavailableError({ | |
| 159 | return yield* new CheckpointRefUnavailableError({ | |
| 160 | operation, | |
| 152 | 161 | threadId: input.threadId, |
| 153 | 162 | turnCount: input.toTurnCount, |
| 154 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 163 | checkpoint: "to", | |
| 155 | 164 | }); |
| 156 | 165 | } |
| 157 | 166 | |
| ... | ... | @@ -167,9 +176,9 @@ export const make = Effect.gen(function* () { |
| 167 | 176 | |
| 168 | 177 | const turnDiff = buildTurnDiffResult(input, diff); |
| 169 | 178 | if (!isTurnDiffResult(turnDiff)) { |
| 170 | return yield* new CheckpointInvariantError({ | |
| 179 | return yield* new CheckpointDiffResultInvalidError({ | |
| 171 | 180 | operation, |
| 172 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 181 | threadId: input.threadId, | |
| 173 | 182 | }); |
| 174 | 183 | } |
| 175 | 184 | |
| ... | ... | @@ -200,9 +209,9 @@ export const make = Effect.gen(function* () { |
| 200 | 209 | "", |
| 201 | 210 | ); |
| 202 | 211 | if (!isTurnDiffResult(emptyDiff)) { |
| 203 | return yield* new CheckpointInvariantError({ | |
| 212 | return yield* new CheckpointDiffResultInvalidError({ | |
| 204 | 213 | operation, |
| 205 | detail: "Computed full thread diff result does not satisfy contract schema.", | |
| 214 | threadId: input.threadId, | |
| 206 | 215 | }); |
| 207 | 216 | } |
| 208 | 217 | return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; |
| ... | ... | @@ -213,33 +222,35 @@ export const make = Effect.gen(function* () { |
| 213 | 222 | .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); |
| 214 | 223 | |
| 215 | 224 | if (Option.isNone(threadContext)) { |
| 216 | return yield* new CheckpointInvariantError({ | |
| 225 | return yield* new CheckpointThreadNotFoundError({ | |
| 217 | 226 | operation, |
| 218 | detail: `Thread '${input.threadId}' not found.`, | |
| 227 | threadId: input.threadId, | |
| 219 | 228 | }); |
| 220 | 229 | } |
| 221 | 230 | |
| 222 | 231 | if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { |
| 223 | return yield* new CheckpointUnavailableError({ | |
| 232 | return yield* new CheckpointTurnRangeUnavailableError({ | |
| 233 | operation, | |
| 224 | 234 | threadId: input.threadId, |
| 225 | turnCount: input.toTurnCount, | |
| 226 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, | |
| 235 | requestedTurnCount: input.toTurnCount, | |
| 236 | availableTurnCount: threadContext.value.latestCheckpointTurnCount, | |
| 227 | 237 | }); |
| 228 | 238 | } |
| 229 | 239 | |
| 230 | 240 | const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; |
| 231 | 241 | if (!workspaceCwd) { |
| 232 | return yield* new CheckpointInvariantError({ | |
| 242 | return yield* new CheckpointWorkspacePathMissingError({ | |
| 233 | 243 | operation, |
| 234 | detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, | |
| 244 | threadId: input.threadId, | |
| 235 | 245 | }); |
| 236 | 246 | } |
| 237 | 247 | |
| 238 | 248 | if (!threadContext.value.toCheckpointRef) { |
| 239 | return yield* new CheckpointUnavailableError({ | |
| 249 | return yield* new CheckpointRefUnavailableError({ | |
| 250 | operation, | |
| 240 | 251 | threadId: input.threadId, |
| 241 | 252 | turnCount: input.toTurnCount, |
| 242 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 253 | checkpoint: "to", | |
| 243 | 254 | }); |
| 244 | 255 | } |
| 245 | 256 | |
| ... | ... | @@ -262,9 +273,9 @@ export const make = Effect.gen(function* () { |
| 262 | 273 | diff, |
| 263 | 274 | ); |
| 264 | 275 | if (!isTurnDiffResult(turnDiff)) { |
| 265 | return yield* new CheckpointInvariantError({ | |
| 276 | return yield* new CheckpointDiffResultInvalidError({ | |
| 266 | 277 | operation, |
| 267 | detail: "Computed full thread diff result does not satisfy contract schema.", | |
| 278 | threadId: input.threadId, | |
| 268 | 279 | }); |
| 269 | 280 | } |
| 270 | 281 |
apps/server/src/checkpointing/Errors.test.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { ThreadId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { | |
| 5 | CheckpointRefUnavailableError, | |
| 6 | CheckpointTurnRangeUnavailableError, | |
| 7 | CheckpointWorkspacePathMissingError, | |
| 8 | } from "./Errors.ts"; | |
| 9 | ||
| 10 | const threadId = ThreadId.make("thread-1"); | |
| 11 | ||
| 12 | it("derives checkpoint messages from structured context", () => { | |
| 13 | const range = new CheckpointTurnRangeUnavailableError({ | |
| 14 | operation: "CheckpointDiffQuery.getTurnDiff", | |
| 15 | threadId, | |
| 16 | requestedTurnCount: 4, | |
| 17 | availableTurnCount: 2, | |
| 18 | }); | |
| 19 | const checkpoint = new CheckpointRefUnavailableError({ | |
| 20 | operation: "CheckpointDiffQuery.getTurnDiff", | |
| 21 | threadId, | |
| 22 | turnCount: 2, | |
| 23 | checkpoint: "to", | |
| 24 | }); | |
| 25 | const workspace = new CheckpointWorkspacePathMissingError({ | |
| 26 | operation: "CheckpointDiffQuery.getFullThreadDiff", | |
| 27 | threadId, | |
| 28 | }); | |
| 29 | ||
| 30 | expect(range.message).toBe( | |
| 31 | "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", | |
| 32 | ); | |
| 33 | expect(checkpoint.message).toBe( | |
| 34 | "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", | |
| 35 | ); | |
| 36 | expect(workspace.message).toBe( | |
| 37 | "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", | |
| 38 | ); | |
| 39 | }); |
apps/server/src/checkpointing/Errors.ts+76-22| ... | ... | @@ -1,40 +1,94 @@ |
| 1 | import { NonNegativeInt, ThreadId, type VcsError } from "@t3tools/contracts"; | |
| 1 | 2 | import * as Schema from "effect/Schema"; |
| 3 | ||
| 2 | 4 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; |
| 3 | import type { VcsError } from "@t3tools/contracts"; | |
| 4 | 5 | |
| 5 | /** | |
| 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. | |
| 7 | */ | |
| 8 | export class CheckpointUnavailableError extends Schema.TaggedErrorClass<CheckpointUnavailableError>()( | |
| 9 | "CheckpointUnavailableError", | |
| 6 | export const CheckpointDiffOperation = Schema.Literals([ | |
| 7 | "CheckpointDiffQuery.getTurnDiff", | |
| 8 | "CheckpointDiffQuery.getFullThreadDiff", | |
| 9 | ]); | |
| 10 | export type CheckpointDiffOperation = typeof CheckpointDiffOperation.Type; | |
| 11 | ||
| 12 | /** The computed result does not satisfy the checkpoint RPC contract. */ | |
| 13 | export class CheckpointDiffResultInvalidError extends Schema.TaggedErrorClass<CheckpointDiffResultInvalidError>()( | |
| 14 | "CheckpointDiffResultInvalidError", | |
| 15 | { | |
| 16 | operation: CheckpointDiffOperation, | |
| 17 | threadId: ThreadId, | |
| 18 | }, | |
| 19 | ) { | |
| 20 | override get message(): string { | |
| 21 | const result = | |
| 22 | this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; | |
| 23 | return `Checkpoint invariant violation in ${this.operation}: Computed ${result} result does not satisfy contract schema.`; | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | /** Projection state no longer contains the requested checkpoint thread. */ | |
| 28 | export class CheckpointThreadNotFoundError extends Schema.TaggedErrorClass<CheckpointThreadNotFoundError>()( | |
| 29 | "CheckpointThreadNotFoundError", | |
| 30 | { | |
| 31 | operation: CheckpointDiffOperation, | |
| 32 | threadId: ThreadId, | |
| 33 | }, | |
| 34 | ) { | |
| 35 | override get message(): string { | |
| 36 | return `Checkpoint invariant violation in ${this.operation}: Thread '${this.threadId}' not found.`; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | /** The checkpoint thread has no workspace path from which to compute a diff. */ | |
| 41 | export class CheckpointWorkspacePathMissingError extends Schema.TaggedErrorClass<CheckpointWorkspacePathMissingError>()( | |
| 42 | "CheckpointWorkspacePathMissingError", | |
| 43 | { | |
| 44 | operation: CheckpointDiffOperation, | |
| 45 | threadId: ThreadId, | |
| 46 | }, | |
| 47 | ) { | |
| 48 | override get message(): string { | |
| 49 | const diff = | |
| 50 | this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; | |
| 51 | return `Checkpoint invariant violation in ${this.operation}: Workspace path missing for thread '${this.threadId}' when computing ${diff}.`; | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | /** The requested turn lies beyond the latest available checkpoint. */ | |
| 56 | export class CheckpointTurnRangeUnavailableError extends Schema.TaggedErrorClass<CheckpointTurnRangeUnavailableError>()( | |
| 57 | "CheckpointTurnRangeUnavailableError", | |
| 10 | 58 | { |
| 11 | threadId: Schema.String, | |
| 12 | turnCount: Schema.Number, | |
| 13 | detail: Schema.String, | |
| 14 | cause: Schema.optional(Schema.Defect()), | |
| 59 | operation: CheckpointDiffOperation, | |
| 60 | threadId: ThreadId, | |
| 61 | requestedTurnCount: NonNegativeInt, | |
| 62 | availableTurnCount: NonNegativeInt, | |
| 15 | 63 | }, |
| 16 | 64 | ) { |
| 17 | 65 | override get message(): string { |
| 18 | return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; | |
| 66 | return `Checkpoint unavailable for thread ${this.threadId} turn ${this.requestedTurnCount}: Turn diff range exceeds current turn count: requested ${this.requestedTurnCount}, current ${this.availableTurnCount}.`; | |
| 19 | 67 | } |
| 20 | 68 | } |
| 21 | 69 | |
| 22 | /** | |
| 23 | * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. | |
| 24 | */ | |
| 25 | export class CheckpointInvariantError extends Schema.TaggedErrorClass<CheckpointInvariantError>()( | |
| 26 | "CheckpointInvariantError", | |
| 70 | /** Expected checkpoint metadata does not contain the requested Git ref. */ | |
| 71 | export class CheckpointRefUnavailableError extends Schema.TaggedErrorClass<CheckpointRefUnavailableError>()( | |
| 72 | "CheckpointRefUnavailableError", | |
| 27 | 73 | { |
| 28 | operation: Schema.String, | |
| 29 | detail: Schema.String, | |
| 30 | cause: Schema.optional(Schema.Defect()), | |
| 74 | operation: CheckpointDiffOperation, | |
| 75 | threadId: ThreadId, | |
| 76 | turnCount: NonNegativeInt, | |
| 77 | checkpoint: Schema.Literals(["from", "to"]), | |
| 31 | 78 | }, |
| 32 | 79 | ) { |
| 33 | 80 | override get message(): string { |
| 34 | return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; | |
| 81 | return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: Checkpoint ref is unavailable for turn ${this.turnCount}.`; | |
| 35 | 82 | } |
| 36 | 83 | } |
| 37 | 84 | |
| 38 | export type CheckpointStoreError = VcsError | CheckpointInvariantError | CheckpointUnavailableError; | |
| 85 | export type CheckpointStoreError = VcsError; | |
| 39 | 86 | |
| 40 | export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; | |
| 87 | export type CheckpointServiceError = | |
| 88 | | CheckpointStoreError | |
| 89 | | ProjectionRepositoryError | |
| 90 | | CheckpointDiffResultInvalidError | |
| 91 | | CheckpointThreadNotFoundError | |
| 92 | | CheckpointWorkspacePathMissingError | |
| 93 | | CheckpointTurnRangeUnavailableError | |
| 94 | | CheckpointRefUnavailableError; |