| author | |
| committer | |
| log | 7307079d4063a5c546a4c277dcc3b6594b41a846 |
| tree | 5aaf1103261db502f6b8f0136afb54dd65989ff6 |
| parent | 23861cf3002a7b15a30968de6edd80ebefdefa86 |
30 files changed, 1901 insertions(+), 808 deletions(-)
.plans/15-effect-server.md+10-1| ... | @@ -1 +1,10 @@ | ... | @@ -1 +1,10 @@ |
| 1 | Rewrite `createServer` and `index.ts` to be Effect native. | ||
| \ No newline at end of file | |||
| 1 | Rewrite `createServer` and `index.ts` to be Effect native. | ||
| 2 | |||
| 3 | Maybe use `effect/unstable/Socket` for the web socket server | ||
| 4 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts | ||
| 5 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts | ||
| 6 | |||
| 7 | - Migrate remaining runtime code to Effect | ||
| 8 | - `gitManager` -> `src/git` | ||
| 9 | - `terminalManager` -> `src/terminal` (Manager + PTY) | ||
| 10 | - ... | ||
| \ No newline at end of file | |||
.vscode/settings.json+2-1| ... | @@ -4,5 +4,6 @@ | ... | @@ -4,5 +4,6 @@ |
| 4 | "editor.codeActionsOnSave": { | 4 | "editor.codeActionsOnSave": { |
| 5 | "source.fixAll.oxc": "always" | 5 | "source.fixAll.oxc": "always" |
| 6 | }, | 6 | }, |
| 7 | "oxc.unusedDisableDirectives": "warn" | 7 | "oxc.unusedDisableDirectives": "warn", |
| 8 | "typescript.tsdk": "node_modules/typescript/lib" | ||
| 8 | } | 9 | } |
AGENTS.md+2| ... | @@ -41,3 +41,5 @@ Docs: | ... | @@ -41,3 +41,5 @@ Docs: |
| 41 | - Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor | 41 | - Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor |
| 42 | 42 | ||
| 43 | Use these as implementation references when designing protocol handling, UX flows, and operational safeguards. | 43 | Use these as implementation references when designing protocol handling, UX flows, and operational safeguards. |
| 44 | |||
| 45 | <!-- Dummy edit marker for workflow testing. --> |
SPEC.md created+569| ... | @@ -0,0 +1,569 @@ | ||
| 1 | # Orchestration Interface Spec (Required State) | ||
| 2 | |||
| 3 | Status: Required target architecture (not a description of current behavior). | ||
| 4 | |||
| 5 | ## 1. Identifier Schema (Branded) | ||
| 6 | |||
| 7 | All identifiers are branded and never plain `string` in schema definitions. | ||
| 8 | |||
| 9 | ```ts | ||
| 10 | const ThreadId = Schema.String.pipe(Schema.brand("ThreadId")); | ||
| 11 | const ProjectId = Schema.String.pipe(Schema.brand("ProjectId")); | ||
| 12 | const CommandId = Schema.String.pipe(Schema.brand("CommandId")); | ||
| 13 | const EventId = Schema.String.pipe(Schema.brand("EventId")); | ||
| 14 | const MessageId = Schema.String.pipe(Schema.brand("MessageId")); | ||
| 15 | const TurnId = Schema.String.pipe(Schema.brand("TurnId")); | ||
| 16 | |||
| 17 | const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId")); | ||
| 18 | const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId")); | ||
| 19 | const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId")); | ||
| 20 | const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); | ||
| 21 | const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId")); | ||
| 22 | |||
| 23 | const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef")); | ||
| 24 | ``` | ||
| 25 | |||
| 26 | Rules: | ||
| 27 | |||
| 28 | - `ThreadId` is app/orchestration thread identity. | ||
| 29 | - `ProviderThreadId` is provider runtime thread identity. | ||
| 30 | - They are never assigned to each other. | ||
| 31 | - `ProviderSessionId` is internal server/provider routing identity; clients do not send it. | ||
| 32 | - use e.g. `ThreadId.make(randomUUID())` to create new entity ids at the source (e.g. client create thread id and send to server, provider adapter creates the provider thread id from the codex app server etc) | ||
| 33 | |||
| 34 | ## 2. Client Commands (Domain Commands) | ||
| 35 | |||
| 36 | All commands are sent through one RPC: `orchestration.dispatchCommand`. | ||
| 37 | |||
| 38 | Output for every command: `DispatchResultSchema = { sequence: number }`. | ||
| 39 | |||
| 40 | ### 2.1 Client-Dispatchable Commands | ||
| 41 | |||
| 42 | 1. `project.create` | ||
| 43 | |||
| 44 | - Input: | ||
| 45 | - `commandId: CommandId` | ||
| 46 | - `projectId: ProjectId` | ||
| 47 | - `title: string` | ||
| 48 | - `workspaceRoot: string` | ||
| 49 | - `defaultModel?: string` | ||
| 50 | - `createdAt: IsoDateTime` | ||
| 51 | - Output: `DispatchResult` | ||
| 52 | |||
| 53 | 2. `project.meta.update` | ||
| 54 | |||
| 55 | - Input: | ||
| 56 | - `commandId: CommandId` | ||
| 57 | - `projectId: ProjectId` | ||
| 58 | - optional `{ title, workspaceRoot, defaultModel }` | ||
| 59 | - Output: `DispatchResult` | ||
| 60 | |||
| 61 | 3. `project.delete` | ||
| 62 | |||
| 63 | - Input: `commandId`, `projectId` | ||
| 64 | - Output: `DispatchResult` | ||
| 65 | |||
| 66 | 4. `thread.create` | ||
| 67 | |||
| 68 | - Input: | ||
| 69 | - `commandId: CommandId` | ||
| 70 | - `threadId: ThreadId` | ||
| 71 | - `projectId: ProjectId` | ||
| 72 | - `title: string` | ||
| 73 | - `model: string` | ||
| 74 | - `branch: string | null` | ||
| 75 | - `worktreePath: string | null` | ||
| 76 | - `createdAt: IsoDateTime` | ||
| 77 | - Output: `DispatchResult` | ||
| 78 | |||
| 79 | 5. `thread.delete` | ||
| 80 | |||
| 81 | - Input: `commandId`, `threadId` | ||
| 82 | - Output: `DispatchResult` | ||
| 83 | |||
| 84 | 6. `thread.meta.update` | ||
| 85 | |||
| 86 | - Input: `commandId`, `threadId`, optional `{ title, model, branch, worktreePath }` | ||
| 87 | - Output: `DispatchResult` | ||
| 88 | |||
| 89 | 7. `thread.turn.start` | ||
| 90 | |||
| 91 | - Input: | ||
| 92 | - `commandId` | ||
| 93 | - `threadId` | ||
| 94 | - `message`: | ||
| 95 | - `messageId: MessageId` | ||
| 96 | - `role: "user"` | ||
| 97 | - `text: string` | ||
| 98 | - `attachments: ChatAttachment[]` (schema-shared) | ||
| 99 | - `model?: string` | ||
| 100 | - `effort?: string` | ||
| 101 | - `createdAt` | ||
| 102 | - Output: `DispatchResult` | ||
| 103 | |||
| 104 | 8. `thread.turn.interrupt` | ||
| 105 | |||
| 106 | - Input: `commandId`, `threadId`, optional `turnId: TurnId`, `createdAt` | ||
| 107 | - Output: `DispatchResult` | ||
| 108 | |||
| 109 | 9. `thread.approval.respond` | ||
| 110 | |||
| 111 | - Input: | ||
| 112 | - `commandId` | ||
| 113 | - `threadId` | ||
| 114 | - `requestId: ApprovalRequestId` | ||
| 115 | - `decision: "accept" | "acceptForSession" | "decline" | "cancel"` | ||
| 116 | - `createdAt` | ||
| 117 | - Output: `DispatchResult` | ||
| 118 | |||
| 119 | 10. `thread.checkpoint.revert` | ||
| 120 | |||
| 121 | - Input: `commandId`, `threadId`, `turnCount: number`, `createdAt` | ||
| 122 | - Output: `DispatchResult` | ||
| 123 | |||
| 124 | 11. `thread.session.stop` | ||
| 125 | |||
| 126 | - Input: `commandId`, `threadId`, `createdAt` | ||
| 127 | - Output: `DispatchResult` | ||
| 128 | |||
| 129 | ### 2.2 Internal-Only Commands (Not Client Dispatchable) | ||
| 130 | |||
| 131 | 1. `thread.session.set` | ||
| 132 | |||
| 133 | - Server-owned projection update for session lifecycle. | ||
| 134 | |||
| 135 | 2. `thread.message.assistant.delta` | ||
| 136 | |||
| 137 | - Server-owned incremental assistant content append. | ||
| 138 | |||
| 139 | 3. `thread.message.assistant.complete` | ||
| 140 | |||
| 141 | - Server-owned assistant message completion marker. | ||
| 142 | |||
| 143 | 4. `thread.turn.diff.complete` | ||
| 144 | |||
| 145 | - Server-owned checkpoint diff summary write. | ||
| 146 | |||
| 147 | 5. `thread.activity.append` | ||
| 148 | |||
| 149 | - Server-owned activity feed append. | ||
| 150 | |||
| 151 | ## 3. Client RPC (Required) | ||
| 152 | |||
| 153 | These are the required client-facing RPC methods. | ||
| 154 | |||
| 155 | 1. `orchestration.getSnapshot` | ||
| 156 | |||
| 157 | - Input: `{}` | ||
| 158 | - Output: `OrchestrationReadModel` | ||
| 159 | |||
| 160 | 2. `orchestration.dispatchCommand` | ||
| 161 | |||
| 162 | - Input: `ClientOrchestrationCommand` (`Schema.Union` of all client-dispatchable commands) | ||
| 163 | - Output: `DispatchResult` | ||
| 164 | |||
| 165 | 3. `orchestration.getTurnDiff` | ||
| 166 | |||
| 167 | - Input: | ||
| 168 | - `threadId: ThreadId` | ||
| 169 | - `fromTurnCount: number` | ||
| 170 | - `toTurnCount: number` | ||
| 171 | - Output: | ||
| 172 | - `threadId: ThreadId` | ||
| 173 | - `fromTurnCount: number` | ||
| 174 | - `toTurnCount: number` | ||
| 175 | - `diff: string` | ||
| 176 | |||
| 177 | 4. `orchestration.replayEvents` (optional operational/debug API) | ||
| 178 | |||
| 179 | - Input: `{ fromSequenceExclusive: number }` | ||
| 180 | - Output: `OrchestrationEvent[]` | ||
| 181 | |||
| 182 | ### 3.1 RPC To Remove | ||
| 183 | |||
| 184 | Remove from client boundary: | ||
| 185 | |||
| 186 | - `providers.startSession` | ||
| 187 | - `providers.sendTurn` | ||
| 188 | - `providers.interruptTurn` | ||
| 189 | - `providers.respondToRequest` | ||
| 190 | - `providers.stopSession` | ||
| 191 | - `providers.listCheckpoints` | ||
| 192 | - `providers.getCheckpointDiff` | ||
| 193 | - `providers.revertToCheckpoint` | ||
| 194 | |||
| 195 | Rationale: | ||
| 196 | |||
| 197 | - Session/turn/checkpoint control belongs to orchestrator command path. | ||
| 198 | - Snapshot already includes checkpoint summaries (`checkpoints`); only full textual diff needs explicit query RPC. | ||
| 199 | |||
| 200 | ## 4. Server Orchestrator Command <-> Event Schemas | ||
| 201 | |||
| 202 | Each command deterministically yields one or more domain events that are persisted to the event store. | ||
| 203 | |||
| 204 | 1. `project.create` -> `project.created` | ||
| 205 | 2. `project.meta.update` -> `project.meta-updated` | ||
| 206 | 3. `project.delete` -> `project.deleted` | ||
| 207 | 4. `thread.create` -> `thread.created` | ||
| 208 | 5. `thread.delete` -> `thread.deleted` | ||
| 209 | 6. `thread.meta.update` -> `thread.meta-updated` | ||
| 210 | 7. `thread.turn.start` -> | ||
| 211 | |||
| 212 | - `thread.message-sent` (user message append) | ||
| 213 | - `thread.turn-start-requested` | ||
| 214 | - then (async side effect success) `thread.session-set` + provider-driven message/turn events | ||
| 215 | |||
| 216 | 8. `thread.turn.interrupt` -> `thread.turn-interrupt-requested` | ||
| 217 | 9. `thread.approval.respond` -> `thread.approval-response-requested` | ||
| 218 | 10. `thread.checkpoint.revert` -> `thread.checkpoint-revert-requested` then `thread.reverted` | ||
| 219 | 11. `thread.session.stop` -> `thread.session-stop-requested` then `thread.session-set` | ||
| 220 | |||
| 221 | Internal command/event path: | ||
| 222 | |||
| 223 | - `thread.session.set` -> `thread.session-set` | ||
| 224 | - `thread.message.assistant.delta` -> `thread.message-sent` (assistant streaming) | ||
| 225 | - `thread.message.assistant.complete` -> `thread.message-sent` (streaming false) | ||
| 226 | - `thread.turn.diff.complete` -> `thread.turn-diff-completed` | ||
| 227 | - `thread.activity.append` -> `thread.activity-appended` | ||
| 228 | |||
| 229 | ## 5. Orchestration Events <-> Generic Provider Events <-> Raw Provider Events | ||
| 230 | |||
| 231 | Required mapping pipeline: | ||
| 232 | |||
| 233 | `Raw Provider Event (adapter-specific)` | ||
| 234 | -> `GenericProviderRuntimeEvent` (provider-agnostic schema) | ||
| 235 | -> `Internal Orchestration Command` | ||
| 236 | -> `Orchestration Domain Event` | ||
| 237 | |||
| 238 | ### 5.1 Mapping Table for Codex Adapter | ||
| 239 | |||
| 240 | 1. Raw `thread/started` | ||
| 241 | |||
| 242 | - Generic: `thread.started` | ||
| 243 | - Internal command: `thread.session.set` (attach `ProviderThreadId`, status `ready|running`) | ||
| 244 | - Domain event: `thread.session-set` | ||
| 245 | |||
| 246 | 2. Raw `turn/started` | ||
| 247 | |||
| 248 | - Generic: `turn.started` | ||
| 249 | - Internal command: `thread.session.set` (status `running`, active turn) | ||
| 250 | - Domain event: `thread.session-set` | ||
| 251 | |||
| 252 | 3. Raw `item/agentMessage/delta` | ||
| 253 | |||
| 254 | - Generic: `message.delta` | ||
| 255 | - Internal command: `thread.message.assistant.delta` | ||
| 256 | - Domain event: `thread.message-sent` (assistant, streaming) | ||
| 257 | |||
| 258 | 4. Raw `item/completed` (agent message) | ||
| 259 | |||
| 260 | - Generic: `message.completed` | ||
| 261 | - Internal command: `thread.message.assistant.complete` | ||
| 262 | - Domain event: `thread.message-sent` (assistant, completed) | ||
| 263 | |||
| 264 | 5. Raw `turn/completed` | ||
| 265 | |||
| 266 | - Generic: `turn.completed` | ||
| 267 | - Internal commands: | ||
| 268 | - `thread.session.set` (ready/error) | ||
| 269 | - `thread.activity.append` (optional summary) | ||
| 270 | - Domain events: `thread.session-set`, `thread.activity-appended` | ||
| 271 | |||
| 272 | 6. Raw approval request | ||
| 273 | |||
| 274 | - Generic: `approval.requested` | ||
| 275 | - Internal command: `thread.activity.append` (typed `requestId`, `requestKind`) | ||
| 276 | - Domain event: `thread.activity-appended` | ||
| 277 | |||
| 278 | 7. Raw approval decision ack | ||
| 279 | |||
| 280 | - Generic: `approval.resolved` | ||
| 281 | - Internal command: `thread.activity.append` | ||
| 282 | - Domain event: `thread.activity-appended` | ||
| 283 | |||
| 284 | 8. Raw tool start/complete | ||
| 285 | |||
| 286 | - Generic: `tool.started` / `tool.completed` | ||
| 287 | - Internal command: `thread.activity.append` | ||
| 288 | - Domain event: `thread.activity-appended` | ||
| 289 | |||
| 290 | 9. Checkpoint capture (server-produced) | ||
| 291 | |||
| 292 | - Generic: `checkpoint.captured` | ||
| 293 | - Internal command: `thread.turn.diff.complete` | ||
| 294 | - Domain event: `thread.turn-diff-completed` | ||
| 295 | |||
| 296 | 10. Raw/runtime error | ||
| 297 | |||
| 298 | - Generic: `runtime.error` | ||
| 299 | - Internal commands: | ||
| 300 | - `thread.session.set` (status `error`) | ||
| 301 | - `thread.activity.append` (`tone=error`) | ||
| 302 | - Domain events: `thread.session-set`, `thread.activity-appended` | ||
| 303 | |||
| 304 | ## 6. Schema Section (Boundary + Reuse) | ||
| 305 | |||
| 306 | ### 6.1 Shared Schemas (contracts package) | ||
| 307 | |||
| 308 | Expose to both web and server: | ||
| 309 | |||
| 310 | - `OrchestrationReadModelSchema` | ||
| 311 | - `ClientOrchestrationCommandSchema` | ||
| 312 | - `OrchestrationEventSchema` | ||
| 313 | - `OrchestrationRpcSchemas` (`getSnapshot`, `dispatchCommand`, `getTurnDiff`, optional replay) | ||
| 314 | - `GenericProviderRuntimeEventSchema` (used for diagnostics/ingestion tests) | ||
| 315 | - Shared message/activity/checkpoint summary schemas used in snapshot | ||
| 316 | |||
| 317 | ### 6.2 Server-Internal Schemas | ||
| 318 | |||
| 319 | Not exposed to web: | ||
| 320 | |||
| 321 | - `InternalOrchestrationCommandSchema` | ||
| 322 | - Adapter raw-event schemas and provider-specific payload schemas | ||
| 323 | - Provider session directory records and persistence-only row schemas | ||
| 324 | - Git service cache/query schemas (non-orchestration read model) | ||
| 325 | |||
| 326 | ### 6.3 Reuse Requirements | ||
| 327 | |||
| 328 | - Snapshot checkpoint summary schema is source of truth for checkpoint list UI. | ||
| 329 | - `getTurnDiff` response reuses `ThreadId`/turn count brands and textual `diff` only. | ||
| 330 | - Activity schemas are reused for approval/tool/runtime annotations; no separate UI-only structure. | ||
| 331 | - Session schema must include both `threadId: ThreadId` and optional `providerThreadId: ProviderThreadId` (explicitly distinct). | ||
| 332 | |||
| 333 | ## 7. Persistence Model (Required) | ||
| 334 | |||
| 335 | This section defines required persisted tables, the canonical persisted event envelope, and required projected/read tables. | ||
| 336 | |||
| 337 | ### 7.1 Write-Side Persisted Tables | ||
| 338 | |||
| 339 | 1. `orchestration_events` (append-only event store) | ||
| 340 | |||
| 341 | - `sequence: number` (global monotonic sequence, primary key) | ||
| 342 | - `eventId: EventId` (unique) | ||
| 343 | - `aggregateKind: "project" | "thread"` | ||
| 344 | - `streamId: ProjectId | ThreadId` (aggregate stream id) | ||
| 345 | - `streamVersion: number` (per-aggregate monotonic version) | ||
| 346 | - `eventType: OrchestrationEventType` | ||
| 347 | - `occurredAt: IsoDateTime` | ||
| 348 | - `commandId: CommandId | null` | ||
| 349 | - `causationEventId: EventId | null` | ||
| 350 | - `correlationId: CommandId | null` | ||
| 351 | - `actorKind: "client" | "server" | "provider"` | ||
| 352 | - `payload: OrchestrationEventPayload` (type-validated by `eventType`) | ||
| 353 | - `metadata: OrchestrationEventMetadata` | ||
| 354 | |||
| 355 | 2. `orchestration_command_receipts` (idempotency + ack replay) | ||
| 356 | |||
| 357 | - `commandId: CommandId` (primary key) | ||
| 358 | - `aggregateKind: "project" | "thread"` | ||
| 359 | - `aggregateId: ProjectId | ThreadId` | ||
| 360 | - `acceptedAt: IsoDateTime` | ||
| 361 | - `resultSequence: number` | ||
| 362 | - `status: "accepted" | "rejected"` | ||
| 363 | - `error: string | null` | ||
| 364 | |||
| 365 | 3. `checkpoint_diff_blobs` (large plaintext diffs; separate from summaries) | ||
| 366 | |||
| 367 | - `threadId: ThreadId` | ||
| 368 | - `fromTurnCount: number` | ||
| 369 | - `toTurnCount: number` | ||
| 370 | - `diff: string` | ||
| 371 | - `createdAt: IsoDateTime` | ||
| 372 | - unique key: `(threadId, fromTurnCount, toTurnCount)` | ||
| 373 | |||
| 374 | 4. `provider_session_runtime` (server-internal adapter resume state) | ||
| 375 | |||
| 376 | - `providerSessionId: ProviderSessionId` (primary key) | ||
| 377 | - `threadId: ThreadId` | ||
| 378 | - `providerName: string` | ||
| 379 | - `adapterKey: string` | ||
| 380 | - `providerThreadId: ProviderThreadId | null` | ||
| 381 | - `status: "starting" | "running" | "stopped" | "error"` | ||
| 382 | - `lastSeenAt: IsoDateTime` | ||
| 383 | - `resumeCursor: JsonValue | null` (adapter-specific opaque state) | ||
| 384 | - `runtimePayload: JsonValue | null` (adapter-specific opaque state) | ||
| 385 | |||
| 386 | ### 7.2 Canonical Persisted Event Schema | ||
| 387 | |||
| 388 | `OrchestrationPersistedEventSchema` (full envelope): | ||
| 389 | |||
| 390 | ```ts | ||
| 391 | type OrchestrationPersistedEvent = { | ||
| 392 | sequence: number; | ||
| 393 | eventId: EventId; | ||
| 394 | aggregateKind: "project" | "thread"; | ||
| 395 | streamId: ProjectId | ThreadId; | ||
| 396 | streamVersion: number; | ||
| 397 | eventType: OrchestrationEventType; | ||
| 398 | occurredAt: IsoDateTime; | ||
| 399 | commandId: CommandId | null; | ||
| 400 | causationEventId: EventId | null; | ||
| 401 | correlationId: CommandId | null; | ||
| 402 | actorKind: "client" | "server" | "provider"; | ||
| 403 | payload: OrchestrationEventPayload; | ||
| 404 | metadata: { | ||
| 405 | providerSessionId?: ProviderSessionId; | ||
| 406 | providerThreadId?: ProviderThreadId; | ||
| 407 | providerTurnId?: ProviderTurnId; | ||
| 408 | providerItemId?: ProviderItemId; | ||
| 409 | adapterKey?: string; | ||
| 410 | requestId?: ApprovalRequestId; | ||
| 411 | ingestedAt?: IsoDateTime; | ||
| 412 | }; | ||
| 413 | }; | ||
| 414 | ``` | ||
| 415 | |||
| 416 | Rules: | ||
| 417 | |||
| 418 | - `payload` must be schema-discriminated by `eventType`. | ||
| 419 | - provider identifiers only appear in `metadata` and provider-specific payload sub-shapes; they do not replace `ThreadId`. | ||
| 420 | - `streamVersion` is concurrency guard for aggregate writes. | ||
| 421 | |||
| 422 | ### 7.3 Required Projected Tables (Read Models) | ||
| 423 | |||
| 424 | 1. `projection_projects` | ||
| 425 | |||
| 426 | - `projectId: ProjectId` (primary key) | ||
| 427 | - `title: string` | ||
| 428 | - `workspaceRoot: string` | ||
| 429 | - `defaultModel: string | null` | ||
| 430 | - `createdAt: IsoDateTime` | ||
| 431 | - `updatedAt: IsoDateTime` | ||
| 432 | - `deletedAt: IsoDateTime | null` | ||
| 433 | |||
| 434 | 2. `projection_threads` | ||
| 435 | |||
| 436 | - `threadId: ThreadId` (primary key) | ||
| 437 | - `projectId: ProjectId` | ||
| 438 | - `title: string` | ||
| 439 | - `model: string` | ||
| 440 | - `branch: string | null` | ||
| 441 | - `worktreePath: string | null` | ||
| 442 | - `latestTurnId: TurnId | null` | ||
| 443 | - `createdAt: IsoDateTime` | ||
| 444 | - `updatedAt: IsoDateTime` | ||
| 445 | - `deletedAt: IsoDateTime | null` | ||
| 446 | |||
| 447 | 3. `projection_thread_messages` | ||
| 448 | |||
| 449 | - `messageId: MessageId` (primary key) | ||
| 450 | - `threadId: ThreadId` | ||
| 451 | - `turnId: TurnId | null` | ||
| 452 | - `role: "user" | "assistant" | "system"` | ||
| 453 | - `text: string` | ||
| 454 | - `isStreaming: boolean` | ||
| 455 | - `createdAt: IsoDateTime` | ||
| 456 | - `updatedAt: IsoDateTime` | ||
| 457 | |||
| 458 | 4. `projection_thread_activities` | ||
| 459 | |||
| 460 | - `activityId: EventId` (primary key; derived from source event) | ||
| 461 | - `threadId: ThreadId` | ||
| 462 | - `turnId: TurnId | null` | ||
| 463 | - `tone: "info" | "tool" | "approval" | "error"` | ||
| 464 | - `kind: string` | ||
| 465 | - `summary: string` | ||
| 466 | - `payload: JsonValue` | ||
| 467 | - `createdAt: IsoDateTime` | ||
| 468 | |||
| 469 | 5. `projection_thread_sessions` | ||
| 470 | |||
| 471 | - `threadId: ThreadId` (primary key) | ||
| 472 | - `status: "idle" | "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"` | ||
| 473 | - `providerName: string | null` | ||
| 474 | - `providerSessionId: ProviderSessionId | null` | ||
| 475 | - `providerThreadId: ProviderThreadId | null` | ||
| 476 | - `activeTurnId: TurnId | null` | ||
| 477 | - `lastError: string | null` | ||
| 478 | - `updatedAt: IsoDateTime` | ||
| 479 | |||
| 480 | 6. `projection_thread_turns` | ||
| 481 | |||
| 482 | - `turnId: TurnId` (primary key) | ||
| 483 | - `threadId: ThreadId` | ||
| 484 | - `turnCount: number` | ||
| 485 | - `status: "running" | "completed" | "interrupted" | "error"` | ||
| 486 | - `userMessageId: MessageId | null` | ||
| 487 | - `assistantMessageId: MessageId | null` | ||
| 488 | - `startedAt: IsoDateTime` | ||
| 489 | - `completedAt: IsoDateTime | null` | ||
| 490 | |||
| 491 | 7. `projection_checkpoints` | ||
| 492 | |||
| 493 | - `threadId: ThreadId` | ||
| 494 | - `turnId: TurnId` | ||
| 495 | - `checkpointTurnCount: number` | ||
| 496 | - `checkpointRef: CheckpointRef` | ||
| 497 | - `status: "ready" | "missing" | "error"` | ||
| 498 | - `files: JsonArray` (typed as file diff summary schema) | ||
| 499 | - `assistantMessageId: MessageId | null` | ||
| 500 | - `completedAt: IsoDateTime` | ||
| 501 | - unique key: `(threadId, checkpointTurnCount)` | ||
| 502 | |||
| 503 | 8. `projection_pending_approvals` | ||
| 504 | |||
| 505 | - `requestId: ApprovalRequestId` (primary key) | ||
| 506 | - `threadId: ThreadId` | ||
| 507 | - `turnId: TurnId | null` | ||
| 508 | - `status: "pending" | "resolved"` | ||
| 509 | - `decision: "accept" | "acceptForSession" | "decline" | "cancel" | null` | ||
| 510 | - `createdAt: IsoDateTime` | ||
| 511 | - `resolvedAt: IsoDateTime | null` | ||
| 512 | |||
| 513 | 9. `projection_state` | ||
| 514 | |||
| 515 | - `projector: string` (primary key; e.g. `threads`, `messages`, `sessions`) | ||
| 516 | - `lastAppliedSequence: number` | ||
| 517 | - `updatedAt: IsoDateTime` | ||
| 518 | |||
| 519 | Projection consistency rules: | ||
| 520 | |||
| 521 | - Every projector applies read-model row updates and `projection_state.lastAppliedSequence` in the same database transaction. | ||
| 522 | - Optional debug field on projection rows: `lastEventSequence: number` (not required for correctness). | ||
| 523 | |||
| 524 | ### 7.4 Snapshot and RPC Requirements | ||
| 525 | |||
| 526 | 1. `orchestration.getSnapshot` is fully served from projection tables and returns `snapshotSequence: number`. | ||
| 527 | 2. Snapshot must include `projects[]` from `projection_projects`. | ||
| 528 | 3. Thread snapshot must include `checkpoints[]` from `projection_checkpoints`: | ||
| 529 | - `turnId: TurnId` | ||
| 530 | - `completedAt: IsoDateTime` | ||
| 531 | - `status` | ||
| 532 | - `files[]` (`path`, `kind`, `additions`, `deletions`) | ||
| 533 | - `checkpointRef: CheckpointRef` | ||
| 534 | - `assistantMessageId?: MessageId` | ||
| 535 | - `checkpointTurnCount: number` | ||
| 536 | 4. Client does not require `listCheckpoints` RPC: | ||
| 537 | - checkpoint list comes from snapshot projections | ||
| 538 | - full diff text comes from `orchestration.getTurnDiff` backed by `checkpoint_diff_blobs` | ||
| 539 | 5. Provider session identity is not a client routing key: | ||
| 540 | - client acts on `ThreadId` | ||
| 541 | - server resolves provider session internally via `projection_thread_sessions` | ||
| 542 | 6. `snapshotSequence` is derived from `projection_state`: | ||
| 543 | - if snapshot depends on multiple projectors, use the minimum `lastAppliedSequence` across those projectors. | ||
| 544 | 7. Event subscription handoff contract: | ||
| 545 | - client performs `getSnapshot` and reads `snapshotSequence` | ||
| 546 | - client subscribes/replays with `fromSequenceExclusive = snapshotSequence` | ||
| 547 | - server guarantees no gap between snapshot visibility and subsequent event stream from that sequence. | ||
| 548 | |||
| 549 | ### 7.5 External Derived State (Non-Orchestration) | ||
| 550 | |||
| 551 | 1. Current git/worktree state is not projected from orchestration events. | ||
| 552 | 2. If persisted, it belongs in a separate git cache/read model (for example `git_state_cache`) owned by a git service. | ||
| 553 | 3. Orchestration may embed git metadata only when captured as a domain fact (for example checkpoint metadata at turn completion). | ||
| 554 | 4. Any RPC for current git status should be outside orchestration RPC (for example `git.getThreadState`). | ||
| 555 | |||
| 556 | ### 7.6 Existing Repository Placement | ||
| 557 | |||
| 558 | 1. `ProjectsRepository` | ||
| 559 | - Fits as projection/query access on top of `projection_projects` plus orchestration command dispatch for writes. | ||
| 560 | - Must not bypass command->event append path for mutations. | ||
| 561 | |||
| 562 | 2. `CheckpointsRepository` | ||
| 563 | - Fits as projection/query access on top of `projection_checkpoints` plus `checkpoint_diff_blobs` (or git-service on-demand diff implementation). | ||
| 564 | - Represents the same concept as prior `turn_diff_summary`; canonical naming is `checkpoint`. | ||
| 565 | - Must not be an independent source of truth outside orchestration events. | ||
| 566 | |||
| 567 | 3. `ProviderSessionsRepository` | ||
| 568 | - Fits as server-internal runtime persistence on top of `provider_session_runtime`, with domain-visible state projected into `projection_thread_sessions`. | ||
| 569 | - Not part of client RPC/domain aggregate boundary. | ||
apps/server/integration/TestProviderAdapter.integration.ts+10-14| ... | @@ -70,13 +70,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -70,13 +70,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 70 | const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) => | 70 | const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) => |
| 71 | Effect.gen(function* () { | 71 | Effect.gen(function* () { |
| 72 | if (input.provider !== undefined && input.provider !== PROVIDER) { | 72 | if (input.provider !== undefined && input.provider !== PROVIDER) { |
| 73 | return yield* Effect.fail( | 73 | return yield* new ProviderAdapterValidationError({ |
| 74 | new ProviderAdapterValidationError({ | 74 | provider: PROVIDER, |
| 75 | provider: PROVIDER, | 75 | operation: "startSession", |
| 76 | operation: "startSession", | 76 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, |
| 77 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | 77 | }); |
| 78 | }), | ||
| 79 | ); | ||
| 80 | } | 78 | } |
| 81 | 79 | ||
| 82 | sessionCount += 1; | 80 | sessionCount += 1; |
| ... | @@ -121,13 +119,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -121,13 +119,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 121 | 119 | ||
| 122 | const response = state.queuedResponses.shift(); | 120 | const response = state.queuedResponses.shift(); |
| 123 | if (!response) { | 121 | if (!response) { |
| 124 | return yield* Effect.fail( | 122 | return yield* new ProviderAdapterValidationError({ |
| 125 | new ProviderAdapterValidationError({ | 123 | provider: PROVIDER, |
| 126 | provider: PROVIDER, | 124 | operation: "sendTurn", |
| 127 | operation: "sendTurn", | 125 | issue: `No queued turn response for session ${input.sessionId}.`, |
| 128 | issue: `No queued turn response for session ${input.sessionId}.`, | 126 | }); |
| 129 | }), | ||
| 130 | ); | ||
| 131 | } | 127 | } |
| 132 | 128 | ||
| 133 | const assistantDeltas: string[] = []; | 129 | const assistantDeltas: string[] = []; |
apps/server/package.json+3| ... | @@ -13,11 +13,13 @@ | ... | @@ -13,11 +13,13 @@ |
| 13 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts", | 13 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts", |
| 14 | "build": "tsdown && node scripts/bundle-client.ts", | 14 | "build": "tsdown && node scripts/bundle-client.ts", |
| 15 | "start": "node dist/index.mjs", | 15 | "start": "node dist/index.mjs", |
| 16 | "prepare": "effect-language-service patch", | ||
| 16 | "typecheck": "tsc --noEmit", | 17 | "typecheck": "tsc --noEmit", |
| 17 | "test": "vitest run" | 18 | "test": "vitest run" |
| 18 | }, | 19 | }, |
| 19 | "dependencies": { | 20 | "dependencies": { |
| 20 | "@effect/platform-node": "4.0.0-beta.10", | 21 | "@effect/platform-node": "4.0.0-beta.10", |
| 22 | "@effect/sql-sqlite-bun": "4.0.0-beta.10", | ||
| 21 | "@pierre/diffs": "^1.1.0-beta.16", | 23 | "@pierre/diffs": "^1.1.0-beta.16", |
| 22 | "effect": "4.0.0-beta.10", | 24 | "effect": "4.0.0-beta.10", |
| 23 | "node-pty": "^1.1.0", | 25 | "node-pty": "^1.1.0", |
| ... | @@ -25,6 +27,7 @@ | ... | @@ -25,6 +27,7 @@ |
| 25 | "ws": "^8.18.0" | 27 | "ws": "^8.18.0" |
| 26 | }, | 28 | }, |
| 27 | "devDependencies": { | 29 | "devDependencies": { |
| 30 | "@effect/language-service": "0.75.1", | ||
| 28 | "@effect/vitest": "4.0.0-beta.10", | 31 | "@effect/vitest": "4.0.0-beta.10", |
| 29 | "@t3tools/contracts": "workspace:*", | 32 | "@t3tools/contracts": "workspace:*", |
| 30 | "@t3tools/web": "workspace:*", | 33 | "@t3tools/web": "workspace:*", |
apps/server/src/checkpointing/Layers/CheckpointService.test.ts+136-28| ... | @@ -60,41 +60,56 @@ const makeGitRepository = Effect.gen(function* () { | ... | @@ -60,41 +60,56 @@ const makeGitRepository = Effect.gen(function* () { |
| 60 | class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterError> { | 60 | class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterError> { |
| 61 | readonly provider = "codex" as const; | 61 | readonly provider = "codex" as const; |
| 62 | 62 | ||
| 63 | private active = true; | ||
| 64 | private readThreadFailure: ProviderAdapterError | undefined; | 63 | private readThreadFailure: ProviderAdapterError | undefined; |
| 65 | private snapshot: ProviderThreadSnapshot; | 64 | private readonly sessions = new Map<string, ProviderSession>(); |
| 66 | private readonly session: ProviderSession; | 65 | private readonly snapshots = new Map<string, ProviderThreadSnapshot>(); |
| 67 | 66 | ||
| 68 | constructor( | 67 | constructor( |
| 69 | readonly sessionId: string, | 68 | readonly sessionId: string, |
| 70 | readonly cwd: string, | 69 | readonly cwd: string, |
| 71 | turns: ReadonlyArray<ProviderThreadTurnSnapshot>, | 70 | turns: ReadonlyArray<ProviderThreadTurnSnapshot>, |
| 72 | ) { | 71 | ) { |
| 73 | const now = new Date().toISOString(); | 72 | this.addSession({ |
| 74 | this.session = { | ||
| 75 | sessionId, | 73 | sessionId, |
| 74 | threadId: "thread-1", | ||
| 75 | turns, | ||
| 76 | }); | ||
| 77 | } | ||
| 78 | |||
| 79 | addSession(input: { | ||
| 80 | readonly sessionId: string; | ||
| 81 | readonly threadId: string; | ||
| 82 | readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>; | ||
| 83 | }): void { | ||
| 84 | const now = new Date().toISOString(); | ||
| 85 | this.sessions.set(input.sessionId, { | ||
| 86 | sessionId: input.sessionId, | ||
| 76 | provider: "codex", | 87 | provider: "codex", |
| 77 | status: "ready", | 88 | status: "ready", |
| 78 | threadId: "thread-1", | 89 | threadId: input.threadId, |
| 79 | cwd, | 90 | cwd: this.cwd, |
| 80 | createdAt: now, | 91 | createdAt: now, |
| 81 | updatedAt: now, | 92 | updatedAt: now, |
| 82 | }; | 93 | }); |
| 83 | this.snapshot = { | 94 | this.snapshots.set(input.sessionId, { |
| 84 | threadId: this.session.threadId ?? "thread-1", | 95 | threadId: input.threadId, |
| 85 | turns, | 96 | turns: input.turns, |
| 86 | }; | 97 | }); |
| 87 | } | 98 | } |
| 88 | 99 | ||
| 89 | setTurns(turns: ReadonlyArray<ProviderThreadTurnSnapshot>): void { | 100 | setTurns(turns: ReadonlyArray<ProviderThreadTurnSnapshot>, sessionId = this.sessionId): void { |
| 90 | this.snapshot = { | 101 | const snapshot = this.snapshots.get(sessionId); |
| 91 | threadId: this.snapshot.threadId, | 102 | if (!snapshot) { |
| 103 | return; | ||
| 104 | } | ||
| 105 | this.snapshots.set(sessionId, { | ||
| 106 | threadId: snapshot.threadId, | ||
| 92 | turns, | 107 | turns, |
| 93 | }; | 108 | }); |
| 94 | } | 109 | } |
| 95 | 110 | ||
| 96 | getTurns(): ReadonlyArray<ProviderThreadTurnSnapshot> { | 111 | getTurns(sessionId = this.sessionId): ReadonlyArray<ProviderThreadTurnSnapshot> { |
| 97 | return this.snapshot.turns; | 112 | return this.snapshots.get(sessionId)?.turns ?? []; |
| 98 | } | 113 | } |
| 99 | 114 | ||
| 100 | setReadThreadFailure(error: ProviderAdapterError | undefined): void { | 115 | setReadThreadFailure(error: ProviderAdapterError | undefined): void { |
| ... | @@ -136,16 +151,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr | ... | @@ -136,16 +151,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr |
| 136 | 151 | ||
| 137 | readonly stopSession = (sessionId: string): Effect.Effect<void, ProviderAdapterError> => | 152 | readonly stopSession = (sessionId: string): Effect.Effect<void, ProviderAdapterError> => |
| 138 | Effect.sync(() => { | 153 | Effect.sync(() => { |
| 139 | if (sessionId === this.sessionId) { | 154 | this.sessions.delete(sessionId); |
| 140 | this.active = false; | 155 | this.snapshots.delete(sessionId); |
| 141 | } | ||
| 142 | }); | 156 | }); |
| 143 | 157 | ||
| 144 | readonly listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> => | 158 | readonly listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> => |
| 145 | Effect.sync(() => (this.active ? [this.session] : [])); | 159 | Effect.sync(() => Array.from(this.sessions.values())); |
| 146 | 160 | ||
| 147 | readonly hasSession = (sessionId: string): Effect.Effect<boolean> => | 161 | readonly hasSession = (sessionId: string): Effect.Effect<boolean> => |
| 148 | Effect.succeed(this.active && sessionId === this.sessionId); | 162 | Effect.succeed(this.sessions.has(sessionId)); |
| 149 | 163 | ||
| 150 | readonly readThread = ( | 164 | readonly readThread = ( |
| 151 | sessionId: string, | 165 | sessionId: string, |
| ... | @@ -155,7 +169,18 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr | ... | @@ -155,7 +169,18 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr |
| 155 | hasSession | 169 | hasSession |
| 156 | ? this.readThreadFailure | 170 | ? this.readThreadFailure |
| 157 | ? Effect.fail(this.readThreadFailure) | 171 | ? Effect.fail(this.readThreadFailure) |
| 158 | : Effect.succeed(this.snapshot) | 172 | : (() => { |
| 173 | const snapshot = this.snapshots.get(sessionId); | ||
| 174 | if (!snapshot) { | ||
| 175 | return Effect.fail( | ||
| 176 | new ProviderAdapterSessionNotFoundError({ | ||
| 177 | provider: this.provider, | ||
| 178 | sessionId, | ||
| 179 | }), | ||
| 180 | ); | ||
| 181 | } | ||
| 182 | return Effect.succeed(snapshot); | ||
| 183 | })() | ||
| 159 | : Effect.fail( | 184 | : Effect.fail( |
| 160 | new ProviderAdapterSessionNotFoundError({ | 185 | new ProviderAdapterSessionNotFoundError({ |
| 161 | provider: this.provider, | 186 | provider: this.provider, |
| ... | @@ -184,14 +209,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr | ... | @@ -184,14 +209,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr |
| 184 | threadId: snapshot.threadId, | 209 | threadId: snapshot.threadId, |
| 185 | turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns), | 210 | turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns), |
| 186 | } satisfies ProviderThreadSnapshot; | 211 | } satisfies ProviderThreadSnapshot; |
| 187 | this.snapshot = next; | 212 | this.snapshots.set(sessionId, next); |
| 188 | return Effect.succeed(next); | 213 | return Effect.succeed(next); |
| 189 | }), | 214 | }), |
| 190 | ); | 215 | ); |
| 191 | 216 | ||
| 192 | readonly stopAll = (): Effect.Effect<void, ProviderAdapterError> => | 217 | readonly stopAll = (): Effect.Effect<void, ProviderAdapterError> => |
| 193 | Effect.sync(() => { | 218 | Effect.sync(() => { |
| 194 | this.active = false; | 219 | this.sessions.clear(); |
| 220 | this.snapshots.clear(); | ||
| 195 | }); | 221 | }); |
| 196 | 222 | ||
| 197 | readonly streamEvents = Stream.empty; | 223 | readonly streamEvents = Stream.empty; |
| ... | @@ -315,6 +341,88 @@ it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", () | ... | @@ -315,6 +341,88 @@ it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", () |
| 315 | }).pipe(Effect.provide(NodeServices.layer)), | 341 | }).pipe(Effect.provide(NodeServices.layer)), |
| 316 | ); | 342 | ); |
| 317 | 343 | ||
| 344 | it.effect( | ||
| 345 | "CheckpointServiceLive reuses checkpoint history after session churn on the same thread", | ||
| 346 | () => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const firstTurn: ProviderThreadTurnSnapshot = { | ||
| 349 | id: "turn-1", | ||
| 350 | items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], | ||
| 351 | }; | ||
| 352 | const secondTurn: ProviderThreadTurnSnapshot = { | ||
| 353 | id: "turn-2", | ||
| 354 | items: [{ type: "agentMessage", text: "second" }], | ||
| 355 | }; | ||
| 356 | |||
| 357 | const fixture = yield* makeFixture([]); | ||
| 358 | |||
| 359 | yield* Effect.gen(function* () { | ||
| 360 | const fs = yield* FileSystem.FileSystem; | ||
| 361 | const path = yield* Path.Path; | ||
| 362 | const service = yield* CheckpointService; | ||
| 363 | const directory = yield* ProviderSessionDirectory; | ||
| 364 | |||
| 365 | yield* directory.upsert({ | ||
| 366 | sessionId: fixture.sessionId, | ||
| 367 | provider: "codex", | ||
| 368 | threadId: "thread-1", | ||
| 369 | }); | ||
| 370 | yield* service.initializeForSession({ | ||
| 371 | providerSessionId: fixture.sessionId, | ||
| 372 | cwd: fixture.cwd, | ||
| 373 | }); | ||
| 374 | |||
| 375 | yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); | ||
| 376 | fixture.adapter.setTurns([firstTurn]); | ||
| 377 | yield* service.captureCurrentTurn({ | ||
| 378 | providerSessionId: fixture.sessionId, | ||
| 379 | }); | ||
| 380 | |||
| 381 | const resumedSessionId = "sess-2"; | ||
| 382 | fixture.adapter.addSession({ | ||
| 383 | sessionId: resumedSessionId, | ||
| 384 | threadId: "thread-1", | ||
| 385 | turns: [firstTurn, secondTurn], | ||
| 386 | }); | ||
| 387 | yield* directory.upsert({ | ||
| 388 | sessionId: resumedSessionId, | ||
| 389 | provider: "codex", | ||
| 390 | threadId: "thread-1", | ||
| 391 | }); | ||
| 392 | |||
| 393 | yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n"); | ||
| 394 | yield* service.captureCurrentTurn({ | ||
| 395 | providerSessionId: resumedSessionId, | ||
| 396 | }); | ||
| 397 | |||
| 398 | const listed = yield* service.listCheckpoints({ sessionId: resumedSessionId }); | ||
| 399 | assert.equal(listed.threadId, "thread-1"); | ||
| 400 | assert.equal(listed.checkpoints.length, 3); | ||
| 401 | assert.equal(listed.checkpoints[2]?.id, "turn-2"); | ||
| 402 | assert.equal(listed.checkpoints[2]?.isCurrent, true); | ||
| 403 | |||
| 404 | const diff = yield* service.getCheckpointDiff({ | ||
| 405 | sessionId: resumedSessionId, | ||
| 406 | fromTurnCount: 1, | ||
| 407 | toTurnCount: 2, | ||
| 408 | }); | ||
| 409 | assert.equal(diff.threadId, "thread-1"); | ||
| 410 | assert.equal(diff.diff.includes("-v2"), true); | ||
| 411 | assert.equal(diff.diff.includes("+v3"), true); | ||
| 412 | |||
| 413 | const reverted = yield* service.revertToCheckpoint({ | ||
| 414 | sessionId: resumedSessionId, | ||
| 415 | turnCount: 1, | ||
| 416 | }); | ||
| 417 | const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); | ||
| 418 | assert.equal(afterRevert, "v2\n"); | ||
| 419 | assert.equal(reverted.turnCount, 1); | ||
| 420 | assert.equal(reverted.rolledBackTurns, 1); | ||
| 421 | assert.equal(fixture.adapter.getTurns(resumedSessionId).length, 1); | ||
| 422 | }).pipe(Effect.provide(fixture.layer)); | ||
| 423 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 424 | ); | ||
| 425 | |||
| 318 | it.effect( | 426 | it.effect( |
| 319 | "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions", | 427 | "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions", |
| 320 | () => | 428 | () => |
| ... | @@ -374,7 +482,7 @@ it.effect( | ... | @@ -374,7 +482,7 @@ it.effect( |
| 374 | }); | 482 | }); |
| 375 | 483 | ||
| 376 | const checkpoint = yield* checkpointRepository.getCheckpoint({ | 484 | const checkpoint = yield* checkpointRepository.getCheckpoint({ |
| 377 | providerSessionId: fixture.sessionId, | 485 | threadId: "thread-1", |
| 378 | turnCount: 1, | 486 | turnCount: 1, |
| 379 | }); | 487 | }); |
| 380 | if (checkpoint._tag !== "Some") { | 488 | if (checkpoint._tag !== "Some") { |
| ... | @@ -429,7 +537,7 @@ it.effect("CheckpointServiceLive initializes root checkpoints without requiring | ... | @@ -429,7 +537,7 @@ it.effect("CheckpointServiceLive initializes root checkpoints without requiring |
| 429 | }); | 537 | }); |
| 430 | 538 | ||
| 431 | const root = yield* checkpointRepository.getCheckpoint({ | 539 | const root = yield* checkpointRepository.getCheckpoint({ |
| 432 | providerSessionId: fixture.sessionId, | 540 | threadId: "thread-1", |
| 433 | turnCount: 0, | 541 | turnCount: 0, |
| 434 | }); | 542 | }); |
| 435 | assert.equal(root._tag, "Some"); | 543 | assert.equal(root._tag, "Some"); |
apps/server/src/checkpointing/Layers/CheckpointService.ts+129-103| ... | @@ -481,11 +481,7 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -481,11 +481,7 @@ const makeCheckpointService = Effect.gen(function* () { |
| 481 | ): Effect.Effect<string, CheckpointServiceError> => | 481 | ): Effect.Effect<string, CheckpointServiceError> => |
| 482 | directory.getThreadId(sessionId).pipe( | 482 | directory.getThreadId(sessionId).pipe( |
| 483 | Effect.mapError((cause) => | 483 | Effect.mapError((cause) => |
| 484 | mapDirectoryLookupError( | 484 | mapDirectoryLookupError(sessionId, `${operation}:getThreadId`, cause), |
| 485 | sessionId, | ||
| 486 | `${operation}:getThreadId`, | ||
| 487 | cause, | ||
| 488 | ), | ||
| 489 | ), | 485 | ), |
| 490 | Effect.flatMap((threadId) => | 486 | Effect.flatMap((threadId) => |
| 491 | Option.match(threadId, { | 487 | Option.match(threadId, { |
| ... | @@ -495,10 +491,7 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -495,10 +491,7 @@ const makeCheckpointService = Effect.gen(function* () { |
| 495 | return Effect.succeed(normalizedThreadId); | 491 | return Effect.succeed(normalizedThreadId); |
| 496 | } | 492 | } |
| 497 | return Effect.fail( | 493 | return Effect.fail( |
| 498 | invariantError( | 494 | invariantError(operation, `Resolved empty thread id for session ${sessionId}.`), |
| 499 | operation, | ||
| 500 | `Resolved empty thread id for session ${sessionId}.`, | ||
| 501 | ), | ||
| 502 | ); | 495 | ); |
| 503 | }, | 496 | }, |
| 504 | onNone: () => | 497 | onNone: () => |
| ... | @@ -512,6 +505,30 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -512,6 +505,30 @@ const makeCheckpointService = Effect.gen(function* () { |
| 512 | ), | 505 | ), |
| 513 | ); | 506 | ); |
| 514 | 507 | ||
| 508 | const ensureThreadMatchesSnapshot = ( | ||
| 509 | resolvedThreadId: string, | ||
| 510 | snapshot: ProviderThreadSnapshot, | ||
| 511 | operation: string, | ||
| 512 | ): Effect.Effect<void, CheckpointInvariantError> => { | ||
| 513 | const snapshotThreadId = snapshot.threadId.trim(); | ||
| 514 | if (snapshotThreadId.length === 0) { | ||
| 515 | return Effect.fail( | ||
| 516 | invariantError(operation, "Provider thread snapshot is missing threadId."), | ||
| 517 | ); | ||
| 518 | } | ||
| 519 | |||
| 520 | if (snapshotThreadId !== resolvedThreadId) { | ||
| 521 | return Effect.fail( | ||
| 522 | invariantError( | ||
| 523 | operation, | ||
| 524 | `Resolved thread ${resolvedThreadId} does not match provider snapshot thread ${snapshotThreadId}.`, | ||
| 525 | ), | ||
| 526 | ); | ||
| 527 | } | ||
| 528 | |||
| 529 | return Effect.void; | ||
| 530 | }; | ||
| 531 | |||
| 515 | const rollbackThreadSnapshot = ( | 532 | const rollbackThreadSnapshot = ( |
| 516 | sessionId: string, | 533 | sessionId: string, |
| 517 | adapter: ProviderAdapterShape<ProviderAdapterError>, | 534 | adapter: ProviderAdapterShape<ProviderAdapterError>, |
| ... | @@ -601,29 +618,30 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -601,29 +618,30 @@ const makeCheckpointService = Effect.gen(function* () { |
| 601 | }; | 618 | }; |
| 602 | 619 | ||
| 603 | const ensureRootCheckpointMetadata = ( | 620 | const ensureRootCheckpointMetadata = ( |
| 604 | providerSessionId: string, | 621 | threadId: string, |
| 605 | operation: string, | 622 | operation: string, |
| 606 | ): Effect.Effect<void, CheckpointServiceError> => | 623 | ): Effect.Effect<void, CheckpointServiceError> => |
| 607 | checkpointRepository.getCheckpoint({ providerSessionId, turnCount: 0 }).pipe( | 624 | checkpointRepository |
| 608 | Effect.flatMap((rootEntry) => | 625 | .getCheckpoint({ threadId, turnCount: 0 }) |
| 609 | Option.isSome(rootEntry) | 626 | .pipe( |
| 610 | ? Effect.void | 627 | Effect.flatMap((rootEntry) => |
| 611 | : Effect.fail( | 628 | Option.isSome(rootEntry) |
| 612 | invariantError( | 629 | ? Effect.void |
| 613 | operation, | 630 | : Effect.fail( |
| 614 | `Missing root checkpoint metadata for session ${providerSessionId}.`, | 631 | invariantError( |
| 632 | operation, | ||
| 633 | `Missing root checkpoint metadata for thread ${threadId}.`, | ||
| 634 | ), | ||
| 615 | ), | 635 | ), |
| 616 | ), | 636 | ), |
| 617 | ), | 637 | ); |
| 618 | ); | ||
| 619 | 638 | ||
| 620 | const getCheckpointRefOrFail = ( | 639 | const getCheckpointRefOrFail = ( |
| 621 | providerSessionId: string, | ||
| 622 | threadId: string, | 640 | threadId: string, |
| 623 | turnCount: number, | 641 | turnCount: number, |
| 624 | operation: string, | 642 | operation: string, |
| 625 | ): Effect.Effect<string, CheckpointServiceError> => | 643 | ): Effect.Effect<string, CheckpointServiceError> => |
| 626 | checkpointRepository.getCheckpoint({ providerSessionId, turnCount }).pipe( | 644 | checkpointRepository.getCheckpoint({ threadId, turnCount }).pipe( |
| 627 | Effect.mapError((error): CheckpointServiceError => error), | 645 | Effect.mapError((error): CheckpointServiceError => error), |
| 628 | Effect.flatMap((checkpoint): Effect.Effect<string, CheckpointServiceError> => { | 646 | Effect.flatMap((checkpoint): Effect.Effect<string, CheckpointServiceError> => { |
| 629 | if (Option.isNone(checkpoint)) { | 647 | if (Option.isNone(checkpoint)) { |
| ... | @@ -716,17 +734,15 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -716,17 +734,15 @@ const makeCheckpointService = Effect.gen(function* () { |
| 716 | } | 734 | } |
| 717 | 735 | ||
| 718 | const existingRoot = yield* checkpointRepository.getCheckpoint({ | 736 | const existingRoot = yield* checkpointRepository.getCheckpoint({ |
| 719 | providerSessionId: input.providerSessionId, | 737 | threadId, |
| 720 | turnCount: 0, | 738 | turnCount: 0, |
| 721 | }); | 739 | }); |
| 722 | if (Option.isSome(existingRoot)) { | 740 | if (Option.isSome(existingRoot)) { |
| 723 | const existingRef = existingRoot.value.checkpointRef.trim(); | 741 | const existingRef = existingRoot.value.checkpointRef.trim(); |
| 724 | if (existingRef.length === 0) { | 742 | if (existingRef.length === 0) { |
| 725 | return yield* Effect.fail( | 743 | return yield* invariantError( |
| 726 | invariantError( | 744 | "CheckpointService.initializeForSession", |
| 727 | "CheckpointService.initializeForSession", | 745 | "Existing root checkpoint metadata is missing checkpointRef.", |
| 728 | "Existing root checkpoint metadata is missing checkpointRef.", | ||
| 729 | ), | ||
| 730 | ); | 746 | ); |
| 731 | } | 747 | } |
| 732 | const exists = yield* store.hasCheckpointRef({ | 748 | const exists = yield* store.hasCheckpointRef({ |
| ... | @@ -734,11 +750,9 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -734,11 +750,9 @@ const makeCheckpointService = Effect.gen(function* () { |
| 734 | checkpointRef: existingRef, | 750 | checkpointRef: existingRef, |
| 735 | }); | 751 | }); |
| 736 | if (!exists) { | 752 | if (!exists) { |
| 737 | return yield* Effect.fail( | 753 | return yield* invariantError( |
| 738 | invariantError( | 754 | "CheckpointService.initializeForSession", |
| 739 | "CheckpointService.initializeForSession", | 755 | "Root checkpoint metadata exists but filesystem ref is missing.", |
| 740 | "Root checkpoint metadata exists but filesystem ref is missing.", | ||
| 741 | ), | ||
| 742 | ); | 756 | ); |
| 743 | } | 757 | } |
| 744 | yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); | 758 | yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); |
| ... | @@ -770,6 +784,15 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -770,6 +784,15 @@ const makeCheckpointService = Effect.gen(function* () { |
| 770 | adapter, | 784 | adapter, |
| 771 | "CheckpointService.captureCurrentTurn:readThread", | 785 | "CheckpointService.captureCurrentTurn:readThread", |
| 772 | ); | 786 | ); |
| 787 | const threadId = yield* resolveThreadIdForSession( | ||
| 788 | input.providerSessionId, | ||
| 789 | "CheckpointService.captureCurrentTurn", | ||
| 790 | ); | ||
| 791 | yield* ensureThreadMatchesSnapshot( | ||
| 792 | threadId, | ||
| 793 | snapshot, | ||
| 794 | "CheckpointService.captureCurrentTurn", | ||
| 795 | ); | ||
| 773 | if (snapshot.turns.length === 0) { | 796 | if (snapshot.turns.length === 0) { |
| 774 | return; | 797 | return; |
| 775 | } | 798 | } |
| ... | @@ -784,23 +807,18 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -784,23 +807,18 @@ const makeCheckpointService = Effect.gen(function* () { |
| 784 | "CheckpointService.captureCurrentTurn", | 807 | "CheckpointService.captureCurrentTurn", |
| 785 | ); | 808 | ); |
| 786 | 809 | ||
| 787 | yield* ensureRootCheckpointMetadata( | 810 | yield* ensureRootCheckpointMetadata(threadId, "CheckpointService.captureCurrentTurn"); |
| 788 | input.providerSessionId, | ||
| 789 | "CheckpointService.captureCurrentTurn", | ||
| 790 | ); | ||
| 791 | 811 | ||
| 792 | const existing = yield* checkpointRepository.getCheckpoint({ | 812 | const existing = yield* checkpointRepository.getCheckpoint({ |
| 793 | providerSessionId: input.providerSessionId, | 813 | threadId, |
| 794 | turnCount: checkpoint.turnCount, | 814 | turnCount: checkpoint.turnCount, |
| 795 | }); | 815 | }); |
| 796 | if (Option.isSome(existing)) { | 816 | if (Option.isSome(existing)) { |
| 797 | const checkpointRef = existing.value.checkpointRef.trim(); | 817 | const checkpointRef = existing.value.checkpointRef.trim(); |
| 798 | if (checkpointRef.length === 0) { | 818 | if (checkpointRef.length === 0) { |
| 799 | return yield* Effect.fail( | 819 | return yield* invariantError( |
| 800 | invariantError( | 820 | "CheckpointService.captureCurrentTurn", |
| 801 | "CheckpointService.captureCurrentTurn", | 821 | `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`, |
| 802 | `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`, | ||
| 803 | ), | ||
| 804 | ); | 822 | ); |
| 805 | } | 823 | } |
| 806 | const exists = yield* store.hasCheckpointRef({ | 824 | const exists = yield* store.hasCheckpointRef({ |
| ... | @@ -808,11 +826,9 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -808,11 +826,9 @@ const makeCheckpointService = Effect.gen(function* () { |
| 808 | checkpointRef, | 826 | checkpointRef, |
| 809 | }); | 827 | }); |
| 810 | if (!exists) { | 828 | if (!exists) { |
| 811 | return yield* Effect.fail( | 829 | return yield* invariantError( |
| 812 | invariantError( | 830 | "CheckpointService.captureCurrentTurn", |
| 813 | "CheckpointService.captureCurrentTurn", | 831 | `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`, |
| 814 | `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`, | ||
| 815 | ), | ||
| 816 | ); | 832 | ); |
| 817 | } | 833 | } |
| 818 | return; | 834 | return; |
| ... | @@ -821,7 +837,7 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -821,7 +837,7 @@ const makeCheckpointService = Effect.gen(function* () { |
| 821 | yield* captureAndPersistCheckpoint({ | 837 | yield* captureAndPersistCheckpoint({ |
| 822 | providerSessionId: input.providerSessionId, | 838 | providerSessionId: input.providerSessionId, |
| 823 | cwd: resolution.checkpointCwd.value, | 839 | cwd: resolution.checkpointCwd.value, |
| 824 | threadId: snapshot.threadId, | 840 | threadId, |
| 825 | checkpoint, | 841 | checkpoint, |
| 826 | }); | 842 | }); |
| 827 | }), | 843 | }), |
| ... | @@ -842,31 +858,36 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -842,31 +858,36 @@ const makeCheckpointService = Effect.gen(function* () { |
| 842 | adapter, | 858 | adapter, |
| 843 | "CheckpointService.listCheckpoints:readThread", | 859 | "CheckpointService.listCheckpoints:readThread", |
| 844 | ); | 860 | ); |
| 861 | const threadId = yield* resolveThreadIdForSession( | ||
| 862 | input.sessionId, | ||
| 863 | "CheckpointService.listCheckpoints", | ||
| 864 | ); | ||
| 865 | yield* ensureThreadMatchesSnapshot( | ||
| 866 | threadId, | ||
| 867 | snapshot, | ||
| 868 | "CheckpointService.listCheckpoints", | ||
| 869 | ); | ||
| 845 | 870 | ||
| 846 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); | 871 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); |
| 847 | if (Option.isNone(resolution.checkpointCwd)) { | 872 | if (Option.isNone(resolution.checkpointCwd)) { |
| 848 | return yield* Effect.fail( | 873 | return yield* new CheckpointRepositoryError({ |
| 849 | new CheckpointRepositoryError({ | 874 | cwd: resolution.candidateCwd, |
| 850 | cwd: resolution.candidateCwd, | 875 | detail: "Filesystem checkpoints are unavailable for this session.", |
| 851 | detail: "Filesystem checkpoints are unavailable for this session.", | 876 | }); |
| 852 | }), | ||
| 853 | ); | ||
| 854 | } | 877 | } |
| 855 | 878 | ||
| 856 | const checkpoints = yield* checkpointRepository.listCheckpoints({ | 879 | const checkpoints = yield* checkpointRepository.listCheckpoints({ |
| 857 | providerSessionId: input.sessionId, | 880 | threadId, |
| 858 | }); | 881 | }); |
| 859 | if (checkpoints.length === 0) { | 882 | if (checkpoints.length === 0) { |
| 860 | return yield* Effect.fail( | 883 | return yield* invariantError( |
| 861 | invariantError( | 884 | "CheckpointService.listCheckpoints", |
| 862 | "CheckpointService.listCheckpoints", | 885 | `Missing checkpoint metadata for active session ${input.sessionId}.`, |
| 863 | `Missing checkpoint metadata for active session ${input.sessionId}.`, | ||
| 864 | ), | ||
| 865 | ); | 886 | ); |
| 866 | } | 887 | } |
| 867 | 888 | ||
| 868 | return { | 889 | return { |
| 869 | threadId: snapshot.threadId, | 890 | threadId, |
| 870 | checkpoints: Array.from(checkpoints), | 891 | checkpoints: Array.from(checkpoints), |
| 871 | }; | 892 | }; |
| 872 | }), | 893 | }), |
| ... | @@ -887,35 +908,38 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -887,35 +908,38 @@ const makeCheckpointService = Effect.gen(function* () { |
| 887 | adapter, | 908 | adapter, |
| 888 | "CheckpointService.getCheckpointDiff:readThread", | 909 | "CheckpointService.getCheckpointDiff:readThread", |
| 889 | ); | 910 | ); |
| 911 | const threadId = yield* resolveThreadIdForSession( | ||
| 912 | input.sessionId, | ||
| 913 | "CheckpointService.getCheckpointDiff", | ||
| 914 | ); | ||
| 915 | yield* ensureThreadMatchesSnapshot( | ||
| 916 | threadId, | ||
| 917 | snapshot, | ||
| 918 | "CheckpointService.getCheckpointDiff", | ||
| 919 | ); | ||
| 890 | 920 | ||
| 891 | if (input.toTurnCount > snapshot.turns.length) { | 921 | if (input.toTurnCount > snapshot.turns.length) { |
| 892 | return yield* Effect.fail( | 922 | return yield* validationError( |
| 893 | validationError( | 923 | "CheckpointService.getCheckpointDiff", |
| 894 | "CheckpointService.getCheckpointDiff", | 924 | `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, |
| 895 | `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, | ||
| 896 | ), | ||
| 897 | ); | 925 | ); |
| 898 | } | 926 | } |
| 899 | 927 | ||
| 900 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); | 928 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); |
| 901 | if (Option.isNone(resolution.checkpointCwd)) { | 929 | if (Option.isNone(resolution.checkpointCwd)) { |
| 902 | return yield* Effect.fail( | 930 | return yield* new CheckpointRepositoryError({ |
| 903 | new CheckpointRepositoryError({ | 931 | cwd: resolution.candidateCwd, |
| 904 | cwd: resolution.candidateCwd, | 932 | detail: "Filesystem checkpoints are unavailable for this session.", |
| 905 | detail: "Filesystem checkpoints are unavailable for this session.", | 933 | }); |
| 906 | }), | ||
| 907 | ); | ||
| 908 | } | 934 | } |
| 909 | 935 | ||
| 910 | const fromCheckpointRef = yield* getCheckpointRefOrFail( | 936 | const fromCheckpointRef = yield* getCheckpointRefOrFail( |
| 911 | input.sessionId, | 937 | threadId, |
| 912 | snapshot.threadId, | ||
| 913 | input.fromTurnCount, | 938 | input.fromTurnCount, |
| 914 | "CheckpointService.getCheckpointDiff:fromCheckpoint", | 939 | "CheckpointService.getCheckpointDiff:fromCheckpoint", |
| 915 | ); | 940 | ); |
| 916 | const toCheckpointRef = yield* getCheckpointRefOrFail( | 941 | const toCheckpointRef = yield* getCheckpointRefOrFail( |
| 917 | input.sessionId, | 942 | threadId, |
| 918 | snapshot.threadId, | ||
| 919 | input.toTurnCount, | 943 | input.toTurnCount, |
| 920 | "CheckpointService.getCheckpointDiff:toCheckpoint", | 944 | "CheckpointService.getCheckpointDiff:toCheckpoint", |
| 921 | ); | 945 | ); |
| ... | @@ -928,7 +952,7 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -928,7 +952,7 @@ const makeCheckpointService = Effect.gen(function* () { |
| 928 | }); | 952 | }); |
| 929 | 953 | ||
| 930 | return { | 954 | return { |
| 931 | threadId: snapshot.threadId, | 955 | threadId, |
| 932 | fromTurnCount: input.fromTurnCount, | 956 | fromTurnCount: input.fromTurnCount, |
| 933 | toTurnCount: input.toTurnCount, | 957 | toTurnCount: input.toTurnCount, |
| 934 | diff, | 958 | diff, |
| ... | @@ -951,31 +975,35 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -951,31 +975,35 @@ const makeCheckpointService = Effect.gen(function* () { |
| 951 | adapter, | 975 | adapter, |
| 952 | "CheckpointService.revertToCheckpoint:readThread", | 976 | "CheckpointService.revertToCheckpoint:readThread", |
| 953 | ); | 977 | ); |
| 978 | const threadId = yield* resolveThreadIdForSession( | ||
| 979 | input.sessionId, | ||
| 980 | "CheckpointService.revertToCheckpoint", | ||
| 981 | ); | ||
| 982 | yield* ensureThreadMatchesSnapshot( | ||
| 983 | threadId, | ||
| 984 | beforeSnapshot, | ||
| 985 | "CheckpointService.revertToCheckpoint", | ||
| 986 | ); | ||
| 954 | const currentTurnCount = beforeSnapshot.turns.length; | 987 | const currentTurnCount = beforeSnapshot.turns.length; |
| 955 | 988 | ||
| 956 | if (input.turnCount > currentTurnCount) { | 989 | if (input.turnCount > currentTurnCount) { |
| 957 | return yield* Effect.fail( | 990 | return yield* validationError( |
| 958 | validationError( | 991 | "CheckpointService.revertToCheckpoint", |
| 959 | "CheckpointService.revertToCheckpoint", | 992 | `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, |
| 960 | `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, | ||
| 961 | ), | ||
| 962 | ); | 993 | ); |
| 963 | } | 994 | } |
| 964 | 995 | ||
| 965 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); | 996 | const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); |
| 966 | if (Option.isNone(resolution.checkpointCwd)) { | 997 | if (Option.isNone(resolution.checkpointCwd)) { |
| 967 | return yield* Effect.fail( | 998 | return yield* new CheckpointRepositoryError({ |
| 968 | new CheckpointRepositoryError({ | 999 | cwd: resolution.candidateCwd, |
| 969 | cwd: resolution.candidateCwd, | 1000 | detail: "Filesystem checkpoints are unavailable for this session.", |
| 970 | detail: "Filesystem checkpoints are unavailable for this session.", | 1001 | }); |
| 971 | }), | ||
| 972 | ); | ||
| 973 | } | 1002 | } |
| 974 | 1003 | ||
| 975 | const checkpointCwd = resolution.checkpointCwd.value; | 1004 | const checkpointCwd = resolution.checkpointCwd.value; |
| 976 | const checkpointRef = yield* getCheckpointRefOrFail( | 1005 | const checkpointRef = yield* getCheckpointRefOrFail( |
| 977 | input.sessionId, | 1006 | threadId, |
| 978 | beforeSnapshot.threadId, | ||
| 979 | input.turnCount, | 1007 | input.turnCount, |
| 980 | "CheckpointService.revertToCheckpoint:checkpointLookup", | 1008 | "CheckpointService.revertToCheckpoint:checkpointLookup", |
| 981 | ); | 1009 | ); |
| ... | @@ -986,13 +1014,11 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -986,13 +1014,11 @@ const makeCheckpointService = Effect.gen(function* () { |
| 986 | fallbackToHead: input.turnCount === 0, | 1014 | fallbackToHead: input.turnCount === 0, |
| 987 | }); | 1015 | }); |
| 988 | if (!restored) { | 1016 | if (!restored) { |
| 989 | return yield* Effect.fail( | 1017 | return yield* new CheckpointUnavailableError({ |
| 990 | new CheckpointUnavailableError({ | 1018 | threadId, |
| 991 | threadId: beforeSnapshot.threadId, | 1019 | turnCount: input.turnCount, |
| 992 | turnCount: input.turnCount, | 1020 | detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${threadId}.`, |
| 993 | detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | 1021 | }); |
| 994 | }), | ||
| 995 | ); | ||
| 996 | } | 1022 | } |
| 997 | 1023 | ||
| 998 | const requestedRollbackTurns = currentTurnCount - input.turnCount; | 1024 | const requestedRollbackTurns = currentTurnCount - input.turnCount; |
| ... | @@ -1015,7 +1041,7 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -1015,7 +1041,7 @@ const makeCheckpointService = Effect.gen(function* () { |
| 1015 | staleTurnCounts, | 1041 | staleTurnCounts, |
| 1016 | (turnCount) => | 1042 | (turnCount) => |
| 1017 | checkpointRepository.getCheckpoint({ | 1043 | checkpointRepository.getCheckpoint({ |
| 1018 | providerSessionId: input.sessionId, | 1044 | threadId, |
| 1019 | turnCount, | 1045 | turnCount, |
| 1020 | }), | 1046 | }), |
| 1021 | { discard: false }, | 1047 | { discard: false }, |
| ... | @@ -1034,11 +1060,11 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -1034,11 +1060,11 @@ const makeCheckpointService = Effect.gen(function* () { |
| 1034 | checkpointRefs: staleCheckpointRefs, | 1060 | checkpointRefs: staleCheckpointRefs, |
| 1035 | }); | 1061 | }); |
| 1036 | yield* checkpointRepository.deleteAfterTurn({ | 1062 | yield* checkpointRepository.deleteAfterTurn({ |
| 1037 | providerSessionId: input.sessionId, | 1063 | threadId, |
| 1038 | maxTurnCount: input.turnCount, | 1064 | maxTurnCount: input.turnCount, |
| 1039 | }); | 1065 | }); |
| 1040 | const checkpoints = yield* checkpointRepository.listCheckpoints({ | 1066 | const checkpoints = yield* checkpointRepository.listCheckpoints({ |
| 1041 | providerSessionId: input.sessionId, | 1067 | threadId, |
| 1042 | }); | 1068 | }); |
| 1043 | const currentCheckpoint = | 1069 | const currentCheckpoint = |
| 1044 | checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | 1070 | checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? |
| ... | @@ -1047,7 +1073,7 @@ const makeCheckpointService = Effect.gen(function* () { | ... | @@ -1047,7 +1073,7 @@ const makeCheckpointService = Effect.gen(function* () { |
| 1047 | const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); | 1073 | const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); |
| 1048 | 1074 | ||
| 1049 | return { | 1075 | return { |
| 1050 | threadId: afterSnapshot.threadId, | 1076 | threadId, |
| 1051 | turnCount: currentCheckpoint?.turnCount ?? 0, | 1077 | turnCount: currentCheckpoint?.turnCount ?? 0, |
| 1052 | messageCount: currentCheckpoint?.messageCount ?? 0, | 1078 | messageCount: currentCheckpoint?.messageCount ?? 0, |
| 1053 | rolledBackTurns, | 1079 | rolledBackTurns, |
apps/server/src/index.ts+2| ... | @@ -11,6 +11,8 @@ import { createServer } from "./wsServer"; | ... | @@ -11,6 +11,8 @@ import { createServer } from "./wsServer"; |
| 11 | fixPath(); | 11 | fixPath(); |
| 12 | 12 | ||
| 13 | const DEFAULT_PORT = 3773; | 13 | const DEFAULT_PORT = 3773; |
| 14 | // Dummy comment for a no-op change request. | ||
| 15 | // Additional dummy comment requested in chat. | ||
| 14 | const cwd = process.cwd(); | 16 | const cwd = process.cwd(); |
| 15 | const logger = createLogger("server"); | 17 | const logger = createLogger("server"); |
| 16 | 18 |
apps/server/src/persistence/Layers/Checkpoints.test.ts+101-68| ... | @@ -11,72 +11,105 @@ function makeCheckpointRepositoryLayer() { | ... | @@ -11,72 +11,105 @@ function makeCheckpointRepositoryLayer() { |
| 11 | 11 | ||
| 12 | it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => | 12 | it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => |
| 13 | Effect.gen(function* () { | 13 | Effect.gen(function* () { |
| 14 | yield* Effect.gen(function* () { | 14 | const repository = yield* CheckpointRepository; |
| 15 | const repository = yield* CheckpointRepository; | 15 | |
| 16 | 16 | yield* repository.upsertCheckpoint({ | |
| 17 | yield* repository.upsertCheckpoint({ | 17 | providerSessionId: "sess-1", |
| 18 | providerSessionId: "sess-1", | 18 | threadId: "thread-1", |
| 19 | threadId: "thread-1", | 19 | checkpointId: "root", |
| 20 | checkpointId: "root", | 20 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/0", |
| 21 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/0", | 21 | turnCount: 0, |
| 22 | turnCount: 0, | 22 | messageCount: 0, |
| 23 | messageCount: 0, | 23 | label: "Start of conversation", |
| 24 | label: "Start of conversation", | 24 | createdAt: new Date().toISOString(), |
| 25 | createdAt: new Date().toISOString(), | 25 | }); |
| 26 | }); | 26 | |
| 27 | 27 | yield* repository.upsertCheckpoint({ | |
| 28 | yield* repository.upsertCheckpoint({ | 28 | providerSessionId: "sess-1", |
| 29 | providerSessionId: "sess-1", | 29 | threadId: "thread-1", |
| 30 | threadId: "thread-1", | 30 | checkpointId: "turn-1", |
| 31 | checkpointId: "turn-1", | 31 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", |
| 32 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", | 32 | turnCount: 1, |
| 33 | turnCount: 1, | 33 | messageCount: 2, |
| 34 | messageCount: 2, | 34 | label: "Turn 1", |
| 35 | label: "Turn 1", | 35 | preview: "hello", |
| 36 | preview: "hello", | 36 | createdAt: new Date().toISOString(), |
| 37 | createdAt: new Date().toISOString(), | 37 | }); |
| 38 | }); | 38 | |
| 39 | 39 | yield* repository.upsertCheckpoint({ | |
| 40 | yield* repository.upsertCheckpoint({ | 40 | providerSessionId: "sess-1", |
| 41 | providerSessionId: "sess-1", | 41 | threadId: "thread-1", |
| 42 | threadId: "thread-1", | 42 | checkpointId: "turn-2", |
| 43 | checkpointId: "turn-2", | 43 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", |
| 44 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", | 44 | turnCount: 2, |
| 45 | turnCount: 2, | 45 | messageCount: 4, |
| 46 | messageCount: 4, | 46 | label: "Turn 2", |
| 47 | label: "Turn 2", | 47 | preview: "done", |
| 48 | preview: "done", | 48 | createdAt: new Date().toISOString(), |
| 49 | createdAt: new Date().toISOString(), | 49 | }); |
| 50 | }); | 50 | |
| 51 | 51 | const checkpoints = yield* repository.listCheckpoints({ threadId: "thread-1" }); | |
| 52 | const checkpoints = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); | 52 | assert.equal(checkpoints.length, 3); |
| 53 | assert.equal(checkpoints.length, 3); | 53 | assert.equal(checkpoints[0]?.id, "root"); |
| 54 | assert.equal(checkpoints[0]?.id, "root"); | 54 | assert.equal(checkpoints[0]?.isCurrent, false); |
| 55 | assert.equal(checkpoints[0]?.isCurrent, false); | 55 | assert.equal(checkpoints[2]?.id, "turn-2"); |
| 56 | assert.equal(checkpoints[2]?.id, "turn-2"); | 56 | assert.equal(checkpoints[2]?.isCurrent, true); |
| 57 | assert.equal(checkpoints[2]?.isCurrent, true); | 57 | |
| 58 | 58 | const one = yield* repository.getCheckpoint({ threadId: "thread-1", turnCount: 1 }); | |
| 59 | const one = yield* repository.getCheckpoint({ providerSessionId: "sess-1", turnCount: 1 }); | 59 | assert.equal(Option.isSome(one), true); |
| 60 | assert.equal(Option.isSome(one), true); | 60 | if (Option.isSome(one)) { |
| 61 | if (Option.isSome(one)) { | 61 | assert.equal(one.value.id, "turn-1"); |
| 62 | assert.equal(one.value.id, "turn-1"); | 62 | assert.equal(one.value.isCurrent, false); |
| 63 | assert.equal(one.value.isCurrent, false); | 63 | assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1"); |
| 64 | assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1"); | 64 | } |
| 65 | } | 65 | |
| 66 | 66 | yield* repository.upsertCheckpoint({ | |
| 67 | yield* repository.deleteAfterTurn({ | 67 | providerSessionId: "sess-2", |
| 68 | providerSessionId: "sess-1", | 68 | threadId: "thread-1", |
| 69 | maxTurnCount: 1, | 69 | checkpointId: "turn-3", |
| 70 | }); | 70 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/3", |
| 71 | 71 | turnCount: 3, | |
| 72 | const pruned = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); | 72 | messageCount: 6, |
| 73 | assert.equal(pruned.length, 2); | 73 | label: "Turn 3", |
| 74 | assert.equal(pruned[1]?.turnCount, 1); | 74 | preview: "new session", |
| 75 | assert.equal(pruned[1]?.isCurrent, true); | 75 | createdAt: new Date().toISOString(), |
| 76 | 76 | }); | |
| 77 | yield* repository.deleteAllForSession({ providerSessionId: "sess-1" }); | 77 | |
| 78 | const empty = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); | 78 | const resumed = yield* repository.listCheckpoints({ threadId: "thread-1" }); |
| 79 | assert.equal(empty.length, 0); | 79 | assert.equal(resumed.length, 4); |
| 80 | }).pipe(Effect.provide(makeCheckpointRepositoryLayer())); | 80 | assert.equal(resumed[3]?.id, "turn-3"); |
| 81 | }), | 81 | assert.equal(resumed[3]?.isCurrent, true); |
| 82 | |||
| 83 | yield* repository.upsertCheckpoint({ | ||
| 84 | providerSessionId: "sess-3", | ||
| 85 | threadId: "thread-2", | ||
| 86 | checkpointId: "root", | ||
| 87 | checkpointRef: "refs/t3/checkpoints/thread-2/turn/0", | ||
| 88 | turnCount: 0, | ||
| 89 | messageCount: 0, | ||
| 90 | label: "Start of conversation", | ||
| 91 | createdAt: new Date().toISOString(), | ||
| 92 | }); | ||
| 93 | |||
| 94 | const otherThread = yield* repository.listCheckpoints({ threadId: "thread-2" }); | ||
| 95 | assert.equal(otherThread.length, 1); | ||
| 96 | assert.equal(otherThread[0]?.id, "root"); | ||
| 97 | |||
| 98 | yield* repository.deleteAfterTurn({ | ||
| 99 | threadId: "thread-1", | ||
| 100 | maxTurnCount: 1, | ||
| 101 | }); | ||
| 102 | |||
| 103 | const pruned = yield* repository.listCheckpoints({ threadId: "thread-1" }); | ||
| 104 | assert.equal(pruned.length, 2); | ||
| 105 | assert.equal(pruned[1]?.turnCount, 1); | ||
| 106 | assert.equal(pruned[1]?.isCurrent, true); | ||
| 107 | |||
| 108 | yield* repository.deleteAllForThread({ threadId: "thread-1" }); | ||
| 109 | const empty = yield* repository.listCheckpoints({ threadId: "thread-1" }); | ||
| 110 | assert.equal(empty.length, 0); | ||
| 111 | |||
| 112 | const stillPresent = yield* repository.listCheckpoints({ threadId: "thread-2" }); | ||
| 113 | assert.equal(stillPresent.length, 1); | ||
| 114 | }).pipe(Effect.provide(makeCheckpointRepositoryLayer())), | ||
| 82 | ); | 115 | ); |
apps/server/src/persistence/Layers/Checkpoints.ts+29-28| ... | @@ -29,7 +29,7 @@ import { | ... | @@ -29,7 +29,7 @@ import { |
| 29 | const NonEmptyString = Schema.NonEmptyString; | 29 | const NonEmptyString = Schema.NonEmptyString; |
| 30 | 30 | ||
| 31 | const CheckpointRowSchema = Schema.Struct({ | 31 | const CheckpointRowSchema = Schema.Struct({ |
| 32 | providerSessionId: Schema.String, | 32 | providerSessionId: Schema.NullOr(Schema.String), |
| 33 | threadId: Schema.String, | 33 | threadId: Schema.String, |
| 34 | checkpointId: Schema.String, | 34 | checkpointId: Schema.String, |
| 35 | checkpointRef: Schema.String, | 35 | checkpointRef: Schema.String, |
| ... | @@ -40,12 +40,12 @@ const CheckpointRowSchema = Schema.Struct({ | ... | @@ -40,12 +40,12 @@ const CheckpointRowSchema = Schema.Struct({ |
| 40 | createdAt: Schema.String, | 40 | createdAt: Schema.String, |
| 41 | }); | 41 | }); |
| 42 | 42 | ||
| 43 | const SessionRequestSchema = Schema.Struct({ | 43 | const ThreadRequestSchema = Schema.Struct({ |
| 44 | providerSessionId: NonEmptyString, | 44 | threadId: NonEmptyString, |
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | const UpsertRequestSchema = Schema.Struct({ | 47 | const UpsertRequestSchema = Schema.Struct({ |
| 48 | providerSessionId: NonEmptyString, | 48 | providerSessionId: Schema.NullOr(NonEmptyString), |
| 49 | threadId: NonEmptyString, | 49 | threadId: NonEmptyString, |
| 50 | checkpointId: NonEmptyString, | 50 | checkpointId: NonEmptyString, |
| 51 | checkpointRef: NonEmptyString, | 51 | checkpointRef: NonEmptyString, |
| ... | @@ -57,12 +57,12 @@ const UpsertRequestSchema = Schema.Struct({ | ... | @@ -57,12 +57,12 @@ const UpsertRequestSchema = Schema.Struct({ |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| 59 | const GetCheckpointRequestSchema = Schema.Struct({ | 59 | const GetCheckpointRequestSchema = Schema.Struct({ |
| 60 | providerSessionId: NonEmptyString, | 60 | threadId: NonEmptyString, |
| 61 | turnCount: Schema.Int, | 61 | turnCount: Schema.Int, |
| 62 | }); | 62 | }); |
| 63 | 63 | ||
| 64 | const DeleteAfterTurnRequestSchema = Schema.Struct({ | 64 | const DeleteAfterTurnRequestSchema = Schema.Struct({ |
| 65 | providerSessionId: NonEmptyString, | 65 | threadId: NonEmptyString, |
| 66 | maxTurnCount: Schema.Int, | 66 | maxTurnCount: Schema.Int, |
| 67 | }); | 67 | }); |
| 68 | 68 | ||
| ... | @@ -176,8 +176,9 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -176,8 +176,9 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 176 | ${request.preview}, | 176 | ${request.preview}, |
| 177 | ${request.createdAt} | 177 | ${request.createdAt} |
| 178 | ) | 178 | ) |
| 179 | ON CONFLICT (provider_session_id, turn_count) | 179 | ON CONFLICT (thread_id, turn_count) |
| 180 | DO UPDATE SET | 180 | DO UPDATE SET |
| 181 | provider_session_id = excluded.provider_session_id, | ||
| 181 | thread_id = excluded.thread_id, | 182 | thread_id = excluded.thread_id, |
| 182 | checkpoint_id = excluded.checkpoint_id, | 183 | checkpoint_id = excluded.checkpoint_id, |
| 183 | checkpoint_ref = excluded.checkpoint_ref, | 184 | checkpoint_ref = excluded.checkpoint_ref, |
| ... | @@ -199,9 +200,9 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -199,9 +200,9 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 199 | }); | 200 | }); |
| 200 | 201 | ||
| 201 | const listCheckpointRows = SqlSchema.findAll({ | 202 | const listCheckpointRows = SqlSchema.findAll({ |
| 202 | Request: SessionRequestSchema, | 203 | Request: ThreadRequestSchema, |
| 203 | Result: CheckpointRowSchema, | 204 | Result: CheckpointRowSchema, |
| 204 | execute: ({ providerSessionId }) => | 205 | execute: ({ threadId }) => |
| 205 | sql` | 206 | sql` |
| 206 | SELECT | 207 | SELECT |
| 207 | provider_session_id AS "providerSessionId", | 208 | provider_session_id AS "providerSessionId", |
| ... | @@ -214,15 +215,15 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -214,15 +215,15 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 214 | preview, | 215 | preview, |
| 215 | created_at AS "createdAt" | 216 | created_at AS "createdAt" |
| 216 | FROM provider_checkpoints | 217 | FROM provider_checkpoints |
| 217 | WHERE provider_session_id = ${providerSessionId} | 218 | WHERE thread_id = ${threadId} |
| 218 | ORDER BY turn_count ASC | 219 | ORDER BY turn_count ASC |
| 219 | `, | 220 | `, |
| 220 | }); | 221 | }); |
| 221 | 222 | ||
| 222 | const listForSession = ( | 223 | const listForThread = ( |
| 223 | providerSessionId: string, | 224 | threadId: string, |
| 224 | ): Effect.Effect<ReadonlyArray<CheckpointRepositoryEntry>, CheckpointRepositoryError> => | 225 | ): Effect.Effect<ReadonlyArray<CheckpointRepositoryEntry>, CheckpointRepositoryError> => |
| 225 | listCheckpointRows({ providerSessionId }).pipe( | 226 | listCheckpointRows({ threadId }).pipe( |
| 226 | Effect.mapError((cause) => | 227 | Effect.mapError((cause) => |
| 227 | toPersistenceError("CheckpointRepository.listCheckpoints:query", cause), | 228 | toPersistenceError("CheckpointRepository.listCheckpoints:query", cause), |
| 228 | ), | 229 | ), |
| ... | @@ -238,7 +239,7 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -238,7 +239,7 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 238 | const parsed = yield* decodeInput( | 239 | const parsed = yield* decodeInput( |
| 239 | UpsertRequestSchema, | 240 | UpsertRequestSchema, |
| 240 | { | 241 | { |
| 241 | providerSessionId: input.providerSessionId.trim(), | 242 | providerSessionId: input.providerSessionId?.trim() || null, |
| 242 | threadId: input.threadId.trim(), | 243 | threadId: input.threadId.trim(), |
| 243 | checkpointId: input.checkpointId.trim(), | 244 | checkpointId: input.checkpointId.trim(), |
| 244 | checkpointRef: input.checkpointRef.trim(), | 245 | checkpointRef: input.checkpointRef.trim(), |
| ... | @@ -264,14 +265,14 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -264,14 +265,14 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 264 | const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) => | 265 | const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) => |
| 265 | Effect.gen(function* () { | 266 | Effect.gen(function* () { |
| 266 | const parsed = yield* decodeInput( | 267 | const parsed = yield* decodeInput( |
| 267 | SessionRequestSchema, | 268 | ThreadRequestSchema, |
| 268 | { | 269 | { |
| 269 | providerSessionId: input.providerSessionId.trim(), | 270 | threadId: input.threadId.trim(), |
| 270 | }, | 271 | }, |
| 271 | "CheckpointRepository.listCheckpoints", | 272 | "CheckpointRepository.listCheckpoints", |
| 272 | ); | 273 | ); |
| 273 | 274 | ||
| 274 | const entries = yield* listForSession(parsed.providerSessionId); | 275 | const entries = yield* listForThread(parsed.threadId); |
| 275 | return entries.map(toProviderCheckpoint); | 276 | return entries.map(toProviderCheckpoint); |
| 276 | }); | 277 | }); |
| 277 | 278 | ||
| ... | @@ -280,7 +281,7 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -280,7 +281,7 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 280 | const parsed = yield* decodeInput( | 281 | const parsed = yield* decodeInput( |
| 281 | GetCheckpointRequestSchema, | 282 | GetCheckpointRequestSchema, |
| 282 | { | 283 | { |
| 283 | providerSessionId: input.providerSessionId.trim(), | 284 | threadId: input.threadId.trim(), |
| 284 | turnCount: input.turnCount, | 285 | turnCount: input.turnCount, |
| 285 | }, | 286 | }, |
| 286 | "CheckpointRepository.getCheckpoint", | 287 | "CheckpointRepository.getCheckpoint", |
| ... | @@ -291,7 +292,7 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -291,7 +292,7 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 291 | parsed.turnCount, | 292 | parsed.turnCount, |
| 292 | ); | 293 | ); |
| 293 | 294 | ||
| 294 | const entries = yield* listForSession(parsed.providerSessionId); | 295 | const entries = yield* listForThread(parsed.threadId); |
| 295 | const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount); | 296 | const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount); |
| 296 | return Option.fromNullishOr(checkpoint); | 297 | return Option.fromNullishOr(checkpoint); |
| 297 | }); | 298 | }); |
| ... | @@ -301,7 +302,7 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -301,7 +302,7 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 301 | const parsed = yield* decodeInput( | 302 | const parsed = yield* decodeInput( |
| 302 | DeleteAfterTurnRequestSchema, | 303 | DeleteAfterTurnRequestSchema, |
| 303 | { | 304 | { |
| 304 | providerSessionId: input.providerSessionId.trim(), | 305 | threadId: input.threadId.trim(), |
| 305 | maxTurnCount: input.maxTurnCount, | 306 | maxTurnCount: input.maxTurnCount, |
| 306 | }, | 307 | }, |
| 307 | "CheckpointRepository.deleteAfterTurn", | 308 | "CheckpointRepository.deleteAfterTurn", |
| ... | @@ -314,7 +315,7 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -314,7 +315,7 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 314 | 315 | ||
| 315 | yield* sql` | 316 | yield* sql` |
| 316 | DELETE FROM provider_checkpoints | 317 | DELETE FROM provider_checkpoints |
| 317 | WHERE provider_session_id = ${parsed.providerSessionId} | 318 | WHERE thread_id = ${parsed.threadId} |
| 318 | AND turn_count > ${parsed.maxTurnCount} | 319 | AND turn_count > ${parsed.maxTurnCount} |
| 319 | `.pipe( | 320 | `.pipe( |
| 320 | Effect.mapError((cause) => | 321 | Effect.mapError((cause) => |
| ... | @@ -323,22 +324,22 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -323,22 +324,22 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 323 | ); | 324 | ); |
| 324 | }); | 325 | }); |
| 325 | 326 | ||
| 326 | const deleteAllForSession: CheckpointRepositoryShape["deleteAllForSession"] = (input) => | 327 | const deleteAllForThread: CheckpointRepositoryShape["deleteAllForThread"] = (input) => |
| 327 | Effect.gen(function* () { | 328 | Effect.gen(function* () { |
| 328 | const parsed = yield* decodeInput( | 329 | const parsed = yield* decodeInput( |
| 329 | SessionRequestSchema, | 330 | ThreadRequestSchema, |
| 330 | { | 331 | { |
| 331 | providerSessionId: input.providerSessionId.trim(), | 332 | threadId: input.threadId.trim(), |
| 332 | }, | 333 | }, |
| 333 | "CheckpointRepository.deleteAllForSession", | 334 | "CheckpointRepository.deleteAllForThread", |
| 334 | ); | 335 | ); |
| 335 | 336 | ||
| 336 | yield* sql` | 337 | yield* sql` |
| 337 | DELETE FROM provider_checkpoints | 338 | DELETE FROM provider_checkpoints |
| 338 | WHERE provider_session_id = ${parsed.providerSessionId} | 339 | WHERE thread_id = ${parsed.threadId} |
| 339 | `.pipe( | 340 | `.pipe( |
| 340 | Effect.mapError((cause) => | 341 | Effect.mapError((cause) => |
| 341 | toPersistenceError("CheckpointRepository.deleteAllForSession:query", cause), | 342 | toPersistenceError("CheckpointRepository.deleteAllForThread:query", cause), |
| 342 | ), | 343 | ), |
| 343 | ); | 344 | ); |
| 344 | }); | 345 | }); |
| ... | @@ -348,7 +349,7 @@ const makeCheckpointRepository = Effect.gen(function* () { | ... | @@ -348,7 +349,7 @@ const makeCheckpointRepository = Effect.gen(function* () { |
| 348 | listCheckpoints, | 349 | listCheckpoints, |
| 349 | getCheckpoint, | 350 | getCheckpoint, |
| 350 | deleteAfterTurn, | 351 | deleteAfterTurn, |
| 351 | deleteAllForSession, | 352 | deleteAllForThread, |
| 352 | } satisfies CheckpointRepositoryShape; | 353 | } satisfies CheckpointRepositoryShape; |
| 353 | }); | 354 | }); |
| 354 | 355 |
apps/server/src/persistence/Layers/Projects.ts+1-1| ... | @@ -333,7 +333,7 @@ const makeRepository = Effect.gen(function* () { | ... | @@ -333,7 +333,7 @@ const makeRepository = Effect.gen(function* () { |
| 333 | const input = yield* decodeAddInput(rawInput); | 333 | const input = yield* decodeAddInput(rawInput); |
| 334 | const normalizedCwd = normalizeCwd(input.cwd); | 334 | const normalizedCwd = normalizeCwd(input.cwd); |
| 335 | if (!isDirectory(normalizedCwd)) { | 335 | if (!isDirectory(normalizedCwd)) { |
| 336 | return yield* Effect.fail(new ProjectPathMissingError({ cwd: normalizedCwd })); | 336 | return yield* new ProjectPathMissingError({ cwd: normalizedCwd }); |
| 337 | } | 337 | } |
| 338 | 338 | ||
| 339 | const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe( | 339 | const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe( |
apps/server/src/persistence/Layers/Sqlite.ts+33-4| ... | @@ -1,11 +1,34 @@ | ... | @@ -1,11 +1,34 @@ |
| 1 | import { Effect, Layer, FileSystem, Path } from "effect"; | 1 | import { Effect, Layer, FileSystem, Path } from "effect"; |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | 3 | ||
| 3 | import { runMigrations } from "../Migrations.ts"; | 4 | import { runMigrations } from "../Migrations.ts"; |
| 4 | import * as SqliteClient from "../SqliteClient.ts"; | 5 | |
| 6 | type RuntimeSqliteLayerConfig = { | ||
| 7 | readonly filename: string; | ||
| 8 | readonly disableWAL?: boolean | undefined; | ||
| 9 | }; | ||
| 10 | |||
| 11 | type Loader = { | ||
| 12 | layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient>; | ||
| 13 | }; | ||
| 14 | const defaultSqliteClientLoaders = { | ||
| 15 | bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), | ||
| 16 | node: () => import("../NodeSqliteClient.ts"), | ||
| 17 | } satisfies Record<string, () => Promise<Loader>>; | ||
| 18 | |||
| 19 | const makeRuntimeSqliteLayer = ( | ||
| 20 | config: RuntimeSqliteLayerConfig, | ||
| 21 | ): Layer.Layer<SqlClient.SqlClient> => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const runtime = process.versions.bun !== undefined ? "bun" : "node"; | ||
| 24 | const loader = defaultSqliteClientLoaders[runtime]; | ||
| 25 | const clientModule = yield* Effect.promise<Loader>(loader); | ||
| 26 | return clientModule.layer(config); | ||
| 27 | }).pipe(Layer.unwrap); | ||
| 5 | 28 | ||
| 6 | const setup = Layer.effectDiscard( | 29 | const setup = Layer.effectDiscard( |
| 7 | Effect.gen(function* () { | 30 | Effect.gen(function* () { |
| 8 | const sql = yield* SqliteClient.SqliteClient; | 31 | const sql = yield* SqlClient.SqlClient; |
| 9 | yield* sql`PRAGMA journal_mode = WAL;`; | 32 | yield* sql`PRAGMA journal_mode = WAL;`; |
| 10 | yield* sql`PRAGMA foreign_keys = ON;`; | 33 | yield* sql`PRAGMA foreign_keys = ON;`; |
| 11 | yield* runMigrations; | 34 | yield* runMigrations; |
| ... | @@ -18,7 +41,13 @@ export const makeSqlitePersistenceLive = (dbPath: string) => | ... | @@ -18,7 +41,13 @@ export const makeSqlitePersistenceLive = (dbPath: string) => |
| 18 | const path = yield* Path.Path; | 41 | const path = yield* Path.Path; |
| 19 | yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); | 42 | yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); |
| 20 | 43 | ||
| 21 | return Layer.provideMerge(setup, SqliteClient.layer({ filename: dbPath })); | 44 | return Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: dbPath })); |
| 22 | }).pipe(Layer.unwrap); | 45 | }).pipe(Layer.unwrap); |
| 23 | 46 | ||
| 24 | export const SqlitePersistenceMemory = Layer.provideMerge(setup, SqliteClient.layerMemory()); | 47 | export const SqlitePersistenceMemory = Layer.provideMerge( |
| 48 | setup, | ||
| 49 | makeRuntimeSqliteLayer({ | ||
| 50 | filename: ":memory:", | ||
| 51 | disableWAL: true, | ||
| 52 | }), | ||
| 53 | ); |
apps/server/src/persistence/Migrations/003_Checkpoints.ts+9-4| ... | @@ -6,7 +6,7 @@ export default Effect.gen(function* () { | ... | @@ -6,7 +6,7 @@ export default Effect.gen(function* () { |
| 6 | 6 | ||
| 7 | yield* sql` | 7 | yield* sql` |
| 8 | CREATE TABLE IF NOT EXISTS provider_checkpoints ( | 8 | CREATE TABLE IF NOT EXISTS provider_checkpoints ( |
| 9 | provider_session_id TEXT NOT NULL, | 9 | provider_session_id TEXT, |
| 10 | thread_id TEXT NOT NULL, | 10 | thread_id TEXT NOT NULL, |
| 11 | checkpoint_id TEXT NOT NULL, | 11 | checkpoint_id TEXT NOT NULL, |
| 12 | checkpoint_ref TEXT NOT NULL, | 12 | checkpoint_ref TEXT NOT NULL, |
| ... | @@ -15,12 +15,17 @@ export default Effect.gen(function* () { | ... | @@ -15,12 +15,17 @@ export default Effect.gen(function* () { |
| 15 | label TEXT NOT NULL, | 15 | label TEXT NOT NULL, |
| 16 | preview TEXT, | 16 | preview TEXT, |
| 17 | created_at TEXT NOT NULL, | 17 | created_at TEXT NOT NULL, |
| 18 | PRIMARY KEY (provider_session_id, turn_count) | 18 | PRIMARY KEY (thread_id, turn_count) |
| 19 | ) | 19 | ) |
| 20 | `; | 20 | `; |
| 21 | 21 | ||
| 22 | yield* sql` | 22 | yield* sql` |
| 23 | CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session_turn | 23 | CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_thread_turn |
| 24 | ON provider_checkpoints(provider_session_id, turn_count ASC) | 24 | ON provider_checkpoints(thread_id, turn_count ASC) |
| 25 | `; | ||
| 26 | |||
| 27 | yield* sql` | ||
| 28 | CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session | ||
| 29 | ON provider_checkpoints(provider_session_id) | ||
| 25 | `; | 30 | `; |
| 26 | }); | 31 | }); |
apps/server/src/persistence/NodeSqliteClient.test.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 4 | |||
| 5 | import * as SqliteClient from "./NodeSqliteClient.ts"; | ||
| 6 | |||
| 7 | it.effect("runs prepared queries and returns positional values", () => | ||
| 8 | Effect.gen(function* () { | ||
| 9 | const sql = yield* SqlClient.SqlClient; | ||
| 10 | |||
| 11 | yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; | ||
| 12 | yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; | ||
| 13 | |||
| 14 | const rows = yield* sql<{ readonly id: number; readonly name: string }>` | ||
| 15 | SELECT id, name FROM entries ORDER BY id | ||
| 16 | `; | ||
| 17 | assert.equal(rows.length, 2); | ||
| 18 | assert.equal(rows[0]?.name, "alpha"); | ||
| 19 | assert.equal(rows[1]?.name, "beta"); | ||
| 20 | |||
| 21 | const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; | ||
| 22 | assert.equal(values.length, 2); | ||
| 23 | assert.equal(values[0]?.[1], "alpha"); | ||
| 24 | assert.equal(values[1]?.[1], "beta"); | ||
| 25 | }).pipe(Effect.provide(SqliteClient.layerMemory())), | ||
| 26 | ); | ||
apps/server/src/persistence/NodeSqliteClient.ts created+242| ... | @@ -0,0 +1,242 @@ | ||
| 1 | /** | ||
| 2 | * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` | ||
| 3 | * bindings instead of `better-sqlite3`. | ||
| 4 | */ | ||
| 5 | import { DatabaseSync, type StatementSync } from "node:sqlite"; | ||
| 6 | |||
| 7 | import * as Cache from "effect/Cache"; | ||
| 8 | import * as Config from "effect/Config"; | ||
| 9 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import { identity } from "effect/Function"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Scope from "effect/Scope"; | ||
| 15 | import * as Semaphore from "effect/Semaphore"; | ||
| 16 | import * as ServiceMap from "effect/ServiceMap"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import * as Reactivity from "effect/unstable/reactivity/Reactivity"; | ||
| 19 | import * as Client from "effect/unstable/sql/SqlClient"; | ||
| 20 | import type { Connection } from "effect/unstable/sql/SqlConnection"; | ||
| 21 | import { SqlError } from "effect/unstable/sql/SqlError"; | ||
| 22 | import * as Statement from "effect/unstable/sql/Statement"; | ||
| 23 | |||
| 24 | const ATTR_DB_SYSTEM_NAME = "db.system.name"; | ||
| 25 | |||
| 26 | export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; | ||
| 27 | |||
| 28 | export type TypeId = "~local/sqlite-node/SqliteClient"; | ||
| 29 | |||
| 30 | export const SqliteClient = ServiceMap.Service<Client.SqlClient>("@local/sqlite-node/SqliteClient"); | ||
| 31 | |||
| 32 | export interface SqliteClientConfig { | ||
| 33 | readonly filename: string; | ||
| 34 | readonly readonly?: boolean | undefined; | ||
| 35 | readonly allowExtension?: boolean | undefined; | ||
| 36 | readonly prepareCacheSize?: number | undefined; | ||
| 37 | readonly prepareCacheTTL?: Duration.Input | undefined; | ||
| 38 | readonly disableWAL?: boolean | undefined; | ||
| 39 | readonly spanAttributes?: Record<string, unknown> | undefined; | ||
| 40 | readonly transformResultNames?: ((str: string) => string) | undefined; | ||
| 41 | readonly transformQueryNames?: ((str: string) => string) | undefined; | ||
| 42 | } | ||
| 43 | |||
| 44 | export interface SqliteMemoryClientConfig extends Omit< | ||
| 45 | SqliteClientConfig, | ||
| 46 | "filename" | "readonly" | ||
| 47 | > {} | ||
| 48 | |||
| 49 | const makeWithDatabase = ( | ||
| 50 | options: SqliteClientConfig, | ||
| 51 | openDatabase: () => DatabaseSync, | ||
| 52 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); | ||
| 55 | const transformRows = options.transformResultNames | ||
| 56 | ? Statement.defaultTransforms(options.transformResultNames).array | ||
| 57 | : undefined; | ||
| 58 | |||
| 59 | const makeConnection = Effect.gen(function* () { | ||
| 60 | const scope = yield* Effect.scope; | ||
| 61 | const db = openDatabase(); | ||
| 62 | yield* Scope.addFinalizer( | ||
| 63 | scope, | ||
| 64 | Effect.sync(() => db.close()), | ||
| 65 | ); | ||
| 66 | |||
| 67 | if (options.disableWAL !== true) { | ||
| 68 | db.exec("PRAGMA journal_mode = WAL"); | ||
| 69 | } | ||
| 70 | |||
| 71 | const statementReaderCache = new WeakMap<StatementSync, boolean>(); | ||
| 72 | const hasRows = (statement: StatementSync): boolean => { | ||
| 73 | const cached = statementReaderCache.get(statement); | ||
| 74 | if (cached !== undefined) { | ||
| 75 | return cached; | ||
| 76 | } | ||
| 77 | const value = statement.columns().length > 0; | ||
| 78 | statementReaderCache.set(statement, value); | ||
| 79 | return value; | ||
| 80 | }; | ||
| 81 | |||
| 82 | const prepareCache = yield* Cache.make({ | ||
| 83 | capacity: options.prepareCacheSize ?? 200, | ||
| 84 | timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), | ||
| 85 | lookup: (sql: string) => | ||
| 86 | Effect.try({ | ||
| 87 | try: () => db.prepare(sql), | ||
| 88 | catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), | ||
| 89 | }), | ||
| 90 | }); | ||
| 91 | |||
| 92 | const runStatement = ( | ||
| 93 | statement: StatementSync, | ||
| 94 | params: ReadonlyArray<unknown>, | ||
| 95 | raw: boolean, | ||
| 96 | ) => | ||
| 97 | Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => { | ||
| 98 | statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); | ||
| 99 | try { | ||
| 100 | if (hasRows(statement)) { | ||
| 101 | return Effect.succeed(statement.all(...(params as any))); | ||
| 102 | } | ||
| 103 | const result = statement.run(...(params as any)); | ||
| 104 | return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []); | ||
| 105 | } catch (cause) { | ||
| 106 | return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); | ||
| 107 | } | ||
| 108 | }); | ||
| 109 | |||
| 110 | const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) => | ||
| 111 | Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); | ||
| 112 | |||
| 113 | const runValues = (sql: string, params: ReadonlyArray<unknown>) => | ||
| 114 | Effect.acquireUseRelease( | ||
| 115 | Cache.get(prepareCache, sql), | ||
| 116 | (statement) => | ||
| 117 | Effect.try({ | ||
| 118 | try: () => { | ||
| 119 | if (hasRows(statement)) { | ||
| 120 | statement.setReturnArrays(true); | ||
| 121 | // Safe to cast to array after we've setReturnArrays(true) | ||
| 122 | return statement.all(...(params as any)) as unknown as ReadonlyArray< | ||
| 123 | ReadonlyArray<unknown> | ||
| 124 | >; | ||
| 125 | } | ||
| 126 | statement.run(...(params as any)); | ||
| 127 | return []; | ||
| 128 | }, | ||
| 129 | catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), | ||
| 130 | }), | ||
| 131 | (statement) => | ||
| 132 | Effect.sync(() => { | ||
| 133 | if (hasRows(statement)) { | ||
| 134 | statement.setReturnArrays(false); | ||
| 135 | } | ||
| 136 | }), | ||
| 137 | ); | ||
| 138 | |||
| 139 | return identity<Connection>({ | ||
| 140 | execute(sql, params, rowTransform) { | ||
| 141 | return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); | ||
| 142 | }, | ||
| 143 | executeRaw(sql, params) { | ||
| 144 | return run(sql, params, true); | ||
| 145 | }, | ||
| 146 | executeValues(sql, params) { | ||
| 147 | return runValues(sql, params); | ||
| 148 | }, | ||
| 149 | executeUnprepared(sql, params, rowTransform) { | ||
| 150 | const effect = runStatement(db.prepare(sql), params ?? [], false); | ||
| 151 | return rowTransform ? Effect.map(effect, rowTransform) : effect; | ||
| 152 | }, | ||
| 153 | executeStream(_sql, _params) { | ||
| 154 | return Stream.die("executeStream not implemented"); | ||
| 155 | }, | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | |||
| 159 | const semaphore = yield* Semaphore.make(1); | ||
| 160 | const connection = yield* makeConnection; | ||
| 161 | |||
| 162 | const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); | ||
| 163 | const transactionAcquirer = Effect.uninterruptibleMask((restore) => { | ||
| 164 | const fiber = Fiber.getCurrent()!; | ||
| 165 | const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); | ||
| 166 | return Effect.as( | ||
| 167 | Effect.tap(restore(semaphore.take(1)), () => | ||
| 168 | Scope.addFinalizer(scope, semaphore.release(1)), | ||
| 169 | ), | ||
| 170 | connection, | ||
| 171 | ); | ||
| 172 | }); | ||
| 173 | |||
| 174 | return yield* Client.make({ | ||
| 175 | acquirer, | ||
| 176 | compiler, | ||
| 177 | transactionAcquirer, | ||
| 178 | spanAttributes: [ | ||
| 179 | ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), | ||
| 180 | [ATTR_DB_SYSTEM_NAME, "sqlite"], | ||
| 181 | ], | ||
| 182 | transformRows, | ||
| 183 | }); | ||
| 184 | }); | ||
| 185 | |||
| 186 | const make = ( | ||
| 187 | options: SqliteClientConfig, | ||
| 188 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | ||
| 189 | makeWithDatabase( | ||
| 190 | options, | ||
| 191 | () => | ||
| 192 | new DatabaseSync(options.filename, { | ||
| 193 | readOnly: options.readonly ?? false, | ||
| 194 | allowExtension: options.allowExtension ?? false, | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | |||
| 198 | const makeMemory = ( | ||
| 199 | config: SqliteMemoryClientConfig = {}, | ||
| 200 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | ||
| 201 | makeWithDatabase( | ||
| 202 | { | ||
| 203 | ...config, | ||
| 204 | filename: ":memory:", | ||
| 205 | readonly: false, | ||
| 206 | disableWAL: config.disableWAL ?? true, | ||
| 207 | }, | ||
| 208 | () => { | ||
| 209 | const database = new DatabaseSync(":memory:", { | ||
| 210 | allowExtension: config.allowExtension ?? false, | ||
| 211 | }); | ||
| 212 | return database; | ||
| 213 | }, | ||
| 214 | ); | ||
| 215 | |||
| 216 | export const layerConfig = ( | ||
| 217 | config: Config.Wrap<SqliteClientConfig>, | ||
| 218 | ): Layer.Layer<Client.SqlClient, Config.ConfigError> => | ||
| 219 | Layer.effectServices( | ||
| 220 | Config.unwrap(config) | ||
| 221 | .asEffect() | ||
| 222 | .pipe( | ||
| 223 | Effect.flatMap(make), | ||
| 224 | Effect.map((client) => | ||
| 225 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | ||
| 226 | ), | ||
| 227 | ), | ||
| 228 | ).pipe(Layer.provide(Reactivity.layer)); | ||
| 229 | |||
| 230 | export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> => | ||
| 231 | Layer.effectServices( | ||
| 232 | Effect.map(make(config), (client) => | ||
| 233 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | ||
| 234 | ), | ||
| 235 | ).pipe(Layer.provide(Reactivity.layer)); | ||
| 236 | |||
| 237 | export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer<Client.SqlClient> => | ||
| 238 | Layer.effectServices( | ||
| 239 | Effect.map(makeMemory(config), (client) => | ||
| 240 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | ||
| 241 | ), | ||
| 242 | ).pipe(Layer.provide(Reactivity.layer)); | ||
apps/server/src/persistence/Services/Checkpoints.ts+13-13| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | * CheckpointRepository - Repository interface for checkpoint metadata persistence. | 2 | * CheckpointRepository - Repository interface for checkpoint metadata persistence. |
| 3 | * | 3 | * |
| 4 | * Stores and queries checkpoint metadata and checkpoint Git refs keyed by | 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 | 5 | * thread + turn count. It does not read/write Git refs itself and it |
| 6 | * does not coordinate provider rollback workflows. | 6 | * does not coordinate provider rollback workflows. |
| 7 | * | 7 | * |
| 8 | * @module CheckpointRepository | 8 | * @module CheckpointRepository |
| ... | @@ -14,7 +14,7 @@ import type { Effect } from "effect"; | ... | @@ -14,7 +14,7 @@ import type { Effect } from "effect"; |
| 14 | import type { CheckpointRepositoryError } from "../Errors.ts"; | 14 | import type { CheckpointRepositoryError } from "../Errors.ts"; |
| 15 | 15 | ||
| 16 | export interface UpsertCheckpointInput { | 16 | export interface UpsertCheckpointInput { |
| 17 | readonly providerSessionId: string; | 17 | readonly providerSessionId?: string; |
| 18 | readonly threadId: string; | 18 | readonly threadId: string; |
| 19 | readonly checkpointId: string; | 19 | readonly checkpointId: string; |
| 20 | readonly checkpointRef: string; | 20 | readonly checkpointRef: string; |
| ... | @@ -31,22 +31,22 @@ export interface CheckpointRepositoryEntry extends ProviderCheckpoint { | ... | @@ -31,22 +31,22 @@ export interface CheckpointRepositoryEntry extends ProviderCheckpoint { |
| 31 | readonly createdAt: string; | 31 | readonly createdAt: string; |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | export interface ListSessionCheckpointsInput { | 34 | export interface ListThreadCheckpointsInput { |
| 35 | readonly providerSessionId: string; | 35 | readonly threadId: string; |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | export interface GetCheckpointInput { | 38 | export interface GetCheckpointInput { |
| 39 | readonly providerSessionId: string; | 39 | readonly threadId: string; |
| 40 | readonly turnCount: number; | 40 | readonly turnCount: number; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | export interface DeleteAfterTurnInput { | 43 | export interface DeleteAfterTurnInput { |
| 44 | readonly providerSessionId: string; | 44 | readonly threadId: string; |
| 45 | readonly maxTurnCount: number; | 45 | readonly maxTurnCount: number; |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | export interface DeleteAllForSessionInput { | 48 | export interface DeleteAllForThreadInput { |
| 49 | readonly providerSessionId: string; | 49 | readonly threadId: string; |
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | export interface CheckpointRepositoryShape { | 52 | export interface CheckpointRepositoryShape { |
| ... | @@ -58,10 +58,10 @@ export interface CheckpointRepositoryShape { | ... | @@ -58,10 +58,10 @@ export interface CheckpointRepositoryShape { |
| 58 | ) => Effect.Effect<void, CheckpointRepositoryError>; | 58 | ) => Effect.Effect<void, CheckpointRepositoryError>; |
| 59 | 59 | ||
| 60 | /** | 60 | /** |
| 61 | * List user-facing checkpoints for a provider session. | 61 | * List user-facing checkpoints for a thread. |
| 62 | */ | 62 | */ |
| 63 | readonly listCheckpoints: ( | 63 | readonly listCheckpoints: ( |
| 64 | input: ListSessionCheckpointsInput, | 64 | input: ListThreadCheckpointsInput, |
| 65 | ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointRepositoryError>; | 65 | ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointRepositoryError>; |
| 66 | 66 | ||
| 67 | /** | 67 | /** |
| ... | @@ -79,10 +79,10 @@ export interface CheckpointRepositoryShape { | ... | @@ -79,10 +79,10 @@ export interface CheckpointRepositoryShape { |
| 79 | ) => Effect.Effect<void, CheckpointRepositoryError>; | 79 | ) => Effect.Effect<void, CheckpointRepositoryError>; |
| 80 | 80 | ||
| 81 | /** | 81 | /** |
| 82 | * Delete all checkpoint metadata for a provider session. | 82 | * Delete all checkpoint metadata for a thread. |
| 83 | */ | 83 | */ |
| 84 | readonly deleteAllForSession: ( | 84 | readonly deleteAllForThread: ( |
| 85 | input: DeleteAllForSessionInput, | 85 | input: DeleteAllForThreadInput, |
| 86 | ) => Effect.Effect<void, CheckpointRepositoryError>; | 86 | ) => Effect.Effect<void, CheckpointRepositoryError>; |
| 87 | } | 87 | } |
| 88 | 88 |
apps/server/src/persistence/SqliteClient.test.ts deleted-26| ... | @@ -1,26 +0,0 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 4 | |||
| 5 | import * as SqliteClient from "./SqliteClient.ts"; | ||
| 6 | |||
| 7 | it.effect("runs prepared queries and returns positional values", () => | ||
| 8 | Effect.gen(function* () { | ||
| 9 | const sql = yield* SqlClient.SqlClient; | ||
| 10 | |||
| 11 | yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; | ||
| 12 | yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; | ||
| 13 | |||
| 14 | const rows = yield* sql<{ readonly id: number; readonly name: string }>` | ||
| 15 | SELECT id, name FROM entries ORDER BY id | ||
| 16 | `; | ||
| 17 | assert.equal(rows.length, 2); | ||
| 18 | assert.equal(rows[0]?.name, "alpha"); | ||
| 19 | assert.equal(rows[1]?.name, "beta"); | ||
| 20 | |||
| 21 | const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; | ||
| 22 | assert.equal(values.length, 2); | ||
| 23 | assert.equal(values[0]?.[1], "alpha"); | ||
| 24 | assert.equal(values[1]?.[1], "beta"); | ||
| 25 | }).pipe(Effect.provide(SqliteClient.layerMemory())), | ||
| 26 | ); | ||
apps/server/src/persistence/SqliteClient.ts deleted-242| ... | @@ -1,242 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` | ||
| 3 | * bindings instead of `better-sqlite3`. | ||
| 4 | */ | ||
| 5 | import { DatabaseSync, type StatementSync } from "node:sqlite"; | ||
| 6 | |||
| 7 | import * as Cache from "effect/Cache"; | ||
| 8 | import * as Config from "effect/Config"; | ||
| 9 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import { identity } from "effect/Function"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Scope from "effect/Scope"; | ||
| 15 | import * as Semaphore from "effect/Semaphore"; | ||
| 16 | import * as ServiceMap from "effect/ServiceMap"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import * as Reactivity from "effect/unstable/reactivity/Reactivity"; | ||
| 19 | import * as Client from "effect/unstable/sql/SqlClient"; | ||
| 20 | import type { Connection } from "effect/unstable/sql/SqlConnection"; | ||
| 21 | import { SqlError } from "effect/unstable/sql/SqlError"; | ||
| 22 | import * as Statement from "effect/unstable/sql/Statement"; | ||
| 23 | |||
| 24 | const ATTR_DB_SYSTEM_NAME = "db.system.name"; | ||
| 25 | |||
| 26 | export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; | ||
| 27 | |||
| 28 | export type TypeId = "~local/sqlite-node/SqliteClient"; | ||
| 29 | |||
| 30 | export const SqliteClient = ServiceMap.Service<Client.SqlClient>("@local/sqlite-node/SqliteClient"); | ||
| 31 | |||
| 32 | export interface SqliteClientConfig { | ||
| 33 | readonly filename: string; | ||
| 34 | readonly readonly?: boolean | undefined; | ||
| 35 | readonly allowExtension?: boolean | undefined; | ||
| 36 | readonly prepareCacheSize?: number | undefined; | ||
| 37 | readonly prepareCacheTTL?: Duration.Input | undefined; | ||
| 38 | readonly disableWAL?: boolean | undefined; | ||
| 39 | readonly spanAttributes?: Record<string, unknown> | undefined; | ||
| 40 | readonly transformResultNames?: ((str: string) => string) | undefined; | ||
| 41 | readonly transformQueryNames?: ((str: string) => string) | undefined; | ||
| 42 | } | ||
| 43 | |||
| 44 | export interface SqliteMemoryClientConfig extends Omit< | ||
| 45 | SqliteClientConfig, | ||
| 46 | "filename" | "readonly" | ||
| 47 | > {} | ||
| 48 | |||
| 49 | const makeWithDatabase = ( | ||
| 50 | options: SqliteClientConfig, | ||
| 51 | openDatabase: () => DatabaseSync, | ||
| 52 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); | ||
| 55 | const transformRows = options.transformResultNames | ||
| 56 | ? Statement.defaultTransforms(options.transformResultNames).array | ||
| 57 | : undefined; | ||
| 58 | |||
| 59 | const makeConnection = Effect.gen(function* () { | ||
| 60 | const scope = yield* Effect.scope; | ||
| 61 | const db = openDatabase(); | ||
| 62 | yield* Scope.addFinalizer( | ||
| 63 | scope, | ||
| 64 | Effect.sync(() => db.close()), | ||
| 65 | ); | ||
| 66 | |||
| 67 | if (options.disableWAL !== true) { | ||
| 68 | db.exec("PRAGMA journal_mode = WAL"); | ||
| 69 | } | ||
| 70 | |||
| 71 | const statementReaderCache = new WeakMap<StatementSync, boolean>(); | ||
| 72 | const hasRows = (statement: StatementSync): boolean => { | ||
| 73 | const cached = statementReaderCache.get(statement); | ||
| 74 | if (cached !== undefined) { | ||
| 75 | return cached; | ||
| 76 | } | ||
| 77 | const value = statement.columns().length > 0; | ||
| 78 | statementReaderCache.set(statement, value); | ||
| 79 | return value; | ||
| 80 | }; | ||
| 81 | |||
| 82 | const prepareCache = yield* Cache.make({ | ||
| 83 | capacity: options.prepareCacheSize ?? 200, | ||
| 84 | timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), | ||
| 85 | lookup: (sql: string) => | ||
| 86 | Effect.try({ | ||
| 87 | try: () => db.prepare(sql), | ||
| 88 | catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), | ||
| 89 | }), | ||
| 90 | }); | ||
| 91 | |||
| 92 | const runStatement = ( | ||
| 93 | statement: StatementSync, | ||
| 94 | params: ReadonlyArray<unknown>, | ||
| 95 | raw: boolean, | ||
| 96 | ) => | ||
| 97 | Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => { | ||
| 98 | statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); | ||
| 99 | try { | ||
| 100 | if (hasRows(statement)) { | ||
| 101 | return Effect.succeed(statement.all(...(params as any))); | ||
| 102 | } | ||
| 103 | const result = statement.run(...(params as any)); | ||
| 104 | return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []); | ||
| 105 | } catch (cause) { | ||
| 106 | return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); | ||
| 107 | } | ||
| 108 | }); | ||
| 109 | |||
| 110 | const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) => | ||
| 111 | Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); | ||
| 112 | |||
| 113 | const runValues = (sql: string, params: ReadonlyArray<unknown>) => | ||
| 114 | Effect.acquireUseRelease( | ||
| 115 | Cache.get(prepareCache, sql), | ||
| 116 | (statement) => | ||
| 117 | Effect.try({ | ||
| 118 | try: () => { | ||
| 119 | if (hasRows(statement)) { | ||
| 120 | statement.setReturnArrays(true); | ||
| 121 | // Safe to cast to array after we've setReturnArrays(true) | ||
| 122 | return statement.all(...(params as any)) as unknown as ReadonlyArray< | ||
| 123 | ReadonlyArray<unknown> | ||
| 124 | >; | ||
| 125 | } | ||
| 126 | statement.run(...(params as any)); | ||
| 127 | return []; | ||
| 128 | }, | ||
| 129 | catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), | ||
| 130 | }), | ||
| 131 | (statement) => | ||
| 132 | Effect.sync(() => { | ||
| 133 | if (hasRows(statement)) { | ||
| 134 | statement.setReturnArrays(false); | ||
| 135 | } | ||
| 136 | }), | ||
| 137 | ); | ||
| 138 | |||
| 139 | return identity<Connection>({ | ||
| 140 | execute(sql, params, rowTransform) { | ||
| 141 | return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); | ||
| 142 | }, | ||
| 143 | executeRaw(sql, params) { | ||
| 144 | return run(sql, params, true); | ||
| 145 | }, | ||
| 146 | executeValues(sql, params) { | ||
| 147 | return runValues(sql, params); | ||
| 148 | }, | ||
| 149 | executeUnprepared(sql, params, rowTransform) { | ||
| 150 | const effect = runStatement(db.prepare(sql), params ?? [], false); | ||
| 151 | return rowTransform ? Effect.map(effect, rowTransform) : effect; | ||
| 152 | }, | ||
| 153 | executeStream(_sql, _params) { | ||
| 154 | return Stream.die("executeStream not implemented"); | ||
| 155 | }, | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | |||
| 159 | const semaphore = yield* Semaphore.make(1); | ||
| 160 | const connection = yield* makeConnection; | ||
| 161 | |||
| 162 | const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); | ||
| 163 | const transactionAcquirer = Effect.uninterruptibleMask((restore) => { | ||
| 164 | const fiber = Fiber.getCurrent()!; | ||
| 165 | const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); | ||
| 166 | return Effect.as( | ||
| 167 | Effect.tap(restore(semaphore.take(1)), () => | ||
| 168 | Scope.addFinalizer(scope, semaphore.release(1)), | ||
| 169 | ), | ||
| 170 | connection, | ||
| 171 | ); | ||
| 172 | }); | ||
| 173 | |||
| 174 | return yield* Client.make({ | ||
| 175 | acquirer, | ||
| 176 | compiler, | ||
| 177 | transactionAcquirer, | ||
| 178 | spanAttributes: [ | ||
| 179 | ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), | ||
| 180 | [ATTR_DB_SYSTEM_NAME, "sqlite"], | ||
| 181 | ], | ||
| 182 | transformRows, | ||
| 183 | }); | ||
| 184 | }); | ||
| 185 | |||
| 186 | const make = ( | ||
| 187 | options: SqliteClientConfig, | ||
| 188 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | ||
| 189 | makeWithDatabase( | ||
| 190 | options, | ||
| 191 | () => | ||
| 192 | new DatabaseSync(options.filename, { | ||
| 193 | readOnly: options.readonly ?? false, | ||
| 194 | allowExtension: options.allowExtension ?? false, | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | |||
| 198 | const makeMemory = ( | ||
| 199 | config: SqliteMemoryClientConfig = {}, | ||
| 200 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | ||
| 201 | makeWithDatabase( | ||
| 202 | { | ||
| 203 | ...config, | ||
| 204 | filename: ":memory:", | ||
| 205 | readonly: false, | ||
| 206 | disableWAL: config.disableWAL ?? true, | ||
| 207 | }, | ||
| 208 | () => { | ||
| 209 | const database = new DatabaseSync(":memory:", { | ||
| 210 | allowExtension: config.allowExtension ?? false, | ||
| 211 | }); | ||
| 212 | return database; | ||
| 213 | }, | ||
| 214 | ); | ||
| 215 | |||
| 216 | export const layerConfig = ( | ||
| 217 | config: Config.Wrap<SqliteClientConfig>, | ||
| 218 | ): Layer.Layer<Client.SqlClient, Config.ConfigError> => | ||
| 219 | Layer.effectServices( | ||
| 220 | Config.unwrap(config) | ||
| 221 | .asEffect() | ||
| 222 | .pipe( | ||
| 223 | Effect.flatMap(make), | ||
| 224 | Effect.map((client) => | ||
| 225 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | ||
| 226 | ), | ||
| 227 | ), | ||
| 228 | ).pipe(Layer.provide(Reactivity.layer)); | ||
| 229 | |||
| 230 | export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> => | ||
| 231 | Layer.effectServices( | ||
| 232 | Effect.map(make(config), (client) => | ||
| 233 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | ||
| 234 | ), | ||
| 235 | ).pipe(Layer.provide(Reactivity.layer)); | ||
| 236 | |||
| 237 | export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer<Client.SqlClient> => | ||
| 238 | Layer.effectServices( | ||
| 239 | Effect.map(makeMemory(config), (client) => | ||
| 240 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | ||
| 241 | ), | ||
| 242 | ).pipe(Layer.provide(Reactivity.layer)); | ||
apps/server/src/provider/Layers/ProviderService.test.ts+141-3| ... | @@ -55,7 +55,7 @@ function makeFakeCodexAdapter() { | ... | @@ -55,7 +55,7 @@ function makeFakeCodexAdapter() { |
| 55 | sessionId: `sess-${nextSession}`, | 55 | sessionId: `sess-${nextSession}`, |
| 56 | provider: "codex", | 56 | provider: "codex", |
| 57 | status: "ready", | 57 | status: "ready", |
| 58 | threadId: `thread-${nextSession}`, | 58 | threadId: input.resumeThreadId ?? `thread-${nextSession}`, |
| 59 | cwd: input.cwd ?? process.cwd(), | 59 | cwd: input.cwd ?? process.cwd(), |
| 60 | createdAt: now, | 60 | createdAt: now, |
| 61 | updatedAt: now, | 61 | updatedAt: now, |
| ... | @@ -281,7 +281,7 @@ function makeProviderServiceLayer() { | ... | @@ -281,7 +281,7 @@ function makeProviderServiceLayer() { |
| 281 | } | 281 | } |
| 282 | 282 | ||
| 283 | const routing = makeProviderServiceLayer(); | 283 | const routing = makeProviderServiceLayer(); |
| 284 | it.effect("ProviderServiceLive reconciles stale persisted sessions on startup", () => | 284 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => |
| 285 | Effect.gen(function* () { | 285 | Effect.gen(function* () { |
| 286 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); | 286 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); |
| 287 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | 287 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| ... | @@ -322,7 +322,100 @@ it.effect("ProviderServiceLive reconciles stale persisted sessions on startup", | ... | @@ -322,7 +322,100 @@ it.effect("ProviderServiceLive reconciles stale persisted sessions on startup", |
| 322 | yield* ProviderService; | 322 | yield* ProviderService; |
| 323 | }).pipe(Effect.provide(providerLayer)); | 323 | }).pipe(Effect.provide(providerLayer)); |
| 324 | 324 | ||
| 325 | assert.deepEqual(checkpoint.releaseSession.mock.calls, [[{ providerSessionId: "sess-stale" }]]); | 325 | assert.deepEqual(checkpoint.releaseSession.mock.calls, []); |
| 326 | const persistedProvider = yield* Effect.gen(function* () { | ||
| 327 | const directory = yield* ProviderSessionDirectory; | ||
| 328 | return yield* directory.getProvider("sess-stale"); | ||
| 329 | }).pipe(Effect.provide(directoryLayer)); | ||
| 330 | assert.equal(persistedProvider, "codex"); | ||
| 331 | |||
| 332 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 333 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 334 | ); | ||
| 335 | |||
| 336 | it.effect("ProviderServiceLive restores checkpoint RPC routing after restart using persisted thread mapping", () => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); | ||
| 339 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | ||
| 340 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | ||
| 341 | |||
| 342 | const checkpoint = makeCheckpointServiceDouble(); | ||
| 343 | const firstCodex = makeFakeCodexAdapter(); | ||
| 344 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { | ||
| 345 | getByProvider: (provider) => | ||
| 346 | provider === "codex" | ||
| 347 | ? Effect.succeed(firstCodex.adapter) | ||
| 348 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 349 | listProviders: () => Effect.succeed(["codex"]), | ||
| 350 | }; | ||
| 351 | |||
| 352 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( | ||
| 353 | Layer.provide(ProviderSessionRepositoryLive), | ||
| 354 | Layer.provide(persistenceLayer), | ||
| 355 | ); | ||
| 356 | const firstProviderLayer = ProviderServiceLive.pipe( | ||
| 357 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), | ||
| 358 | Layer.provide(firstDirectoryLayer), | ||
| 359 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | ||
| 360 | ); | ||
| 361 | |||
| 362 | const startedSession = yield* Effect.gen(function* () { | ||
| 363 | const provider = yield* ProviderService; | ||
| 364 | return yield* provider.startSession({ | ||
| 365 | provider: "codex", | ||
| 366 | cwd: "/tmp/project", | ||
| 367 | }); | ||
| 368 | }).pipe(Effect.provide(firstProviderLayer)); | ||
| 369 | |||
| 370 | yield* Effect.gen(function* () { | ||
| 371 | const provider = yield* ProviderService; | ||
| 372 | yield* provider.stopAll(); | ||
| 373 | }).pipe(Effect.provide(firstProviderLayer)); | ||
| 374 | |||
| 375 | const secondCodex = makeFakeCodexAdapter(); | ||
| 376 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { | ||
| 377 | getByProvider: (provider) => | ||
| 378 | provider === "codex" | ||
| 379 | ? Effect.succeed(secondCodex.adapter) | ||
| 380 | : Effect.fail(new ProviderUnsupportedError({ provider })), | ||
| 381 | listProviders: () => Effect.succeed(["codex"]), | ||
| 382 | }; | ||
| 383 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( | ||
| 384 | Layer.provide(ProviderSessionRepositoryLive), | ||
| 385 | Layer.provide(persistenceLayer), | ||
| 386 | ); | ||
| 387 | const secondProviderLayer = ProviderServiceLive.pipe( | ||
| 388 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), | ||
| 389 | Layer.provide(secondDirectoryLayer), | ||
| 390 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | ||
| 391 | ); | ||
| 392 | |||
| 393 | checkpoint.getCheckpointDiff.mockClear(); | ||
| 394 | secondCodex.startSession.mockClear(); | ||
| 395 | |||
| 396 | yield* Effect.gen(function* () { | ||
| 397 | const provider = yield* ProviderService; | ||
| 398 | yield* provider.getCheckpointDiff({ | ||
| 399 | sessionId: startedSession.sessionId, | ||
| 400 | fromTurnCount: 0, | ||
| 401 | toTurnCount: 0, | ||
| 402 | }); | ||
| 403 | }).pipe(Effect.provide(secondProviderLayer)); | ||
| 404 | |||
| 405 | assert.deepEqual(secondCodex.startSession.mock.calls, [ | ||
| 406 | [ | ||
| 407 | { | ||
| 408 | provider: "codex", | ||
| 409 | resumeThreadId: startedSession.threadId, | ||
| 410 | }, | ||
| 411 | ], | ||
| 412 | ]); | ||
| 413 | assert.equal(checkpoint.getCheckpointDiff.mock.calls.length, 1); | ||
| 414 | const recoveredDiffInput = checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; | ||
| 415 | assert.equal(typeof recoveredDiffInput?.sessionId, "string"); | ||
| 416 | assert.equal((recoveredDiffInput?.sessionId?.length ?? 0) > 0, true); | ||
| 417 | assert.equal(recoveredDiffInput?.fromTurnCount, 0); | ||
| 418 | assert.equal(recoveredDiffInput?.toTurnCount, 0); | ||
| 326 | 419 | ||
| 327 | fs.rmSync(tempDir, { recursive: true, force: true }); | 420 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 328 | }).pipe(Effect.provide(NodeServices.layer)), | 421 | }).pipe(Effect.provide(NodeServices.layer)), |
| ... | @@ -403,6 +496,51 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -403,6 +496,51 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 403 | }), | 496 | }), |
| 404 | ); | 497 | ); |
| 405 | 498 | ||
| 499 | it.effect("recovers stale persisted sessions for checkpoint RPCs by resuming thread identity", () => | ||
| 500 | Effect.gen(function* () { | ||
| 501 | const provider = yield* ProviderService; | ||
| 502 | |||
| 503 | const initial = yield* provider.startSession({ | ||
| 504 | provider: "codex", | ||
| 505 | cwd: "/tmp/project", | ||
| 506 | }); | ||
| 507 | yield* routing.codex.stopSession(initial.sessionId); | ||
| 508 | |||
| 509 | routing.checkpoint.getCheckpointDiff.mockClear(); | ||
| 510 | routing.checkpoint.revertToCheckpoint.mockClear(); | ||
| 511 | routing.codex.startSession.mockClear(); | ||
| 512 | |||
| 513 | yield* provider.getCheckpointDiff({ | ||
| 514 | sessionId: initial.sessionId, | ||
| 515 | fromTurnCount: 0, | ||
| 516 | toTurnCount: 0, | ||
| 517 | }); | ||
| 518 | yield* provider.revertToCheckpoint({ | ||
| 519 | sessionId: initial.sessionId, | ||
| 520 | turnCount: 0, | ||
| 521 | }); | ||
| 522 | |||
| 523 | assert.deepEqual(routing.codex.startSession.mock.calls, [ | ||
| 524 | [ | ||
| 525 | { | ||
| 526 | provider: "codex", | ||
| 527 | resumeThreadId: initial.threadId, | ||
| 528 | }, | ||
| 529 | ], | ||
| 530 | ]); | ||
| 531 | assert.equal(routing.checkpoint.getCheckpointDiff.mock.calls.length, 1); | ||
| 532 | assert.equal(routing.checkpoint.revertToCheckpoint.mock.calls.length, 1); | ||
| 533 | |||
| 534 | const diffInput = routing.checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; | ||
| 535 | const revertInput = routing.checkpoint.revertToCheckpoint.mock.calls[0]?.[0]; | ||
| 536 | assert.equal(diffInput?.fromTurnCount, 0); | ||
| 537 | assert.equal(diffInput?.toTurnCount, 0); | ||
| 538 | assert.equal(diffInput?.sessionId === initial.sessionId, false); | ||
| 539 | assert.equal(revertInput?.sessionId, diffInput?.sessionId); | ||
| 540 | assert.equal(revertInput?.turnCount, 0); | ||
| 541 | }), | ||
| 542 | ); | ||
| 543 | |||
| 406 | it.effect("releases checkpoint session state when stopping all sessions", () => | 544 | it.effect("releases checkpoint session state when stopping all sessions", () => |
| 407 | Effect.gen(function* () { | 545 | Effect.gen(function* () { |
| 408 | const provider = yield* ProviderService; | 546 | const provider = yield* ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+413-179| ... | @@ -23,8 +23,9 @@ import { | ... | @@ -23,8 +23,9 @@ import { |
| 23 | providerStopSessionInputSchema, | 23 | providerStopSessionInputSchema, |
| 24 | type ProviderRuntimeEvent, | 24 | type ProviderRuntimeEvent, |
| 25 | type ProviderRuntimeTurnCompletedEvent, | 25 | type ProviderRuntimeTurnCompletedEvent, |
| 26 | type ProviderSession, | ||
| 26 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 27 | import { Effect, Layer, PubSub, Queue, Stream } from "effect"; | 28 | import { Effect, Layer, Option, PubSub, Queue, Ref, Stream } from "effect"; |
| 28 | 29 | ||
| 29 | import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; | 30 | import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; |
| 30 | import { ProviderValidationError } from "../Errors.ts"; | 31 | import { ProviderValidationError } from "../Errors.ts"; |
| ... | @@ -97,25 +98,100 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -97,25 +98,100 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 97 | 98 | ||
| 98 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 99 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 99 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | 100 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 101 | const routedSessionAliasesRef = yield* Ref.make<Map<string, string>>(new Map()); | ||
| 102 | |||
| 103 | const canonicalizeRuntimeEventSession = ( | ||
| 104 | event: ProviderRuntimeEvent, | ||
| 105 | ): Effect.Effect<ProviderRuntimeEvent> => | ||
| 106 | Ref.get(routedSessionAliasesRef).pipe( | ||
| 107 | Effect.map((aliases) => { | ||
| 108 | for (const [staleSessionId, liveSessionId] of aliases) { | ||
| 109 | if (liveSessionId === event.sessionId) { | ||
| 110 | return { | ||
| 111 | ...event, | ||
| 112 | sessionId: staleSessionId, | ||
| 113 | } satisfies ProviderRuntimeEvent; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | return event; | ||
| 117 | }), | ||
| 118 | ); | ||
| 100 | 119 | ||
| 101 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 120 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 102 | Effect.sync(() => { | 121 | canonicalizeRuntimeEventSession(event).pipe( |
| 103 | canonicalEventLogger?.write({ | 122 | Effect.tap((canonicalEvent) => |
| 104 | observedAt: new Date().toISOString(), | 123 | Effect.sync(() => { |
| 105 | event, | 124 | canonicalEventLogger?.write({ |
| 106 | }); | 125 | observedAt: new Date().toISOString(), |
| 107 | }).pipe( | 126 | event: canonicalEvent, |
| 108 | Effect.flatMap(() => PubSub.publish(runtimeEventPubSub, event)), | 127 | }); |
| 128 | }), | ||
| 129 | ), | ||
| 130 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), | ||
| 109 | Effect.asVoid, | 131 | Effect.asVoid, |
| 110 | ); | 132 | ); |
| 111 | 133 | ||
| 112 | const providers = yield* registry.listProviders(); | 134 | const upsertSessionBinding = ( |
| 113 | const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); | 135 | session: ProviderSession, |
| 136 | operation: string, | ||
| 137 | ) => | ||
| 138 | Effect.gen(function* () { | ||
| 139 | const threadId = session.threadId?.trim(); | ||
| 140 | if (!threadId) { | ||
| 141 | return yield* toValidationError( | ||
| 142 | operation, | ||
| 143 | `Provider '${session.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`, | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | yield* directory.upsert({ | ||
| 148 | sessionId: session.sessionId, | ||
| 149 | provider: session.provider, | ||
| 150 | threadId, | ||
| 151 | }); | ||
| 114 | 152 | ||
| 115 | const staleSessionIds = yield* directory.reconcileWithAdapters(adapters); | 153 | return threadId; |
| 116 | yield* Effect.forEach(staleSessionIds, (sessionId) => | 154 | }); |
| 117 | checkpointService.releaseSession({ providerSessionId: sessionId }), | 155 | |
| 118 | ).pipe(Effect.asVoid); | 156 | const clearAliasKey = (staleSessionId: string) => |
| 157 | Ref.update(routedSessionAliasesRef, (current) => { | ||
| 158 | if (!current.has(staleSessionId)) { | ||
| 159 | return current; | ||
| 160 | } | ||
| 161 | const next = new Map(current); | ||
| 162 | next.delete(staleSessionId); | ||
| 163 | return next; | ||
| 164 | }); | ||
| 165 | |||
| 166 | const clearAliasesReferencing = (sessionId: string) => | ||
| 167 | Ref.update(routedSessionAliasesRef, (current) => { | ||
| 168 | let changed = false; | ||
| 169 | const next = new Map<string, string>(); | ||
| 170 | for (const [key, value] of current) { | ||
| 171 | if (key === sessionId || value === sessionId) { | ||
| 172 | changed = true; | ||
| 173 | continue; | ||
| 174 | } | ||
| 175 | next.set(key, value); | ||
| 176 | } | ||
| 177 | return changed ? next : current; | ||
| 178 | }); | ||
| 179 | |||
| 180 | const setAlias = (staleSessionId: string, liveSessionId: string) => | ||
| 181 | Ref.update(routedSessionAliasesRef, (current) => { | ||
| 182 | const existing = current.get(staleSessionId); | ||
| 183 | if (existing === liveSessionId) { | ||
| 184 | return current; | ||
| 185 | } | ||
| 186 | const next = new Map(current); | ||
| 187 | next.set(staleSessionId, liveSessionId); | ||
| 188 | return next; | ||
| 189 | }); | ||
| 190 | |||
| 191 | const providers = yield* registry.listProviders(); | ||
| 192 | const adapters = yield* Effect.forEach(providers, (provider) => | ||
| 193 | registry.getByProvider(provider), | ||
| 194 | ); | ||
| 119 | 195 | ||
| 120 | const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> => | 196 | const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> => |
| 121 | checkpointService | 197 | checkpointService |
| ... | @@ -166,203 +242,361 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -166,203 +242,361 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 166 | ).pipe(Effect.forkScoped), | 242 | ).pipe(Effect.forkScoped), |
| 167 | ).pipe(Effect.asVoid); | 243 | ).pipe(Effect.asVoid); |
| 168 | 244 | ||
| 169 | const adapterForSession = (sessionId: string) => | 245 | const recoverSessionForThread = (input: { |
| 170 | directory | 246 | readonly staleSessionId: string; |
| 171 | .getProvider(sessionId) | 247 | readonly provider: ProviderSession["provider"]; |
| 172 | .pipe(Effect.flatMap((provider) => registry.getByProvider(provider))); | 248 | readonly threadId: string; |
| 173 | 249 | readonly operation: string; | |
| 174 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => | 250 | }) => |
| 175 | Effect.gen(function* () { | 251 | Effect.gen(function* () { |
| 176 | const parsed = providerSessionStartInputSchema.safeParse(rawInput); | 252 | const adapter = yield* registry.getByProvider(input.provider); |
| 177 | if (!parsed.success) { | 253 | const activeSessions = yield* adapter.listSessions(); |
| 178 | return yield* Effect.fail( | 254 | const existing = activeSessions.find( |
| 179 | toValidationError( | 255 | (session) => session.threadId?.trim() === input.threadId, |
| 256 | ); | ||
| 257 | if (existing) { | ||
| 258 | const existingThreadId = yield* upsertSessionBinding( | ||
| 259 | existing, | ||
| 260 | `${input.operation}:upsertExistingSession`, | ||
| 261 | ); | ||
| 262 | yield* directory.upsert({ | ||
| 263 | sessionId: input.staleSessionId, | ||
| 264 | provider: existing.provider, | ||
| 265 | threadId: existingThreadId, | ||
| 266 | }); | ||
| 267 | if (existing.sessionId !== input.staleSessionId) { | ||
| 268 | yield* setAlias(input.staleSessionId, existing.sessionId); | ||
| 269 | } else { | ||
| 270 | yield* clearAliasKey(input.staleSessionId); | ||
| 271 | } | ||
| 272 | return { | ||
| 273 | adapter, | ||
| 274 | sessionId: existing.sessionId, | ||
| 275 | } as const; | ||
| 276 | } | ||
| 277 | |||
| 278 | const resumed = yield* adapter.startSession({ | ||
| 279 | provider: input.provider, | ||
| 280 | resumeThreadId: input.threadId, | ||
| 281 | }); | ||
| 282 | if (resumed.provider !== adapter.provider) { | ||
| 283 | return yield* toValidationError( | ||
| 284 | input.operation, | ||
| 285 | `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | const resumedThreadId = yield* upsertSessionBinding( | ||
| 290 | resumed, | ||
| 291 | `${input.operation}:upsertRecoveredSession`, | ||
| 292 | ); | ||
| 293 | if (resumedThreadId !== input.threadId) { | ||
| 294 | return yield* toValidationError( | ||
| 295 | input.operation, | ||
| 296 | `Recovered session thread '${resumedThreadId}' does not match expected thread '${input.threadId}'.`, | ||
| 297 | ); | ||
| 298 | } | ||
| 299 | |||
| 300 | yield* directory.upsert({ | ||
| 301 | sessionId: input.staleSessionId, | ||
| 302 | provider: resumed.provider, | ||
| 303 | threadId: resumedThreadId, | ||
| 304 | }); | ||
| 305 | |||
| 306 | const checkpointCwd = resumed.cwd ?? process.cwd(); | ||
| 307 | yield* checkpointService.initializeForSession({ | ||
| 308 | providerSessionId: resumed.sessionId, | ||
| 309 | cwd: checkpointCwd, | ||
| 310 | }); | ||
| 311 | |||
| 312 | if (resumed.sessionId !== input.staleSessionId) { | ||
| 313 | yield* setAlias(input.staleSessionId, resumed.sessionId); | ||
| 314 | } else { | ||
| 315 | yield* clearAliasKey(input.staleSessionId); | ||
| 316 | } | ||
| 317 | |||
| 318 | return { | ||
| 319 | adapter, | ||
| 320 | sessionId: resumed.sessionId, | ||
| 321 | } as const; | ||
| 322 | }); | ||
| 323 | |||
| 324 | const resolveRoutableSession = (input: { | ||
| 325 | readonly sessionId: string; | ||
| 326 | readonly operation: string; | ||
| 327 | readonly allowRecovery: boolean; | ||
| 328 | }) => | ||
| 329 | Effect.gen(function* () { | ||
| 330 | const provider = yield* directory.getProvider(input.sessionId); | ||
| 331 | const adapter = yield* registry.getByProvider(provider); | ||
| 332 | |||
| 333 | const hasRequestedSession = yield* adapter.hasSession(input.sessionId); | ||
| 334 | if (hasRequestedSession) { | ||
| 335 | yield* clearAliasKey(input.sessionId); | ||
| 336 | return { | ||
| 337 | adapter, | ||
| 338 | sessionId: input.sessionId, | ||
| 339 | isActive: true, | ||
| 340 | } as const; | ||
| 341 | } | ||
| 342 | |||
| 343 | const alias = yield* Ref.get(routedSessionAliasesRef).pipe( | ||
| 344 | Effect.map((aliases) => aliases.get(input.sessionId)), | ||
| 345 | ); | ||
| 346 | if (alias) { | ||
| 347 | const aliasIsActive = yield* adapter.hasSession(alias); | ||
| 348 | if (aliasIsActive) { | ||
| 349 | return { | ||
| 350 | adapter, | ||
| 351 | sessionId: alias, | ||
| 352 | isActive: true, | ||
| 353 | } as const; | ||
| 354 | } | ||
| 355 | yield* clearAliasKey(input.sessionId); | ||
| 356 | } | ||
| 357 | |||
| 358 | if (!input.allowRecovery) { | ||
| 359 | return { | ||
| 360 | adapter, | ||
| 361 | sessionId: input.sessionId, | ||
| 362 | isActive: false, | ||
| 363 | } as const; | ||
| 364 | } | ||
| 365 | |||
| 366 | const threadIdOption = yield* directory.getThreadId(input.sessionId); | ||
| 367 | const threadId = Option.getOrUndefined(threadIdOption)?.trim(); | ||
| 368 | if (!threadId) { | ||
| 369 | return yield* toValidationError( | ||
| 370 | input.operation, | ||
| 371 | `Cannot recover stale session '${input.sessionId}' because no thread id is persisted.`, | ||
| 372 | ); | ||
| 373 | } | ||
| 374 | |||
| 375 | const recovered = yield* recoverSessionForThread({ | ||
| 376 | staleSessionId: input.sessionId, | ||
| 377 | provider, | ||
| 378 | threadId, | ||
| 379 | operation: input.operation, | ||
| 380 | }); | ||
| 381 | |||
| 382 | return { | ||
| 383 | adapter: recovered.adapter, | ||
| 384 | sessionId: recovered.sessionId, | ||
| 385 | isActive: true, | ||
| 386 | } as const; | ||
| 387 | }); | ||
| 388 | |||
| 389 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => | ||
| 390 | Effect.gen(function* () { | ||
| 391 | const parsed = providerSessionStartInputSchema.safeParse(rawInput); | ||
| 392 | if (!parsed.success) { | ||
| 393 | return yield* toValidationError( | ||
| 180 | "ProviderService.startSession", | 394 | "ProviderService.startSession", |
| 181 | parsed.error.message, | 395 | parsed.error.message, |
| 182 | parsed.error.cause, | 396 | parsed.error.cause, |
| 183 | ), | 397 | ); |
| 184 | ); | 398 | } |
| 185 | } | ||
| 186 | 399 | ||
| 187 | const input = parsed.data; | 400 | const input = parsed.data; |
| 188 | const adapter = yield* registry.getByProvider(input.provider); | 401 | const adapter = yield* registry.getByProvider(input.provider); |
| 189 | const session = yield* adapter.startSession(input); | 402 | const session = yield* adapter.startSession(input); |
| 190 | 403 | ||
| 191 | if (session.provider !== adapter.provider) { | 404 | if (session.provider !== adapter.provider) { |
| 192 | return yield* Effect.fail( | 405 | return yield* toValidationError( |
| 193 | toValidationError( | ||
| 194 | "ProviderService.startSession", | 406 | "ProviderService.startSession", |
| 195 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, | 407 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, |
| 196 | ), | 408 | ); |
| 197 | ); | 409 | } |
| 198 | } | ||
| 199 | 410 | ||
| 200 | const threadId = session.threadId?.trim(); | 411 | yield* upsertSessionBinding(session, "ProviderService.startSession"); |
| 201 | if (!threadId) { | ||
| 202 | return yield* Effect.fail( | ||
| 203 | toValidationError( | ||
| 204 | "ProviderService.startSession", | ||
| 205 | `Provider '${adapter.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`, | ||
| 206 | ), | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | 412 | ||
| 210 | yield* directory.upsert({ | 413 | const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); |
| 211 | sessionId: session.sessionId, | 414 | yield* checkpointService.initializeForSession({ |
| 212 | provider: session.provider, | 415 | providerSessionId: session.sessionId, |
| 213 | threadId, | 416 | cwd: checkpointCwd, |
| 214 | }); | 417 | }); |
| 215 | 418 | ||
| 216 | const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); | 419 | return session; |
| 217 | yield* checkpointService.initializeForSession({ | ||
| 218 | providerSessionId: session.sessionId, | ||
| 219 | cwd: checkpointCwd, | ||
| 220 | }); | 420 | }); |
| 221 | 421 | ||
| 222 | return session; | 422 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => |
| 223 | }); | 423 | Effect.gen(function* () { |
| 424 | const parsed = providerSendTurnInputSchema.safeParse(rawInput); | ||
| 425 | if (!parsed.success) { | ||
| 426 | return yield* toValidationError( | ||
| 427 | "ProviderService.sendTurn", | ||
| 428 | parsed.error.message, | ||
| 429 | parsed.error.cause, | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | |||
| 433 | const input = parsed.data; | ||
| 434 | const routed = yield* resolveRoutableSession({ | ||
| 435 | sessionId: input.sessionId, | ||
| 436 | operation: "ProviderService.sendTurn", | ||
| 437 | allowRecovery: true, | ||
| 438 | }); | ||
| 439 | return yield* routed.adapter.sendTurn({ | ||
| 440 | ...input, | ||
| 441 | sessionId: routed.sessionId, | ||
| 442 | }); | ||
| 443 | }); | ||
| 224 | 444 | ||
| 225 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => | 445 | const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => |
| 226 | Effect.gen(function* () { | 446 | Effect.gen(function* () { |
| 227 | const parsed = providerSendTurnInputSchema.safeParse(rawInput); | 447 | const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); |
| 228 | if (!parsed.success) { | 448 | if (!parsed.success) { |
| 229 | return yield* Effect.fail( | 449 | return yield* toValidationError( |
| 230 | toValidationError("ProviderService.sendTurn", parsed.error.message, parsed.error.cause), | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | |||
| 234 | const input = parsed.data; | ||
| 235 | const adapter = yield* adapterForSession(input.sessionId); | ||
| 236 | return yield* adapter.sendTurn(input); | ||
| 237 | }); | ||
| 238 | |||
| 239 | const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); | ||
| 242 | if (!parsed.success) { | ||
| 243 | return yield* Effect.fail( | ||
| 244 | toValidationError( | ||
| 245 | "ProviderService.interruptTurn", | 450 | "ProviderService.interruptTurn", |
| 246 | parsed.error.message, | 451 | parsed.error.message, |
| 247 | parsed.error.cause, | 452 | parsed.error.cause, |
| 248 | ), | 453 | ); |
| 249 | ); | 454 | } |
| 250 | } | 455 | |
| 251 | 456 | const input = parsed.data; | |
| 252 | const input = parsed.data; | 457 | const routed = yield* resolveRoutableSession({ |
| 253 | const adapter = yield* adapterForSession(input.sessionId); | 458 | sessionId: input.sessionId, |
| 254 | yield* adapter.interruptTurn(input.sessionId, input.turnId); | 459 | operation: "ProviderService.interruptTurn", |
| 255 | }); | 460 | allowRecovery: true, |
| 256 | 461 | }); | |
| 257 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => | 462 | yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId); |
| 258 | Effect.gen(function* () { | 463 | }); |
| 259 | const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); | 464 | |
| 260 | if (!parsed.success) { | 465 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => |
| 261 | return yield* Effect.fail( | 466 | Effect.gen(function* () { |
| 262 | toValidationError( | 467 | const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); |
| 468 | if (!parsed.success) { | ||
| 469 | return yield* toValidationError( | ||
| 263 | "ProviderService.respondToRequest", | 470 | "ProviderService.respondToRequest", |
| 264 | parsed.error.message, | 471 | parsed.error.message, |
| 265 | parsed.error.cause, | 472 | parsed.error.cause, |
| 266 | ), | 473 | ); |
| 267 | ); | 474 | } |
| 268 | } | 475 | |
| 269 | 476 | const input = parsed.data; | |
| 270 | const input = parsed.data; | 477 | const routed = yield* resolveRoutableSession({ |
| 271 | const adapter = yield* adapterForSession(input.sessionId); | 478 | sessionId: input.sessionId, |
| 272 | yield* adapter.respondToRequest(input.sessionId, input.requestId, input.decision); | 479 | operation: "ProviderService.respondToRequest", |
| 273 | }); | 480 | allowRecovery: true, |
| 274 | 481 | }); | |
| 275 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => | 482 | yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision); |
| 276 | Effect.gen(function* () { | 483 | }); |
| 277 | const parsed = providerStopSessionInputSchema.safeParse(rawInput); | 484 | |
| 278 | if (!parsed.success) { | 485 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => |
| 279 | return yield* Effect.fail( | 486 | Effect.gen(function* () { |
| 280 | toValidationError( | 487 | const parsed = providerStopSessionInputSchema.safeParse(rawInput); |
| 488 | if (!parsed.success) { | ||
| 489 | return yield* toValidationError( | ||
| 281 | "ProviderService.stopSession", | 490 | "ProviderService.stopSession", |
| 282 | parsed.error.message, | 491 | parsed.error.message, |
| 283 | parsed.error.cause, | 492 | parsed.error.cause, |
| 284 | ), | 493 | ); |
| 285 | ); | 494 | } |
| 286 | } | 495 | |
| 287 | 496 | const input = parsed.data; | |
| 288 | const input = parsed.data; | 497 | const routed = yield* resolveRoutableSession({ |
| 289 | const adapter = yield* adapterForSession(input.sessionId); | 498 | sessionId: input.sessionId, |
| 290 | yield* adapter.stopSession(input.sessionId); | 499 | operation: "ProviderService.stopSession", |
| 291 | yield* checkpointService.releaseSession({ providerSessionId: input.sessionId }); | 500 | allowRecovery: false, |
| 292 | yield* directory.remove(input.sessionId); | 501 | }); |
| 293 | }); | 502 | if (routed.isActive) { |
| 294 | 503 | yield* routed.adapter.stopSession(routed.sessionId); | |
| 295 | const listSessions: ProviderServiceShape["listSessions"] = () => | 504 | } |
| 296 | Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( | 505 | if (routed.sessionId !== input.sessionId) { |
| 297 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), | 506 | yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId }); |
| 298 | ); | 507 | yield* directory.remove(routed.sessionId); |
| 508 | yield* clearAliasesReferencing(routed.sessionId); | ||
| 509 | } | ||
| 510 | yield* checkpointService.releaseSession({ providerSessionId: input.sessionId }); | ||
| 511 | yield* directory.remove(input.sessionId); | ||
| 512 | yield* clearAliasesReferencing(input.sessionId); | ||
| 513 | }); | ||
| 514 | |||
| 515 | const listSessions: ProviderServiceShape["listSessions"] = () => | ||
| 516 | Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( | ||
| 517 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), | ||
| 518 | ); | ||
| 299 | 519 | ||
| 300 | const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => | 520 | const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => |
| 301 | Effect.gen(function* () { | 521 | Effect.gen(function* () { |
| 302 | const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); | 522 | const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); |
| 303 | if (!parsed.success) { | 523 | if (!parsed.success) { |
| 304 | return yield* Effect.fail( | 524 | return yield* toValidationError( |
| 305 | toValidationError( | ||
| 306 | "ProviderService.listCheckpoints", | 525 | "ProviderService.listCheckpoints", |
| 307 | parsed.error.message, | 526 | parsed.error.message, |
| 308 | parsed.error.cause, | 527 | parsed.error.cause, |
| 309 | ), | 528 | ); |
| 310 | ); | 529 | } |
| 311 | } | 530 | |
| 312 | 531 | const input = parsed.data; | |
| 313 | const input = parsed.data; | 532 | const routed = yield* resolveRoutableSession({ |
| 314 | yield* directory.getProvider(input.sessionId); | 533 | sessionId: input.sessionId, |
| 315 | return yield* checkpointService.listCheckpoints(input); | 534 | operation: "ProviderService.listCheckpoints", |
| 316 | }); | 535 | allowRecovery: true, |
| 317 | 536 | }); | |
| 318 | const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => | 537 | return yield* checkpointService.listCheckpoints({ |
| 319 | Effect.gen(function* () { | 538 | ...input, |
| 320 | const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); | 539 | sessionId: routed.sessionId, |
| 321 | if (!parsed.success) { | 540 | }); |
| 322 | return yield* Effect.fail( | 541 | }); |
| 323 | toValidationError( | 542 | |
| 543 | const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => | ||
| 544 | Effect.gen(function* () { | ||
| 545 | const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); | ||
| 546 | if (!parsed.success) { | ||
| 547 | return yield* toValidationError( | ||
| 324 | "ProviderService.getCheckpointDiff", | 548 | "ProviderService.getCheckpointDiff", |
| 325 | parsed.error.message, | 549 | parsed.error.message, |
| 326 | parsed.error.cause, | 550 | parsed.error.cause, |
| 327 | ), | 551 | ); |
| 328 | ); | 552 | } |
| 329 | } | 553 | |
| 330 | 554 | const input = parsed.data; | |
| 331 | const input = parsed.data; | 555 | const routed = yield* resolveRoutableSession({ |
| 332 | yield* directory.getProvider(input.sessionId); | 556 | sessionId: input.sessionId, |
| 333 | return yield* checkpointService.getCheckpointDiff(input); | 557 | operation: "ProviderService.getCheckpointDiff", |
| 334 | }); | 558 | allowRecovery: true, |
| 335 | 559 | }); | |
| 336 | const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => | 560 | return yield* checkpointService.getCheckpointDiff({ |
| 337 | Effect.gen(function* () { | 561 | ...input, |
| 338 | const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); | 562 | sessionId: routed.sessionId, |
| 339 | if (!parsed.success) { | 563 | }); |
| 340 | return yield* Effect.fail( | 564 | }); |
| 341 | toValidationError( | 565 | |
| 566 | const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => | ||
| 567 | Effect.gen(function* () { | ||
| 568 | const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); | ||
| 569 | if (!parsed.success) { | ||
| 570 | return yield* toValidationError( | ||
| 342 | "ProviderService.revertToCheckpoint", | 571 | "ProviderService.revertToCheckpoint", |
| 343 | parsed.error.message, | 572 | parsed.error.message, |
| 344 | parsed.error.cause, | 573 | parsed.error.cause, |
| 345 | ), | 574 | ); |
| 346 | ); | 575 | } |
| 347 | } | 576 | |
| 348 | 577 | const input = parsed.data; | |
| 349 | const input = parsed.data; | 578 | const routed = yield* resolveRoutableSession({ |
| 350 | yield* directory.getProvider(input.sessionId); | 579 | sessionId: input.sessionId, |
| 351 | return yield* checkpointService.revertToCheckpoint(input); | 580 | operation: "ProviderService.revertToCheckpoint", |
| 352 | }); | 581 | allowRecovery: true, |
| 353 | 582 | }); | |
| 354 | const stopAll: ProviderServiceShape["stopAll"] = () => | 583 | return yield* checkpointService.revertToCheckpoint({ |
| 355 | Effect.gen(function* () { | 584 | ...input, |
| 356 | const sessionIds = yield* directory.listSessionIds(); | 585 | sessionId: routed.sessionId, |
| 357 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); | 586 | }); |
| 358 | yield* Effect.forEach( | 587 | }); |
| 359 | sessionIds, | 588 | |
| 360 | (sessionId) => | 589 | const stopAll: ProviderServiceShape["stopAll"] = () => |
| 361 | checkpointService.releaseSession({ providerSessionId: sessionId }).pipe( | 590 | Effect.gen(function* () { |
| 362 | Effect.flatMap(() => directory.remove(sessionId)), | 591 | const sessionIds = yield* directory.listSessionIds(); |
| 363 | ), | 592 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); |
| 364 | ).pipe(Effect.asVoid); | 593 | // Keep persisted session bindings so stale sessions can be resumed after |
| 365 | }); | 594 | // process restart via resumeThreadId. |
| 595 | yield* Effect.forEach(sessionIds, (sessionId) => | ||
| 596 | checkpointService.releaseSession({ providerSessionId: sessionId }), | ||
| 597 | ).pipe(Effect.asVoid); | ||
| 598 | yield* Ref.set(routedSessionAliasesRef, new Map()); | ||
| 599 | }); | ||
| 366 | 600 | ||
| 367 | return { | 601 | return { |
| 368 | startSession, | 602 | startSession, |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-45| ... | @@ -5,13 +5,12 @@ import path from "node:path"; | ... | @@ -5,13 +5,12 @@ import path from "node:path"; |
| 5 | import { NodeServices } from "@effect/platform-node"; | 5 | import { NodeServices } from "@effect/platform-node"; |
| 6 | import { it, assert } from "@effect/vitest"; | 6 | import { it, assert } from "@effect/vitest"; |
| 7 | import { assertFailure, assertSome } from "@effect/vitest/utils"; | 7 | import { assertFailure, assertSome } from "@effect/vitest/utils"; |
| 8 | import { Effect, Layer, Stream } from "effect"; | 8 | import { Effect, Layer } from "effect"; |
| 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 10 | 10 | ||
| 11 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 11 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 12 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | 12 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; |
| 13 | import { ProviderSessionNotFoundError, ProviderValidationError, type ProviderAdapterError } from "../Errors.ts"; | 13 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; |
| 14 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | ||
| 15 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 14 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 16 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | 15 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 17 | 16 | ||
| ... | @@ -22,24 +21,6 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli | ... | @@ -22,24 +21,6 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli |
| 22 | ); | 21 | ); |
| 23 | } | 22 | } |
| 24 | 23 | ||
| 25 | function makeAdapter(activeSessionIds: ReadonlySet<string>): ProviderAdapterShape<ProviderAdapterError> { | ||
| 26 | const unsupported = () => Effect.die(new Error("unsupported test operation")) as never; | ||
| 27 | return { | ||
| 28 | provider: "codex", | ||
| 29 | startSession: () => unsupported(), | ||
| 30 | sendTurn: () => unsupported(), | ||
| 31 | interruptTurn: () => unsupported(), | ||
| 32 | respondToRequest: () => unsupported(), | ||
| 33 | stopSession: () => unsupported(), | ||
| 34 | listSessions: () => Effect.succeed([]), | ||
| 35 | hasSession: (sessionId) => Effect.succeed(activeSessionIds.has(sessionId)), | ||
| 36 | readThread: () => unsupported(), | ||
| 37 | rollbackThread: () => unsupported(), | ||
| 38 | stopAll: () => Effect.void, | ||
| 39 | streamEvents: Stream.empty, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory)); | 24 | const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory)); |
| 44 | 25 | ||
| 45 | layer("ProviderSessionDirectoryLive", (it) => { | 26 | layer("ProviderSessionDirectoryLive", (it) => { |
| ... | @@ -118,28 +99,4 @@ layer("ProviderSessionDirectoryLive", (it) => { | ... | @@ -118,28 +99,4 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 118 | 99 | ||
| 119 | fs.rmSync(tempDir, { recursive: true, force: true }); | 100 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 120 | }).pipe(Effect.provide(NodeServices.layer))); | 101 | }).pipe(Effect.provide(NodeServices.layer))); |
| 121 | |||
| 122 | it("reconciles stale sessions and keeps active mappings", () => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const directory = yield* ProviderSessionDirectory; | ||
| 125 | |||
| 126 | yield* directory.upsert({ | ||
| 127 | sessionId: "sess-active", | ||
| 128 | provider: "codex", | ||
| 129 | threadId: "thread-active", | ||
| 130 | }); | ||
| 131 | yield* directory.upsert({ | ||
| 132 | sessionId: "sess-stale", | ||
| 133 | provider: "codex", | ||
| 134 | threadId: "thread-stale", | ||
| 135 | }); | ||
| 136 | |||
| 137 | const pruned = yield* directory.reconcileWithAdapters([ | ||
| 138 | makeAdapter(new Set(["sess-active"])), | ||
| 139 | ]); | ||
| 140 | assert.deepEqual(pruned, ["sess-stale"]); | ||
| 141 | |||
| 142 | const remaining = yield* directory.listSessionIds(); | ||
| 143 | assert.deepEqual(remaining, ["sess-active"]); | ||
| 144 | })); | ||
| 145 | }); | 102 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts-33| ... | @@ -109,45 +109,12 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -109,45 +109,12 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 109 | const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => | 109 | const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => |
| 110 | listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId))); | 110 | listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId))); |
| 111 | 111 | ||
| 112 | const reconcileWithAdapters: ProviderSessionDirectoryShape["reconcileWithAdapters"] = ( | ||
| 113 | adapters, | ||
| 114 | ) => | ||
| 115 | Effect.gen(function* () { | ||
| 116 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); | ||
| 117 | const bindings = yield* listBindings(); | ||
| 118 | const staleSessionIds: string[] = []; | ||
| 119 | |||
| 120 | for (const binding of bindings) { | ||
| 121 | const adapter = byProvider.get(binding.provider); | ||
| 122 | if (!adapter) { | ||
| 123 | staleSessionIds.push(binding.sessionId); | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | |||
| 127 | const hasSession = yield* adapter.hasSession(binding.sessionId); | ||
| 128 | if (!hasSession) { | ||
| 129 | staleSessionIds.push(binding.sessionId); | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | if (staleSessionIds.length === 0) { | ||
| 134 | return [] as ReadonlyArray<string>; | ||
| 135 | } | ||
| 136 | |||
| 137 | yield* Effect.forEach(staleSessionIds, (sessionId) => persistDelete(sessionId)).pipe( | ||
| 138 | Effect.asVoid, | ||
| 139 | ); | ||
| 140 | |||
| 141 | return staleSessionIds as ReadonlyArray<string>; | ||
| 142 | }); | ||
| 143 | |||
| 144 | return { | 112 | return { |
| 145 | upsert, | 113 | upsert, |
| 146 | getProvider, | 114 | getProvider, |
| 147 | getThreadId, | 115 | getThreadId, |
| 148 | remove, | 116 | remove, |
| 149 | listSessionIds, | 117 | listSessionIds, |
| 150 | reconcileWithAdapters, | ||
| 151 | } satisfies ProviderSessionDirectoryShape; | 118 | } satisfies ProviderSessionDirectoryShape; |
| 152 | }); | 119 | }); |
| 153 | 120 |
apps/server/src/provider/Services/ProviderAdapterRegistry.ts+2| ... | @@ -35,3 +35,5 @@ export class ProviderAdapterRegistry extends ServiceMap.Service< | ... | @@ -35,3 +35,5 @@ export class ProviderAdapterRegistry extends ServiceMap.Service< |
| 35 | ProviderAdapterRegistry, | 35 | ProviderAdapterRegistry, |
| 36 | ProviderAdapterRegistryShape | 36 | ProviderAdapterRegistryShape |
| 37 | >()("provider/ProviderAdapterRegistry") {} | 37 | >()("provider/ProviderAdapterRegistry") {} |
| 38 | |||
| 39 | // Dummy comment for workflow testing. |
apps/server/src/provider/Services/ProviderService.ts+4-1| ... | @@ -95,7 +95,10 @@ export interface ProviderServiceShape { | ... | @@ -95,7 +95,10 @@ export interface ProviderServiceShape { |
| 95 | ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>; | 95 | ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>; |
| 96 | 96 | ||
| 97 | /** | 97 | /** |
| 98 | * Stop all provider sessions. | 98 | * Stop all active provider sessions. |
| 99 | * | ||
| 100 | * Persisted session bindings remain so stale sessions can be resumed after | ||
| 101 | * process restart. | ||
| 99 | */ | 102 | */ |
| 100 | readonly stopAll: () => Effect.Effect<void, ProviderServiceError>; | 103 | readonly stopAll: () => Effect.Effect<void, ProviderServiceError>; |
| 101 | 104 |
apps/server/src/provider/Services/ProviderSessionDirectory.ts-13| ... | @@ -12,12 +12,10 @@ import { Option, ServiceMap } from "effect"; | ... | @@ -12,12 +12,10 @@ import { Option, ServiceMap } from "effect"; |
| 12 | import type { Effect } from "effect"; | 12 | import type { Effect } from "effect"; |
| 13 | 13 | ||
| 14 | import type { | 14 | import type { |
| 15 | ProviderAdapterError, | ||
| 16 | ProviderSessionDirectoryPersistenceError, | 15 | ProviderSessionDirectoryPersistenceError, |
| 17 | ProviderSessionNotFoundError, | 16 | ProviderSessionNotFoundError, |
| 18 | ProviderValidationError, | 17 | ProviderValidationError, |
| 19 | } from "../Errors.ts"; | 18 | } from "../Errors.ts"; |
| 20 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | ||
| 21 | 19 | ||
| 22 | export interface ProviderSessionBinding { | 20 | export interface ProviderSessionBinding { |
| 23 | readonly sessionId: string; | 21 | readonly sessionId: string; |
| ... | @@ -70,17 +68,6 @@ export interface ProviderSessionDirectoryShape { | ... | @@ -70,17 +68,6 @@ export interface ProviderSessionDirectoryShape { |
| 70 | ProviderSessionDirectoryPersistenceError | 68 | ProviderSessionDirectoryPersistenceError |
| 71 | >; | 69 | >; |
| 72 | 70 | ||
| 73 | /** | ||
| 74 | * Remove stale persisted sessions that are no longer active in adapters. | ||
| 75 | * | ||
| 76 | * Returns pruned session ids. | ||
| 77 | */ | ||
| 78 | readonly reconcileWithAdapters: ( | ||
| 79 | adapters: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>, | ||
| 80 | ) => Effect.Effect< | ||
| 81 | ReadonlyArray<string>, | ||
| 82 | ProviderSessionDirectoryPersistenceError | ProviderAdapterError | ||
| 83 | >; | ||
| 84 | } | 71 | } |
| 85 | 72 | ||
| 86 | /** | 73 | /** |
apps/server/tsconfig.json+6-1| ... | @@ -5,7 +5,12 @@ | ... | @@ -5,7 +5,12 @@ |
| 5 | "types": ["node"], | 5 | "types": ["node"], |
| 6 | "lib": ["ES2023", "esnext.disposable"], | 6 | "lib": ["ES2023", "esnext.disposable"], |
| 7 | "noEmit": true, | 7 | "noEmit": true, |
| 8 | "allowImportingTsExtensions": true | 8 | "allowImportingTsExtensions": true, |
| 9 | "plugins": [ | ||
| 10 | { | ||
| 11 | "name": "@effect/language-service" | ||
| 12 | } | ||
| 13 | ] | ||
| 9 | }, | 14 | }, |
| 10 | "include": ["src", "integration", "tsdown.config.ts", "scripts"] | 15 | "include": ["src", "integration", "tsdown.config.ts", "scripts"] |
| 11 | } | 16 | } |
bun.lock+7| ... | @@ -35,6 +35,7 @@ | ... | @@ -35,6 +35,7 @@ |
| 35 | }, | 35 | }, |
| 36 | "dependencies": { | 36 | "dependencies": { |
| 37 | "@effect/platform-node": "4.0.0-beta.10", | 37 | "@effect/platform-node": "4.0.0-beta.10", |
| 38 | "@effect/sql-sqlite-bun": "4.0.0-beta.10", | ||
| 38 | "@pierre/diffs": "^1.1.0-beta.16", | 39 | "@pierre/diffs": "^1.1.0-beta.16", |
| 39 | "effect": "4.0.0-beta.10", | 40 | "effect": "4.0.0-beta.10", |
| 40 | "node-pty": "^1.1.0", | 41 | "node-pty": "^1.1.0", |
| ... | @@ -42,6 +43,7 @@ | ... | @@ -42,6 +43,7 @@ |
| 42 | "ws": "^8.18.0", | 43 | "ws": "^8.18.0", |
| 43 | }, | 44 | }, |
| 44 | "devDependencies": { | 45 | "devDependencies": { |
| 46 | "@effect/language-service": "0.75.1", | ||
| 45 | "@effect/vitest": "4.0.0-beta.10", | 47 | "@effect/vitest": "4.0.0-beta.10", |
| 46 | "@t3tools/contracts": "workspace:*", | 48 | "@t3tools/contracts": "workspace:*", |
| 47 | "@t3tools/web": "workspace:*", | 49 | "@t3tools/web": "workspace:*", |
| ... | @@ -96,6 +98,7 @@ | ... | @@ -96,6 +98,7 @@ |
| 96 | "zod": "^3.24.1", | 98 | "zod": "^3.24.1", |
| 97 | }, | 99 | }, |
| 98 | "devDependencies": { | 100 | "devDependencies": { |
| 101 | "@effect/language-service": "0.75.1", | ||
| 99 | "tsdown": "^0.20.3", | 102 | "tsdown": "^0.20.3", |
| 100 | "typescript": "^5.7.3", | 103 | "typescript": "^5.7.3", |
| 101 | }, | 104 | }, |
| ... | @@ -150,10 +153,14 @@ | ... | @@ -150,10 +153,14 @@ |
| 150 | 153 | ||
| 151 | "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], | 154 | "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], |
| 152 | 155 | ||
| 156 | "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], | ||
| 157 | |||
| 153 | "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.10", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.10", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10", "ioredis": "^5.7.0" } }, "sha512-ikZ/uyos5okYAHE+wWOwZkHgriFUTY3aY8Kdqbj/ro8xw0z9YXWN4fbyQAZUWkBcieDKS36N+QfHRMPG4fEWRA=="], | 158 | "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.10", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.10", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10", "ioredis": "^5.7.0" } }, "sha512-ikZ/uyos5okYAHE+wWOwZkHgriFUTY3aY8Kdqbj/ro8xw0z9YXWN4fbyQAZUWkBcieDKS36N+QfHRMPG4fEWRA=="], |
| 154 | 159 | ||
| 155 | "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="], | 160 | "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="], |
| 156 | 161 | ||
| 162 | "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-LY1jNu2uqGYsJCVjVU73nzcZ+kAYBDsG7O8x5ddmkIXj10RmnIhjYMStWBa0NZOFg4Hsl+CadxtsDy3edaOVeA=="], | ||
| 163 | |||
| 157 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="], | 164 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="], |
| 158 | 165 | ||
| 159 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], | 166 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], |
packages/contracts/package.json+2| ... | @@ -19,6 +19,7 @@ | ... | @@ -19,6 +19,7 @@ |
| 19 | "scripts": { | 19 | "scripts": { |
| 20 | "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean", | 20 | "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean", |
| 21 | "build": "tsdown src/index.ts --format esm,cjs --dts --clean", | 21 | "build": "tsdown src/index.ts --format esm,cjs --dts --clean", |
| 22 | "prepare": "effect-language-service patch", | ||
| 22 | "typecheck": "tsc --noEmit", | 23 | "typecheck": "tsc --noEmit", |
| 23 | "test": "vitest run" | 24 | "test": "vitest run" |
| 24 | }, | 25 | }, |
| ... | @@ -27,6 +28,7 @@ | ... | @@ -27,6 +28,7 @@ |
| 27 | "zod": "^3.24.1" | 28 | "zod": "^3.24.1" |
| 28 | }, | 29 | }, |
| 29 | "devDependencies": { | 30 | "devDependencies": { |
| 31 | "@effect/language-service": "0.75.1", | ||
| 30 | "tsdown": "^0.20.3", | 32 | "tsdown": "^0.20.3", |
| 31 | "typescript": "^5.7.3" | 33 | "typescript": "^5.7.3" |
| 32 | } | 34 | } |
packages/contracts/tsconfig.json+7| ... | @@ -1,4 +1,11 @@ | ... | @@ -1,4 +1,11 @@ |
| 1 | { | 1 | { |
| 2 | "extends": "../../tsconfig.base.json", | 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { | ||
| 4 | "plugins": [ | ||
| 5 | { | ||
| 6 | "name": "@effect/language-service" | ||
| 7 | } | ||
| 8 | ] | ||
| 9 | }, | ||
| 3 | "include": ["src"] | 10 | "include": ["src"] |
| 4 | } | 11 | } |