| author | |
| committer | |
| log | c0a218ca4858c2331ac52f91af1d633f61a1242d |
| tree | e3ec7964c9c061caae738a1dd68e312f9258979d |
| parent | d4b6536a502e34e473cfa4f5d6b54be0f483cc1e |
16 files changed, 859 insertions(+), 130 deletions(-)
apps/server/integration/providerService.integration.test.ts+8-3| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | } from "../src/provider/Services/ProviderService.ts"; |
| 17 | 17 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; |
| 18 | 18 | import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts"; |
| 19 | import { ProviderSessionRepositoryLive } from "../src/persistence/Layers/ProviderSessions.ts"; | |
| 19 | 20 | |
| 20 | 21 | import { |
| 21 | 22 | makeTestProviderAdapterHarness, |
| ... | ... | @@ -77,12 +78,16 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 77 | 78 | listProviders: () => Effect.succeed(["codex"]), |
| 78 | 79 | }; |
| 79 | 80 | |
| 81 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 82 | Layer.provide(ProviderSessionRepositoryLive), | |
| 83 | ); | |
| 84 | ||
| 80 | 85 | const shared = Layer.mergeAll( |
| 81 | ProviderSessionDirectoryLive, | |
| 86 | directoryLayer, | |
| 82 | 87 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 83 | 88 | Layer.provide(CheckpointStoreLive, NodeServices.layer), |
| 84 | Layer.provide(CheckpointRepositoryLive, SqlitePersistenceMemory), | |
| 85 | ); | |
| 89 | CheckpointRepositoryLive, | |
| 90 | ).pipe(Layer.provide(SqlitePersistenceMemory)); | |
| 86 | 91 | |
| 87 | 92 | const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared)); |
| 88 | 93 |
apps/server/src/checkpointing/Layers/CheckpointService.test.ts+8-3| ... | ... | @@ -32,6 +32,7 @@ import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSess |
| 32 | 32 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 33 | 33 | import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; |
| 34 | 34 | import { CheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; |
| 35 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | |
| 35 | 36 | |
| 36 | 37 | const runGit = (cwd: string, args: readonly string[]) => |
| 37 | 38 | runGitProcess({ |
| ... | ... | @@ -211,13 +212,17 @@ function makeFixture(turns: ReadonlyArray<ProviderThreadTurnSnapshot>) { |
| 211 | 212 | listProviders: () => Effect.succeed(["codex"]), |
| 212 | 213 | }; |
| 213 | 214 | |
| 215 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 216 | Layer.provide(ProviderSessionRepositoryLive), | |
| 217 | ); | |
| 218 | ||
| 214 | 219 | const dependencies = Layer.mergeAll( |
| 215 | 220 | Layer.provide(CheckpointStoreLive, NodeServices.layer), |
| 216 | Layer.provide(CheckpointRepositoryLive, SqlitePersistenceMemory), | |
| 221 | CheckpointRepositoryLive, | |
| 217 | 222 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 218 | ProviderSessionDirectoryLive, | |
| 223 | directoryLayer, | |
| 219 | 224 | NodeServices.layer, |
| 220 | ); | |
| 225 | ).pipe(Layer.provide(SqlitePersistenceMemory)); | |
| 221 | 226 | |
| 222 | 227 | const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies)); |
| 223 | 228 |
apps/server/src/checkpointing/Layers/CheckpointService.ts+36-12| ... | ... | @@ -392,16 +392,40 @@ const makeCheckpointService = Effect.gen(function* () { |
| 392 | 392 | Effect.map((current) => Option.fromNullishOr(current.get(sessionId))), |
| 393 | 393 | ); |
| 394 | 394 | |
| 395 | const mapDirectoryLookupError = ( | |
| 396 | sessionId: string, | |
| 397 | operation: string, | |
| 398 | cause: unknown, | |
| 399 | ): CheckpointSessionNotFoundError | CheckpointInvariantError => { | |
| 400 | if ( | |
| 401 | typeof cause === "object" && | |
| 402 | cause !== null && | |
| 403 | "_tag" in cause && | |
| 404 | (cause as { _tag?: string })._tag === "ProviderSessionNotFoundError" | |
| 405 | ) { | |
| 406 | return new CheckpointSessionNotFoundError({ | |
| 407 | sessionId, | |
| 408 | cause, | |
| 409 | }); | |
| 410 | } | |
| 411 | ||
| 412 | return invariantError( | |
| 413 | operation, | |
| 414 | `Failed to resolve provider session metadata for session ${sessionId}.`, | |
| 415 | cause, | |
| 416 | ); | |
| 417 | }; | |
| 418 | ||
| 395 | 419 | const resolveAdapterForSession = ( |
| 396 | 420 | sessionId: string, |
| 397 | 421 | ): Effect.Effect<ProviderAdapterShape<ProviderAdapterError>, CheckpointServiceError> => |
| 398 | 422 | directory.getProvider(sessionId).pipe( |
| 399 | Effect.mapError( | |
| 400 | (cause) => | |
| 401 | new CheckpointSessionNotFoundError({ | |
| 402 | sessionId, | |
| 403 | cause, | |
| 404 | }), | |
| 423 | Effect.mapError((cause) => | |
| 424 | mapDirectoryLookupError( | |
| 425 | sessionId, | |
| 426 | "CheckpointService.resolveAdapterForSession:getProvider", | |
| 427 | cause, | |
| 428 | ), | |
| 405 | 429 | ), |
| 406 | 430 | Effect.flatMap((provider) => |
| 407 | 431 | registry |
| ... | ... | @@ -456,12 +480,12 @@ const makeCheckpointService = Effect.gen(function* () { |
| 456 | 480 | operation: string, |
| 457 | 481 | ): Effect.Effect<string, CheckpointServiceError> => |
| 458 | 482 | directory.getThreadId(sessionId).pipe( |
| 459 | Effect.mapError( | |
| 460 | (cause) => | |
| 461 | new CheckpointSessionNotFoundError({ | |
| 462 | sessionId, | |
| 463 | cause, | |
| 464 | }), | |
| 483 | Effect.mapError((cause) => | |
| 484 | mapDirectoryLookupError( | |
| 485 | sessionId, | |
| 486 | `${operation}:getThreadId`, | |
| 487 | cause, | |
| 488 | ), | |
| 465 | 489 | ), |
| 466 | 490 | Effect.flatMap((threadId) => |
| 467 | 491 | Option.match(threadId, { |
apps/server/src/persistence/Errors.ts+34| ... | ... | @@ -150,6 +150,36 @@ export class CheckpointRepositoryPersistenceError extends Schema.TaggedErrorClas |
| 150 | 150 | } |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | // =============================== | |
| 154 | // Provider Session Repository Errors | |
| 155 | // =============================== | |
| 156 | ||
| 157 | export class ProviderSessionRepositoryValidationError extends Schema.TaggedErrorClass<ProviderSessionRepositoryValidationError>()( | |
| 158 | "ProviderSessionRepositoryValidationError", | |
| 159 | { | |
| 160 | operation: Schema.String, | |
| 161 | issue: Schema.String, | |
| 162 | cause: Schema.optional(Schema.Defect), | |
| 163 | }, | |
| 164 | ) { | |
| 165 | override get message(): string { | |
| 166 | return `Provider session repository validation failed in ${this.operation}: ${this.issue}`; | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedErrorClass<ProviderSessionRepositoryPersistenceError>()( | |
| 171 | "ProviderSessionRepositoryPersistenceError", | |
| 172 | { | |
| 173 | operation: Schema.String, | |
| 174 | detail: Schema.String, | |
| 175 | cause: Schema.optional(Schema.Defect), | |
| 176 | }, | |
| 177 | ) { | |
| 178 | override get message(): string { | |
| 179 | return `Provider session repository persistence error in ${this.operation}: ${this.detail}`; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 153 | 183 | export type ProjectRepositoryError = |
| 154 | 184 | | PersistenceSqlError |
| 155 | 185 | | PersistenceDecodeError |
| ... | ... | @@ -166,3 +196,7 @@ export type OrchestrationEventStoreError = |
| 166 | 196 | export type CheckpointRepositoryError = |
| 167 | 197 | | CheckpointRepositoryValidationError |
| 168 | 198 | | CheckpointRepositoryPersistenceError; |
| 199 | ||
| 200 | export type ProviderSessionRepositoryError = | |
| 201 | | ProviderSessionRepositoryValidationError | |
| 202 | | ProviderSessionRepositoryPersistenceError; |
apps/server/src/persistence/Layers/ProviderSessions.test.ts created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { NodeServices } from "@effect/platform-node"; | |
| 6 | import { it, assert } from "@effect/vitest"; | |
| 7 | import { Effect, Layer, Option } from "effect"; | |
| 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 9 | ||
| 10 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 11 | import { ProviderSessionRepository } from "../Services/ProviderSessions.ts"; | |
| 12 | import { ProviderSessionRepositoryLive } from "./ProviderSessions.ts"; | |
| 13 | ||
| 14 | function makeRepositoryLayer<E, R>( | |
| 15 | persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>, | |
| 16 | ) { | |
| 17 | return ProviderSessionRepositoryLive.pipe(Layer.provide(persistenceLayer)); | |
| 18 | } | |
| 19 | ||
| 20 | it.effect("ProviderSessionRepositoryLive upserts, lists, gets, and deletes sessions", () => | |
| 21 | Effect.gen(function* () { | |
| 22 | const repository = yield* ProviderSessionRepository; | |
| 23 | ||
| 24 | yield* repository.upsertSession({ | |
| 25 | sessionId: "sess-1", | |
| 26 | provider: "codex", | |
| 27 | threadId: "thread-1", | |
| 28 | }); | |
| 29 | yield* repository.upsertSession({ | |
| 30 | sessionId: "sess-2", | |
| 31 | provider: "claudeCode", | |
| 32 | }); | |
| 33 | ||
| 34 | const all = yield* repository.listSessions(); | |
| 35 | assert.equal(all.length, 2); | |
| 36 | assert.equal(all[0]?.sessionId, "sess-1"); | |
| 37 | assert.equal(all[1]?.sessionId, "sess-2"); | |
| 38 | ||
| 39 | const existing = yield* repository.getSession({ sessionId: "sess-1" }); | |
| 40 | assert.equal(Option.isSome(existing), true); | |
| 41 | if (Option.isSome(existing)) { | |
| 42 | assert.equal(existing.value.provider, "codex"); | |
| 43 | assert.equal(existing.value.threadId, "thread-1"); | |
| 44 | } | |
| 45 | ||
| 46 | yield* repository.deleteSession({ sessionId: "sess-2" }); | |
| 47 | const afterDelete = yield* repository.listSessions(); | |
| 48 | assert.deepEqual(afterDelete.map((entry) => entry.sessionId), ["sess-1"]); | |
| 49 | }).pipe(Effect.provide(makeRepositoryLayer(SqlitePersistenceMemory))), | |
| 50 | ); | |
| 51 | ||
| 52 | it.effect("ProviderSessionRepositoryLive persists sessions across layer restarts", () => | |
| 53 | Effect.gen(function* () { | |
| 54 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-sessions-")); | |
| 55 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 56 | ||
| 57 | const repositoryLayer = makeRepositoryLayer(makeSqlitePersistenceLive(dbPath)); | |
| 58 | ||
| 59 | yield* Effect.gen(function* () { | |
| 60 | const repository = yield* ProviderSessionRepository; | |
| 61 | yield* repository.upsertSession({ | |
| 62 | sessionId: "sess-persisted", | |
| 63 | provider: "codex", | |
| 64 | threadId: "thread-persisted", | |
| 65 | }); | |
| 66 | }).pipe(Effect.provide(repositoryLayer)); | |
| 67 | ||
| 68 | const persisted = yield* Effect.gen(function* () { | |
| 69 | const repository = yield* ProviderSessionRepository; | |
| 70 | return yield* repository.getSession({ sessionId: "sess-persisted" }); | |
| 71 | }).pipe(Effect.provide(repositoryLayer)); | |
| 72 | ||
| 73 | assert.equal(Option.isSome(persisted), true); | |
| 74 | if (Option.isSome(persisted)) { | |
| 75 | assert.equal(persisted.value.provider, "codex"); | |
| 76 | assert.equal(persisted.value.threadId, "thread-persisted"); | |
| 77 | } | |
| 78 | ||
| 79 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 80 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 81 | ); |
apps/server/src/persistence/Layers/ProviderSessions.ts created+232| ... | ... | @@ -0,0 +1,232 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Option, Schema } from "effect"; | |
| 4 | ||
| 5 | import { | |
| 6 | ProviderSessionRepositoryPersistenceError, | |
| 7 | ProviderSessionRepositoryValidationError, | |
| 8 | } from "../Errors.ts"; | |
| 9 | import { | |
| 10 | ProviderSessionRepository, | |
| 11 | type ProviderSessionEntry, | |
| 12 | type ProviderSessionRepositoryShape, | |
| 13 | } from "../Services/ProviderSessions.ts"; | |
| 14 | ||
| 15 | const NonEmptyString = Schema.NonEmptyString; | |
| 16 | ||
| 17 | const ProviderKindSchema = Schema.Literals(["codex", "claudeCode"]); | |
| 18 | ||
| 19 | const ProviderSessionRowSchema = Schema.Struct({ | |
| 20 | sessionId: Schema.String, | |
| 21 | provider: ProviderKindSchema, | |
| 22 | threadId: Schema.NullOr(Schema.String), | |
| 23 | createdAt: Schema.String, | |
| 24 | updatedAt: Schema.String, | |
| 25 | }); | |
| 26 | ||
| 27 | const SessionIdRequestSchema = Schema.Struct({ | |
| 28 | sessionId: NonEmptyString, | |
| 29 | }); | |
| 30 | ||
| 31 | const UpsertSessionRequestSchema = Schema.Struct({ | |
| 32 | sessionId: NonEmptyString, | |
| 33 | provider: ProviderKindSchema, | |
| 34 | threadId: Schema.NullOr(NonEmptyString), | |
| 35 | }); | |
| 36 | ||
| 37 | function errorMessage(cause: unknown, fallback: string): string { | |
| 38 | if (cause instanceof Error && cause.message.length > 0) { | |
| 39 | return cause.message; | |
| 40 | } | |
| 41 | return fallback; | |
| 42 | } | |
| 43 | ||
| 44 | function toValidationError( | |
| 45 | operation: string, | |
| 46 | cause: unknown, | |
| 47 | ): ProviderSessionRepositoryValidationError { | |
| 48 | return new ProviderSessionRepositoryValidationError({ | |
| 49 | operation, | |
| 50 | issue: errorMessage(cause, "Invalid provider session repository input."), | |
| 51 | cause, | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | function decodeInput<A>( | |
| 56 | schema: Schema.Schema<A>, | |
| 57 | input: unknown, | |
| 58 | operation: string, | |
| 59 | ): Effect.Effect<A, ProviderSessionRepositoryValidationError> { | |
| 60 | return Schema.decodeUnknownEffect(schema)(input).pipe( | |
| 61 | Effect.mapError((cause) => toValidationError(operation, cause)), | |
| 62 | ) as Effect.Effect<A, ProviderSessionRepositoryValidationError>; | |
| 63 | } | |
| 64 | ||
| 65 | function toPersistenceError( | |
| 66 | operation: string, | |
| 67 | cause: unknown, | |
| 68 | ): ProviderSessionRepositoryPersistenceError { | |
| 69 | return new ProviderSessionRepositoryPersistenceError({ | |
| 70 | operation, | |
| 71 | detail: `Failed to execute ${operation}.`, | |
| 72 | cause, | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 76 | function toEntry( | |
| 77 | row: Schema.Schema.Type<typeof ProviderSessionRowSchema>, | |
| 78 | ): ProviderSessionEntry { | |
| 79 | return { | |
| 80 | sessionId: row.sessionId, | |
| 81 | provider: row.provider, | |
| 82 | ...(row.threadId !== null ? { threadId: row.threadId } : {}), | |
| 83 | createdAt: row.createdAt, | |
| 84 | updatedAt: row.updatedAt, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | const makeProviderSessionRepository = Effect.gen(function* () { | |
| 89 | const sql = yield* SqlClient.SqlClient; | |
| 90 | ||
| 91 | const upsertSessionRow = SqlSchema.findOne({ | |
| 92 | Request: UpsertSessionRequestSchema, | |
| 93 | Result: ProviderSessionRowSchema, | |
| 94 | execute: (request) => | |
| 95 | sql` | |
| 96 | INSERT INTO provider_sessions ( | |
| 97 | session_id, | |
| 98 | provider, | |
| 99 | thread_id, | |
| 100 | created_at, | |
| 101 | updated_at | |
| 102 | ) | |
| 103 | VALUES ( | |
| 104 | ${request.sessionId}, | |
| 105 | ${request.provider}, | |
| 106 | ${request.threadId}, | |
| 107 | CURRENT_TIMESTAMP, | |
| 108 | CURRENT_TIMESTAMP | |
| 109 | ) | |
| 110 | ON CONFLICT (session_id) | |
| 111 | DO UPDATE SET | |
| 112 | provider = excluded.provider, | |
| 113 | thread_id = excluded.thread_id, | |
| 114 | updated_at = CURRENT_TIMESTAMP | |
| 115 | RETURNING | |
| 116 | session_id AS "sessionId", | |
| 117 | provider, | |
| 118 | thread_id AS "threadId", | |
| 119 | created_at AS "createdAt", | |
| 120 | updated_at AS "updatedAt" | |
| 121 | `, | |
| 122 | }); | |
| 123 | ||
| 124 | const findSessionRow = SqlSchema.findOneOption({ | |
| 125 | Request: SessionIdRequestSchema, | |
| 126 | Result: ProviderSessionRowSchema, | |
| 127 | execute: ({ sessionId }) => | |
| 128 | sql` | |
| 129 | SELECT | |
| 130 | session_id AS "sessionId", | |
| 131 | provider, | |
| 132 | thread_id AS "threadId", | |
| 133 | created_at AS "createdAt", | |
| 134 | updated_at AS "updatedAt" | |
| 135 | FROM provider_sessions | |
| 136 | WHERE session_id = ${sessionId} | |
| 137 | `, | |
| 138 | }); | |
| 139 | ||
| 140 | const listSessionRows = SqlSchema.findAll({ | |
| 141 | Request: Schema.Void, | |
| 142 | Result: ProviderSessionRowSchema, | |
| 143 | execute: () => | |
| 144 | sql` | |
| 145 | SELECT | |
| 146 | session_id AS "sessionId", | |
| 147 | provider, | |
| 148 | thread_id AS "threadId", | |
| 149 | created_at AS "createdAt", | |
| 150 | updated_at AS "updatedAt" | |
| 151 | FROM provider_sessions | |
| 152 | ORDER BY created_at ASC, session_id ASC | |
| 153 | `, | |
| 154 | }); | |
| 155 | ||
| 156 | const deleteSessionRow = SqlSchema.void({ | |
| 157 | Request: SessionIdRequestSchema, | |
| 158 | execute: ({ sessionId }) => sql`DELETE FROM provider_sessions WHERE session_id = ${sessionId}`, | |
| 159 | }); | |
| 160 | ||
| 161 | const upsertSession: ProviderSessionRepositoryShape["upsertSession"] = (input) => | |
| 162 | Effect.gen(function* () { | |
| 163 | const parsed = yield* decodeInput( | |
| 164 | UpsertSessionRequestSchema, | |
| 165 | { | |
| 166 | sessionId: input.sessionId.trim(), | |
| 167 | provider: input.provider, | |
| 168 | threadId: input.threadId?.trim() || null, | |
| 169 | }, | |
| 170 | "ProviderSessionRepository.upsertSession", | |
| 171 | ); | |
| 172 | ||
| 173 | yield* upsertSessionRow(parsed).pipe( | |
| 174 | Effect.mapError((cause) => | |
| 175 | toPersistenceError("ProviderSessionRepository.upsertSession:query", cause), | |
| 176 | ), | |
| 177 | Effect.asVoid, | |
| 178 | ); | |
| 179 | }); | |
| 180 | ||
| 181 | const getSession: ProviderSessionRepositoryShape["getSession"] = (input) => | |
| 182 | Effect.gen(function* () { | |
| 183 | const parsed = yield* decodeInput( | |
| 184 | SessionIdRequestSchema, | |
| 185 | { sessionId: input.sessionId.trim() }, | |
| 186 | "ProviderSessionRepository.getSession", | |
| 187 | ); | |
| 188 | ||
| 189 | const row = yield* findSessionRow(parsed).pipe( | |
| 190 | Effect.mapError((cause) => | |
| 191 | toPersistenceError("ProviderSessionRepository.getSession:query", cause), | |
| 192 | ), | |
| 193 | ); | |
| 194 | ||
| 195 | return Option.map(row, toEntry); | |
| 196 | }); | |
| 197 | ||
| 198 | const listSessions: ProviderSessionRepositoryShape["listSessions"] = () => | |
| 199 | listSessionRows(undefined).pipe( | |
| 200 | Effect.mapError((cause) => | |
| 201 | toPersistenceError("ProviderSessionRepository.listSessions:query", cause), | |
| 202 | ), | |
| 203 | Effect.map((rows) => rows.map(toEntry)), | |
| 204 | ); | |
| 205 | ||
| 206 | const deleteSession: ProviderSessionRepositoryShape["deleteSession"] = (input) => | |
| 207 | Effect.gen(function* () { | |
| 208 | const parsed = yield* decodeInput( | |
| 209 | SessionIdRequestSchema, | |
| 210 | { sessionId: input.sessionId.trim() }, | |
| 211 | "ProviderSessionRepository.deleteSession", | |
| 212 | ); | |
| 213 | ||
| 214 | yield* deleteSessionRow(parsed).pipe( | |
| 215 | Effect.mapError((cause) => | |
| 216 | toPersistenceError("ProviderSessionRepository.deleteSession:query", cause), | |
| 217 | ), | |
| 218 | ); | |
| 219 | }); | |
| 220 | ||
| 221 | return { | |
| 222 | upsertSession, | |
| 223 | getSession, | |
| 224 | listSessions, | |
| 225 | deleteSession, | |
| 226 | } satisfies ProviderSessionRepositoryShape; | |
| 227 | }); | |
| 228 | ||
| 229 | export const ProviderSessionRepositoryLive = Layer.effect( | |
| 230 | ProviderSessionRepository, | |
| 231 | makeProviderSessionRepository, | |
| 232 | ); |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -15,6 +15,7 @@ import * as Layer from "effect/Layer"; |
| 15 | 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| 16 | 16 | import Migration0002 from "./Migrations/002_Projects.ts"; |
| 17 | 17 | import Migration0003 from "./Migrations/003_Checkpoints.ts"; |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessions.ts"; | |
| 18 | 19 | |
| 19 | 20 | /** |
| 20 | 21 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -30,6 +31,7 @@ const loader = Migrator.fromRecord({ |
| 30 | 31 | "1_OrchestrationEvents": Migration0001, |
| 31 | 32 | "2_Projects": Migration0002, |
| 32 | 33 | "3_CheckpointCatalog": Migration0003, |
| 34 | "4_ProviderSessions": Migration0004, | |
| 33 | 35 | }); |
| 34 | 36 | |
| 35 | 37 | /** |
apps/server/src/persistence/Migrations/004_ProviderSessions.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS provider_sessions ( | |
| 9 | session_id TEXT PRIMARY KEY, | |
| 10 | provider TEXT NOT NULL, | |
| 11 | thread_id TEXT, | |
| 12 | created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 13 | updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 14 | ) | |
| 15 | `; | |
| 16 | ||
| 17 | yield* sql` | |
| 18 | CREATE INDEX IF NOT EXISTS idx_provider_sessions_provider | |
| 19 | ON provider_sessions(provider) | |
| 20 | `; | |
| 21 | }); |
apps/server/src/persistence/Services/ProviderSessions.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { Option, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { ProviderSessionRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export interface ProviderSessionEntry { | |
| 8 | readonly sessionId: string; | |
| 9 | readonly provider: ProviderKind; | |
| 10 | readonly threadId?: string; | |
| 11 | readonly createdAt: string; | |
| 12 | readonly updatedAt: string; | |
| 13 | } | |
| 14 | ||
| 15 | export interface UpsertProviderSessionInput { | |
| 16 | readonly sessionId: string; | |
| 17 | readonly provider: ProviderKind; | |
| 18 | readonly threadId?: string; | |
| 19 | } | |
| 20 | ||
| 21 | export interface DeleteProviderSessionInput { | |
| 22 | readonly sessionId: string; | |
| 23 | } | |
| 24 | ||
| 25 | export interface GetProviderSessionInput { | |
| 26 | readonly sessionId: string; | |
| 27 | } | |
| 28 | ||
| 29 | export interface ProviderSessionRepositoryShape { | |
| 30 | readonly upsertSession: ( | |
| 31 | input: UpsertProviderSessionInput, | |
| 32 | ) => Effect.Effect<void, ProviderSessionRepositoryError>; | |
| 33 | ||
| 34 | readonly getSession: ( | |
| 35 | input: GetProviderSessionInput, | |
| 36 | ) => Effect.Effect<Option.Option<ProviderSessionEntry>, ProviderSessionRepositoryError>; | |
| 37 | ||
| 38 | readonly listSessions: () => Effect.Effect< | |
| 39 | ReadonlyArray<ProviderSessionEntry>, | |
| 40 | ProviderSessionRepositoryError | |
| 41 | >; | |
| 42 | ||
| 43 | readonly deleteSession: ( | |
| 44 | input: DeleteProviderSessionInput, | |
| 45 | ) => Effect.Effect<void, ProviderSessionRepositoryError>; | |
| 46 | } | |
| 47 | ||
| 48 | export class ProviderSessionRepository extends ServiceMap.Service< | |
| 49 | ProviderSessionRepository, | |
| 50 | ProviderSessionRepositoryShape | |
| 51 | >()("persistence/ProviderSessionRepository") {} |
apps/server/src/provider/Errors.ts+17| ... | ... | @@ -148,6 +148,22 @@ export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<Provid |
| 148 | 148 | } |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | /** | |
| 152 | * ProviderSessionDirectoryPersistenceError - Session directory persistence failure. | |
| 153 | */ | |
| 154 | export class ProviderSessionDirectoryPersistenceError extends Schema.TaggedErrorClass<ProviderSessionDirectoryPersistenceError>()( | |
| 155 | "ProviderSessionDirectoryPersistenceError", | |
| 156 | { | |
| 157 | operation: Schema.String, | |
| 158 | detail: Schema.String, | |
| 159 | cause: Schema.optional(Schema.Defect), | |
| 160 | }, | |
| 161 | ) { | |
| 162 | override get message(): string { | |
| 163 | return `Provider session directory persistence error in ${this.operation}: ${this.detail}`; | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 151 | 167 | /** |
| 152 | 168 | * ProviderCheckpointUnavailableError - Checkpointing unavailable for this session. |
| 153 | 169 | */ |
| ... | ... | @@ -210,6 +226,7 @@ export type ProviderServiceError = |
| 210 | 226 | | ProviderValidationError |
| 211 | 227 | | ProviderUnsupportedError |
| 212 | 228 | | ProviderSessionNotFoundError |
| 229 | | ProviderSessionDirectoryPersistenceError | |
| 213 | 230 | | ProviderCheckpointUnavailableError |
| 214 | 231 | | ProviderCheckpointRangeError |
| 215 | 232 | | ProviderFilesystemError |
apps/server/src/provider/Layers/ProviderService.test.ts+63-1| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import type { |
| 2 | 6 | ProviderApprovalDecision, |
| 3 | 7 | ProviderGetCheckpointDiffInput, |
| ... | ... | @@ -29,9 +33,15 @@ import { |
| 29 | 33 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| 30 | 34 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 31 | 35 | import { ProviderService } from "../Services/ProviderService.ts"; |
| 36 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 32 | 37 | import { ProviderServiceLive } from "./ProviderService.ts"; |
| 33 | 38 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 34 | 39 | import { NodeServices } from "@effect/platform-node"; |
| 40 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | |
| 41 | import { | |
| 42 | makeSqlitePersistenceLive, | |
| 43 | SqlitePersistenceMemory, | |
| 44 | } from "../../persistence/Layers/Sqlite.ts"; | |
| 35 | 45 | |
| 36 | 46 | function makeFakeCodexAdapter() { |
| 37 | 47 | const sessions = new Map<string, ProviderSession>(); |
| ... | ... | @@ -246,14 +256,19 @@ function makeProviderServiceLayer() { |
| 246 | 256 | |
| 247 | 257 | const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service); |
| 248 | 258 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); |
| 259 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 260 | Layer.provide(ProviderSessionRepositoryLive), | |
| 261 | Layer.provide(SqlitePersistenceMemory), | |
| 262 | ); | |
| 249 | 263 | |
| 250 | 264 | const layer = it.layer( |
| 251 | 265 | Layer.mergeAll( |
| 252 | 266 | ProviderServiceLive.pipe( |
| 253 | 267 | Layer.provide(providerAdapterLayer), |
| 254 | Layer.provide(ProviderSessionDirectoryLive), | |
| 268 | Layer.provide(directoryLayer), | |
| 255 | 269 | Layer.provide(checkpointLayer), |
| 256 | 270 | ), |
| 271 | directoryLayer, | |
| 257 | 272 | NodeServices.layer, |
| 258 | 273 | ), |
| 259 | 274 | ); |
| ... | ... | @@ -266,6 +281,53 @@ function makeProviderServiceLayer() { |
| 266 | 281 | } |
| 267 | 282 | |
| 268 | 283 | const routing = makeProviderServiceLayer(); |
| 284 | it.effect("ProviderServiceLive reconciles stale persisted sessions on startup", () => | |
| 285 | Effect.gen(function* () { | |
| 286 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); | |
| 287 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 288 | ||
| 289 | const codex = makeFakeCodexAdapter(); | |
| 290 | const checkpoint = makeCheckpointServiceDouble(); | |
| 291 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 292 | getByProvider: (provider) => | |
| 293 | provider === "codex" | |
| 294 | ? Effect.succeed(codex.adapter) | |
| 295 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 296 | listProviders: () => Effect.succeed(["codex"]), | |
| 297 | }; | |
| 298 | ||
| 299 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 300 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 301 | Layer.provide(ProviderSessionRepositoryLive), | |
| 302 | Layer.provide(persistenceLayer), | |
| 303 | ); | |
| 304 | ||
| 305 | yield* Effect.gen(function* () { | |
| 306 | const directory = yield* ProviderSessionDirectory; | |
| 307 | yield* directory.upsert({ | |
| 308 | sessionId: "sess-stale", | |
| 309 | provider: "codex", | |
| 310 | threadId: "thread-stale", | |
| 311 | }); | |
| 312 | }).pipe(Effect.provide(directoryLayer)); | |
| 313 | ||
| 314 | const providerLayer = ProviderServiceLive.pipe( | |
| 315 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | |
| 316 | Layer.provide(directoryLayer), | |
| 317 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | |
| 318 | ); | |
| 319 | ||
| 320 | checkpoint.releaseSession.mockClear(); | |
| 321 | yield* Effect.gen(function* () { | |
| 322 | yield* ProviderService; | |
| 323 | }).pipe(Effect.provide(providerLayer)); | |
| 324 | ||
| 325 | assert.deepEqual(checkpoint.releaseSession.mock.calls, [[{ providerSessionId: "sess-stale" }]]); | |
| 326 | ||
| 327 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 328 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 329 | ); | |
| 330 | ||
| 269 | 331 | routing.layer("ProviderServiceLive routing", (it) => { |
| 270 | 332 | it.effect("routes provider operations and delegates checkpoint workflows", () => |
| 271 | 333 | Effect.gen(function* () { |
apps/server/src/provider/Layers/ProviderService.ts+56-49| ... | ... | @@ -112,57 +112,64 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 112 | 112 | const providers = yield* registry.listProviders(); |
| 113 | 113 | const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); |
| 114 | 114 | |
| 115 | const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> => | |
| 116 | checkpointService | |
| 117 | .captureCurrentTurn({ | |
| 118 | providerSessionId: event.sessionId, | |
| 119 | ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), | |
| 120 | ...(event.status !== undefined ? { status: event.status } : {}), | |
| 121 | }) | |
| 122 | .pipe( | |
| 123 | Effect.flatMap(() => checkpointService.listCheckpoints({ sessionId: event.sessionId })), | |
| 124 | Effect.flatMap((result) => { | |
| 125 | const currentCheckpoint = | |
| 126 | result.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | |
| 127 | result.checkpoints[result.checkpoints.length - 1]; | |
| 128 | if (!currentCheckpoint) { | |
| 115 | const staleSessionIds = yield* directory.reconcileWithAdapters(adapters); | |
| 116 | yield* Effect.forEach(staleSessionIds, (sessionId) => | |
| 117 | checkpointService.releaseSession({ providerSessionId: sessionId }), | |
| 118 | ).pipe(Effect.asVoid); | |
| 119 | ||
| 120 | const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> => | |
| 121 | checkpointService | |
| 122 | .captureCurrentTurn({ | |
| 123 | providerSessionId: event.sessionId, | |
| 124 | ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), | |
| 125 | ...(event.status !== undefined ? { status: event.status } : {}), | |
| 126 | }) | |
| 127 | .pipe( | |
| 128 | Effect.flatMap(() => checkpointService.listCheckpoints({ sessionId: event.sessionId })), | |
| 129 | Effect.flatMap((result) => { | |
| 130 | const currentCheckpoint = | |
| 131 | result.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | |
| 132 | result.checkpoints[result.checkpoints.length - 1]; | |
| 133 | if (!currentCheckpoint) { | |
| 134 | return Effect.void; | |
| 135 | } | |
| 136 | ||
| 137 | return publishRuntimeEvent( | |
| 138 | toCheckpointCapturedEvent({ | |
| 139 | event, | |
| 140 | threadId: result.threadId, | |
| 141 | turnCount: currentCheckpoint.turnCount, | |
| 142 | }), | |
| 143 | ); | |
| 144 | }), | |
| 145 | Effect.catch((error) => publishRuntimeEvent(toCheckpointCaptureErrorEvent(event, error))), | |
| 146 | ); | |
| 147 | ||
| 148 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | |
| 149 | publishRuntimeEvent(event).pipe( | |
| 150 | Effect.flatMap(() => { | |
| 151 | if (event.type !== "turn.completed") { | |
| 129 | 152 | return Effect.void; |
| 130 | 153 | } |
| 131 | ||
| 132 | return publishRuntimeEvent( | |
| 133 | toCheckpointCapturedEvent({ | |
| 134 | event, | |
| 135 | threadId: result.threadId, | |
| 136 | turnCount: currentCheckpoint.turnCount, | |
| 137 | }), | |
| 138 | ); | |
| 154 | return onTurnCompleted(event); | |
| 139 | 155 | }), |
| 140 | Effect.catch((error) => publishRuntimeEvent(toCheckpointCaptureErrorEvent(event, error))), | |
| 141 | 156 | ); |
| 142 | 157 | |
| 143 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | |
| 144 | publishRuntimeEvent(event).pipe( | |
| 145 | Effect.flatMap(() => { | |
| 146 | if (event.type !== "turn.completed") { | |
| 147 | return Effect.void; | |
| 148 | } | |
| 149 | return onTurnCompleted(event); | |
| 150 | }), | |
| 158 | const worker = Effect.forever( | |
| 159 | Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), | |
| 151 | 160 | ); |
| 161 | yield* Effect.forkScoped(worker); | |
| 152 | 162 | |
| 153 | const worker = Effect.forever(Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent))); | |
| 154 | yield* Effect.forkScoped(worker); | |
| 155 | ||
| 156 | yield* Effect.forEach(adapters, (adapter) => | |
| 157 | Stream.runForEach(adapter.streamEvents, (event) => | |
| 158 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid), | |
| 159 | ).pipe(Effect.forkScoped), | |
| 160 | ).pipe(Effect.asVoid); | |
| 163 | yield* Effect.forEach(adapters, (adapter) => | |
| 164 | Stream.runForEach(adapter.streamEvents, (event) => | |
| 165 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid), | |
| 166 | ).pipe(Effect.forkScoped), | |
| 167 | ).pipe(Effect.asVoid); | |
| 161 | 168 | |
| 162 | const adapterForSession = (sessionId: string) => | |
| 163 | directory | |
| 164 | .getProvider(sessionId) | |
| 165 | .pipe(Effect.flatMap((provider) => registry.getByProvider(provider))); | |
| 169 | const adapterForSession = (sessionId: string) => | |
| 170 | directory | |
| 171 | .getProvider(sessionId) | |
| 172 | .pipe(Effect.flatMap((provider) => registry.getByProvider(provider))); | |
| 166 | 173 | |
| 167 | 174 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => |
| 168 | 175 | Effect.gen(function* () { |
| ... | ... | @@ -361,12 +368,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 361 | 368 | startSession, |
| 362 | 369 | sendTurn, |
| 363 | 370 | interruptTurn, |
| 364 | respondToRequest, | |
| 365 | stopSession, | |
| 366 | listSessions, | |
| 367 | listCheckpoints, | |
| 368 | getCheckpointDiff, | |
| 369 | revertToCheckpoint, | |
| 371 | respondToRequest, | |
| 372 | stopSession, | |
| 373 | listSessions, | |
| 374 | listCheckpoints, | |
| 375 | getCheckpointDiff, | |
| 376 | revertToCheckpoint, | |
| 370 | 377 | stopAll, |
| 371 | 378 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 372 | 379 | } satisfies ProviderServiceShape; |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+88-3| ... | ... | @@ -1,12 +1,46 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { NodeServices } from "@effect/platform-node"; | |
| 1 | 6 | import { it, assert } from "@effect/vitest"; |
| 2 | 7 | import { assertFailure, assertSome } from "@effect/vitest/utils"; |
| 3 | import { Effect } from "effect"; | |
| 8 | import { Effect, Layer, Stream } from "effect"; | |
| 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | 10 | |
| 5 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 11 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 12 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | |
| 13 | import { ProviderSessionNotFoundError, ProviderValidationError, type ProviderAdapterError } from "../Errors.ts"; | |
| 14 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 6 | 15 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 7 | 16 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 8 | 17 | |
| 9 | const layer = it.layer(ProviderSessionDirectoryLive); | |
| 18 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { | |
| 19 | return ProviderSessionDirectoryLive.pipe( | |
| 20 | Layer.provide(ProviderSessionRepositoryLive), | |
| 21 | Layer.provide(persistenceLayer), | |
| 22 | ); | |
| 23 | } | |
| 24 | ||
| 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)); | |
| 10 | 44 | |
| 11 | 45 | layer("ProviderSessionDirectoryLive", (it) => { |
| 12 | 46 | it("upserts, reads, and removes session bindings", () => |
| ... | ... | @@ -57,4 +91,55 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 57 | 91 | }), |
| 58 | 92 | ); |
| 59 | 93 | })); |
| 94 | ||
| 95 | it("rehydrates persisted mappings across layer restart", () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); | |
| 98 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 99 | const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); | |
| 100 | ||
| 101 | yield* Effect.gen(function* () { | |
| 102 | const directory = yield* ProviderSessionDirectory; | |
| 103 | yield* directory.upsert({ | |
| 104 | sessionId: "sess-restart", | |
| 105 | provider: "codex", | |
| 106 | threadId: "thread-restart", | |
| 107 | }); | |
| 108 | }).pipe(Effect.provide(directoryLayer)); | |
| 109 | ||
| 110 | yield* Effect.gen(function* () { | |
| 111 | const directory = yield* ProviderSessionDirectory; | |
| 112 | const provider = yield* directory.getProvider("sess-restart"); | |
| 113 | assert.equal(provider, "codex"); | |
| 114 | ||
| 115 | const threadId = yield* directory.getThreadId("sess-restart"); | |
| 116 | assertSome(threadId, "thread-restart"); | |
| 117 | }).pipe(Effect.provide(directoryLayer)); | |
| 118 | ||
| 119 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 120 | }).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 | })); | |
| 60 | 145 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+120-52| ... | ... | @@ -1,27 +1,78 @@ |
| 1 | /** | |
| 2 | * ProviderSessionDirectoryLive - In-memory session ownership index layer. | |
| 3 | * | |
| 4 | * Tracks provider ownership and optional thread id by `sessionId` so | |
| 5 | * `ProviderService` can route session-scoped API calls to the right adapter. | |
| 6 | * This layer stores metadata only and does not call provider adapters directly. | |
| 7 | * | |
| 8 | * @module ProviderSessionDirectoryLive | |
| 9 | */ | |
| 10 | import { Effect, Layer, Option, Ref } from "effect"; | |
| 11 | ||
| 12 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 1 | import { Effect, Layer, Option } from "effect"; | |
| 2 | ||
| 3 | import { ProviderSessionRepository } from "../../persistence/Services/ProviderSessions.ts"; | |
| 4 | import { | |
| 5 | ProviderSessionDirectoryPersistenceError, | |
| 6 | ProviderSessionNotFoundError, | |
| 7 | ProviderValidationError, | |
| 8 | } from "../Errors.ts"; | |
| 13 | 9 | import { |
| 14 | 10 | ProviderSessionDirectory, |
| 15 | 11 | type ProviderSessionBinding, |
| 16 | 12 | type ProviderSessionDirectoryShape, |
| 17 | 13 | } from "../Services/ProviderSessionDirectory.ts"; |
| 18 | 14 | |
| 15 | function toPersistenceError(operation: string) { | |
| 16 | return (cause: unknown) => | |
| 17 | new ProviderSessionDirectoryPersistenceError({ | |
| 18 | operation, | |
| 19 | detail: `Failed to execute ${operation}.`, | |
| 20 | cause, | |
| 21 | }); | |
| 22 | } | |
| 23 | ||
| 24 | function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding { | |
| 25 | return { | |
| 26 | sessionId: binding.sessionId.trim(), | |
| 27 | provider: binding.provider, | |
| 28 | ...(binding.threadId?.trim() ? { threadId: binding.threadId.trim() } : {}), | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 19 | 32 | const makeProviderSessionDirectory = Effect.gen(function* () { |
| 20 | const bindingsRef = yield* Ref.make(new Map<string, ProviderSessionBinding>()); | |
| 33 | const repository = yield* ProviderSessionRepository; | |
| 34 | ||
| 35 | const persistUpsert = (binding: ProviderSessionBinding) => | |
| 36 | repository | |
| 37 | .upsertSession({ | |
| 38 | sessionId: binding.sessionId, | |
| 39 | provider: binding.provider, | |
| 40 | ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), | |
| 41 | }) | |
| 42 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsertSession"))); | |
| 43 | ||
| 44 | const persistDelete = (sessionId: string) => | |
| 45 | repository | |
| 46 | .deleteSession({ sessionId }) | |
| 47 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteSession"))); | |
| 48 | ||
| 49 | const getBinding = (sessionId: string) => | |
| 50 | repository.getSession({ sessionId }).pipe( | |
| 51 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getSession")), | |
| 52 | Effect.map((entry) => | |
| 53 | Option.map(entry, (value) => ({ | |
| 54 | sessionId: value.sessionId, | |
| 55 | provider: value.provider, | |
| 56 | ...(value.threadId !== undefined ? { threadId: value.threadId } : {}), | |
| 57 | })), | |
| 58 | ), | |
| 59 | ); | |
| 60 | ||
| 61 | const listBindings = () => | |
| 62 | repository.listSessions().pipe( | |
| 63 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:listSessions")), | |
| 64 | Effect.map((rows) => | |
| 65 | rows.map((row) => ({ | |
| 66 | sessionId: row.sessionId, | |
| 67 | provider: row.provider, | |
| 68 | ...(row.threadId !== undefined ? { threadId: row.threadId } : {}), | |
| 69 | })), | |
| 70 | ), | |
| 71 | ); | |
| 21 | 72 | |
| 22 | 73 | const upsert: ProviderSessionDirectoryShape["upsert"] = (binding) => { |
| 23 | const sessionId = binding.sessionId.trim(); | |
| 24 | if (sessionId.length === 0) { | |
| 74 | const normalized = normalizeBinding(binding); | |
| 75 | if (normalized.sessionId.length === 0) { | |
| 25 | 76 | return Effect.fail( |
| 26 | 77 | new ProviderValidationError({ |
| 27 | 78 | operation: "ProviderSessionDirectory.upsert", |
| ... | ... | @@ -30,53 +81,65 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 30 | 81 | ); |
| 31 | 82 | } |
| 32 | 83 | |
| 33 | const normalized: ProviderSessionBinding = { | |
| 34 | sessionId, | |
| 35 | provider: binding.provider, | |
| 36 | ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), | |
| 37 | }; | |
| 38 | ||
| 39 | return Ref.update(bindingsRef, (current) => { | |
| 40 | const next = new Map(current); | |
| 41 | next.set(sessionId, normalized); | |
| 42 | return next; | |
| 43 | }); | |
| 84 | return persistUpsert(normalized); | |
| 44 | 85 | }; |
| 45 | 86 | |
| 46 | 87 | const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => |
| 47 | Ref.get(bindingsRef).pipe( | |
| 48 | Effect.flatMap((bindings) => { | |
| 49 | const binding = bindings.get(sessionId); | |
| 50 | if (!binding) { | |
| 51 | return Effect.fail(new ProviderSessionNotFoundError({ sessionId })); | |
| 52 | } | |
| 53 | return Effect.succeed(binding.provider); | |
| 54 | }), | |
| 88 | getBinding(sessionId).pipe( | |
| 89 | Effect.flatMap((binding) => | |
| 90 | Option.match(binding, { | |
| 91 | onSome: (value) => Effect.succeed(value.provider), | |
| 92 | onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), | |
| 93 | }), | |
| 94 | ), | |
| 55 | 95 | ); |
| 56 | 96 | |
| 57 | 97 | const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => |
| 58 | Ref.get(bindingsRef).pipe( | |
| 59 | Effect.flatMap((bindings) => { | |
| 60 | const binding = bindings.get(sessionId); | |
| 61 | if (!binding) { | |
| 62 | return Effect.fail(new ProviderSessionNotFoundError({ sessionId })); | |
| 63 | } | |
| 64 | return Effect.succeed(Option.fromNullishOr(binding.threadId)); | |
| 65 | }), | |
| 98 | getBinding(sessionId).pipe( | |
| 99 | Effect.flatMap((binding) => | |
| 100 | Option.match(binding, { | |
| 101 | onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)), | |
| 102 | onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), | |
| 103 | }), | |
| 104 | ), | |
| 66 | 105 | ); |
| 67 | 106 | |
| 68 | const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => | |
| 69 | Ref.update(bindingsRef, (current) => { | |
| 70 | if (!current.has(sessionId)) { | |
| 71 | return current; | |
| 72 | } | |
| 73 | const next = new Map(current); | |
| 74 | next.delete(sessionId); | |
| 75 | return next; | |
| 76 | }); | |
| 107 | const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => persistDelete(sessionId); | |
| 77 | 108 | |
| 78 | 109 | const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => |
| 79 | Ref.get(bindingsRef).pipe(Effect.map((bindings) => Array.from(bindings.keys()))); | |
| 110 | listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId))); | |
| 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 | }); | |
| 80 | 143 | |
| 81 | 144 | return { |
| 82 | 145 | upsert, |
| ... | ... | @@ -84,6 +147,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 84 | 147 | getThreadId, |
| 85 | 148 | remove, |
| 86 | 149 | listSessionIds, |
| 150 | reconcileWithAdapters, | |
| 87 | 151 | } satisfies ProviderSessionDirectoryShape; |
| 88 | 152 | }); |
| 89 | 153 | |
| ... | ... | @@ -91,3 +155,7 @@ export const ProviderSessionDirectoryLive = Layer.effect( |
| 91 | 155 | ProviderSessionDirectory, |
| 92 | 156 | makeProviderSessionDirectory, |
| 93 | 157 | ); |
| 158 | ||
| 159 | export function makeProviderSessionDirectoryLive() { | |
| 160 | return Layer.effect(ProviderSessionDirectory, makeProviderSessionDirectory); | |
| 161 | } |
apps/server/src/provider/Services/ProviderSessionDirectory.ts+37-6| ... | ... | @@ -11,7 +11,13 @@ import type { ProviderKind } from "@t3tools/contracts"; |
| 11 | 11 | import { Option, ServiceMap } from "effect"; |
| 12 | 12 | import type { Effect } from "effect"; |
| 13 | 13 | |
| 14 | import type { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 14 | import type { | |
| 15 | ProviderAdapterError, | |
| 16 | ProviderSessionDirectoryPersistenceError, | |
| 17 | ProviderSessionNotFoundError, | |
| 18 | ProviderValidationError, | |
| 19 | } from "../Errors.ts"; | |
| 20 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | |
| 15 | 21 | |
| 16 | 22 | export interface ProviderSessionBinding { |
| 17 | 23 | readonly sessionId: string; |
| ... | ... | @@ -19,37 +25,62 @@ export interface ProviderSessionBinding { |
| 19 | 25 | readonly threadId?: string; |
| 20 | 26 | } |
| 21 | 27 | |
| 28 | export type ProviderSessionDirectoryReadError = | |
| 29 | | ProviderSessionNotFoundError | |
| 30 | | ProviderSessionDirectoryPersistenceError; | |
| 31 | ||
| 32 | export type ProviderSessionDirectoryWriteError = | |
| 33 | | ProviderValidationError | |
| 34 | | ProviderSessionDirectoryPersistenceError; | |
| 35 | ||
| 22 | 36 | export interface ProviderSessionDirectoryShape { |
| 23 | 37 | /** |
| 24 | 38 | * Record or update ownership for one provider session. |
| 25 | 39 | */ |
| 26 | 40 | readonly upsert: ( |
| 27 | 41 | binding: ProviderSessionBinding, |
| 28 | ) => Effect.Effect<void, ProviderValidationError>; | |
| 42 | ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>; | |
| 29 | 43 | |
| 30 | 44 | /** |
| 31 | 45 | * Resolve the provider owner for a session id. |
| 32 | 46 | */ |
| 33 | 47 | readonly getProvider: ( |
| 34 | 48 | sessionId: string, |
| 35 | ) => Effect.Effect<ProviderKind, ProviderSessionNotFoundError>; | |
| 49 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; | |
| 36 | 50 | |
| 37 | 51 | /** |
| 38 | 52 | * Resolve the tracked thread id for a session, if known. |
| 39 | 53 | */ |
| 40 | 54 | readonly getThreadId: ( |
| 41 | 55 | sessionId: string, |
| 42 | ) => Effect.Effect<Option.Option<string>, ProviderSessionNotFoundError>; | |
| 56 | ) => Effect.Effect<Option.Option<string>, ProviderSessionDirectoryReadError>; | |
| 43 | 57 | |
| 44 | 58 | /** |
| 45 | 59 | * Remove a session binding. |
| 46 | 60 | */ |
| 47 | readonly remove: (sessionId: string) => Effect.Effect<void>; | |
| 61 | readonly remove: ( | |
| 62 | sessionId: string, | |
| 63 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; | |
| 48 | 64 | |
| 49 | 65 | /** |
| 50 | 66 | * List tracked session ids. |
| 51 | 67 | */ |
| 52 | readonly listSessionIds: () => Effect.Effect<ReadonlyArray<string>>; | |
| 68 | readonly listSessionIds: () => Effect.Effect< | |
| 69 | ReadonlyArray<string>, | |
| 70 | ProviderSessionDirectoryPersistenceError | |
| 71 | >; | |
| 72 | ||
| 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 | >; | |
| 53 | 84 | } |
| 54 | 85 | |
| 55 | 86 | /** |
apps/server/src/wsServer.ts+5-1| ... | ... | @@ -55,6 +55,7 @@ import { ProviderService, type ProviderServiceShape } from "./provider/Services/ |
| 55 | 55 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 56 | 56 | import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; |
| 57 | 57 | import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; |
| 58 | import { ProviderSessionRepositoryLive } from "./persistence/Layers/ProviderSessions"; | |
| 58 | 59 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 59 | 60 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 60 | 61 | import * as SqlError from "effect/unstable/sql/SqlError"; |
| ... | ... | @@ -637,12 +638,15 @@ export function createServer(options: ServerOptions) { |
| 637 | 638 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 638 | 639 | Layer.provide(codexAdapterLayer), |
| 639 | 640 | ); |
| 641 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 642 | Layer.provide(ProviderSessionRepositoryLive), | |
| 643 | ); | |
| 640 | 644 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); |
| 641 | 645 | const checkpointDependenciesLayer = Layer.mergeAll( |
| 642 | 646 | checkpointStoreLayer, |
| 643 | 647 | CheckpointRepositoryLive, |
| 644 | 648 | adapterRegistryLayer, |
| 645 | ProviderSessionDirectoryLive, | |
| 649 | providerSessionDirectoryLayer, | |
| 646 | 650 | ); |
| 647 | 651 | const checkpointServiceLayer = CheckpointServiceLive.pipe( |
| 648 | 652 | Layer.provideMerge(checkpointDependenciesLayer), |