From 150b3782b942e3b35899e134536efc0e4bbd51ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 12:38:21 -0800 Subject: [PATCH] more effect: new service graph --- apps/server/src/checkpointing/Errors.ts | 148 ++++++++++++++ .../Services/CheckpointCatalog.ts | 83 ++++++++ .../Services/CheckpointService.ts | 78 ++++++++ .../Services/CheckpointStore.ts | 10 +- apps/server/src/orchestration/Service.ts | 8 +- .../Services/OrchestrationEvents.ts | 7 +- .../src/persistence/Services/Projects.ts | 7 +- apps/server/src/provider/Errors.ts | 145 ++++---------- apps/server/src/provider/Services/Codex.ts | 110 ----------- .../src/provider/Services/CodexAdapter.ts | 27 +++ .../src/provider/Services/ProviderAdapter.ts | 104 ++++++++++ .../Services/ProviderAdapterRegistry.ts | 37 ++++ .../{Provider.ts => ProviderService.ts} | 18 +- .../Services/ProviderSessionDirectory.ts | 61 ++++++ apps/server/src/provider/contracts.test.ts | 187 ++++++++++++++---- 15 files changed, 761 insertions(+), 269 deletions(-) create mode 100644 apps/server/src/checkpointing/Errors.ts create mode 100644 apps/server/src/checkpointing/Services/CheckpointCatalog.ts create mode 100644 apps/server/src/checkpointing/Services/CheckpointService.ts rename apps/server/src/{provider => checkpointing}/Services/CheckpointStore.ts (86%) delete mode 100644 apps/server/src/provider/Services/Codex.ts create mode 100644 apps/server/src/provider/Services/CodexAdapter.ts create mode 100644 apps/server/src/provider/Services/ProviderAdapter.ts create mode 100644 apps/server/src/provider/Services/ProviderAdapterRegistry.ts rename apps/server/src/provider/Services/{Provider.ts => ProviderService.ts} (84%) create mode 100644 apps/server/src/provider/Services/ProviderSessionDirectory.ts diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..584ce90b7084bd420f74f0ccab8d1e226264bf44 --- /dev/null +++ b/apps/server/src/checkpointing/Errors.ts @@ -0,0 +1,148 @@ +import { Schema } from "effect"; + +/** + * CheckpointValidationError - Invalid checkpoint API input. + */ +export class CheckpointValidationError extends Schema.TaggedErrorClass()( + "CheckpointValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CheckpointGitCommandError - Git command execution failed in checkpoint store. + */ +export class CheckpointGitCommandError extends Schema.TaggedErrorClass()( + "CheckpointGitCommandError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +/** + * CheckpointUnavailableError - Expected checkpoint does not exist. + */ +export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointUnavailableError", + { + threadId: Schema.String, + turnCount: Schema.Number, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; + } +} + +/** + * CheckpointRepositoryError - Checkpointing unavailable for cwd/repository. + */ +export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( + "CheckpointRepositoryError", + { + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint repository error for ${this.cwd}: ${this.detail}`; + } +} + +/** + * CheckpointCatalogPersistenceError - Metadata persistence/read failure. + */ +export class CheckpointCatalogPersistenceError extends Schema.TaggedErrorClass()( + "CheckpointCatalogPersistenceError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint catalog persistence error in ${this.operation}: ${this.detail}`; + } +} + +/** + * CheckpointServiceValidationError - Invalid application-level checkpoint request. + */ +export class CheckpointServiceValidationError extends Schema.TaggedErrorClass()( + "CheckpointServiceValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint service validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow. + */ +export class CheckpointSessionNotFoundError extends Schema.TaggedErrorClass()( + "CheckpointSessionNotFoundError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint session not found: ${this.sessionId}`; + } +} + +/** + * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. + */ +export class CheckpointInvariantError extends Schema.TaggedErrorClass()( + "CheckpointInvariantError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; + } +} + +export type CheckpointStoreError = + | CheckpointValidationError + | CheckpointGitCommandError + | CheckpointUnavailableError + | CheckpointRepositoryError; + +export type CheckpointCatalogError = + | CheckpointValidationError + | CheckpointCatalogPersistenceError; + +export type CheckpointServiceError = + | CheckpointStoreError + | CheckpointCatalogError + | CheckpointServiceValidationError + | CheckpointSessionNotFoundError + | CheckpointInvariantError; diff --git a/apps/server/src/checkpointing/Services/CheckpointCatalog.ts b/apps/server/src/checkpointing/Services/CheckpointCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9e00e08a90dc6742c0a9b138b277d99d3f4f3de --- /dev/null +++ b/apps/server/src/checkpointing/Services/CheckpointCatalog.ts @@ -0,0 +1,83 @@ +/** + * CheckpointCatalog - Metadata repository for checkpoints. + * + * Stores user-facing checkpoint metadata used by list/revert APIs. It does not + * touch the filesystem or hidden Git refs; `CheckpointStore` owns that state. + * It also does not drive provider conversation rollback. + * + * @module CheckpointCatalog + */ +import type { ProviderCheckpoint } from "@t3tools/contracts"; +import { Option, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointCatalogError } from "../Errors.ts"; + +export interface UpsertCheckpointInput { + readonly providerSessionId: string; + readonly threadId: string; + readonly turnCount: number; + readonly messageCount: number; + readonly label: string; + readonly preview?: string; + readonly createdAt: string; +} + +export interface ListSessionCheckpointsInput { + readonly providerSessionId: string; +} + +export interface GetCheckpointInput { + readonly providerSessionId: string; + readonly turnCount: number; +} + +export interface DeleteAfterTurnInput { + readonly providerSessionId: string; + readonly maxTurnCount: number; +} + +export interface DeleteAllForSessionInput { + readonly providerSessionId: string; +} + +export interface CheckpointCatalogShape { + /** + * Insert or update one checkpoint metadata row. + */ + readonly upsertCheckpoint: (input: UpsertCheckpointInput) => Effect.Effect; + + /** + * List checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ListSessionCheckpointsInput, + ) => Effect.Effect, CheckpointCatalogError>; + + /** + * Read one checkpoint by turn count. + */ + readonly getCheckpoint: ( + input: GetCheckpointInput, + ) => Effect.Effect, CheckpointCatalogError>; + + /** + * Delete checkpoint metadata newer than the provided turn count. + */ + readonly deleteAfterTurn: (input: DeleteAfterTurnInput) => Effect.Effect; + + /** + * Delete all checkpoint metadata for a provider session. + */ + readonly deleteAllForSession: ( + input: DeleteAllForSessionInput, + ) => Effect.Effect; +} + +/** + * CheckpointCatalog - Service tag for checkpoint metadata persistence. + */ +export class CheckpointCatalog extends ServiceMap.Service< + CheckpointCatalog, + CheckpointCatalogShape +>()("checkpointing/CheckpointCatalog") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointService.ts b/apps/server/src/checkpointing/Services/CheckpointService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4622b1d18263960c6490d7cd06830b07236d0c6c --- /dev/null +++ b/apps/server/src/checkpointing/Services/CheckpointService.ts @@ -0,0 +1,78 @@ +/** + * CheckpointService - Application orchestrator for checkpoint workflows. + * + * Coordinates `CheckpointStore` (Git refs), `CheckpointCatalog` (metadata), and + * provider timeline operations resolved through `ProviderSessionDirectory` and + * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters). + * + * This service owns checkpoint workflow rules and validation, while concrete + * storage/process details stay in lower-level services. + * + * @module CheckpointService + */ +import type { + ProviderGetCheckpointDiffInput, + ProviderGetCheckpointDiffResult, + ProviderListCheckpointsInput, + ProviderListCheckpointsResult, + ProviderRevertToCheckpointInput, + ProviderRevertToCheckpointResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointServiceError } from "../Errors.ts"; + +export interface InitializeCheckpointSessionInput { + readonly providerSessionId: string; + readonly cwd: string; +} + +export interface CaptureCurrentTurnInput { + readonly providerSessionId: string; + readonly turnId?: string; + readonly status?: string; +} + +export interface CheckpointServiceShape { + /** + * Initialize root checkpoint and metadata for a provider session. + */ + readonly initializeForSession: ( + input: InitializeCheckpointSessionInput, + ) => Effect.Effect; + + /** + * Capture the current thread turn into checkpoint refs/metadata. + */ + readonly captureCurrentTurn: (input: CaptureCurrentTurnInput) => Effect.Effect; + + /** + * List checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ProviderListCheckpointsInput, + ) => Effect.Effect; + + /** + * Diff two checkpoint turn counts for a provider session. + */ + readonly getCheckpointDiff: ( + input: ProviderGetCheckpointDiffInput, + ) => Effect.Effect; + + /** + * Revert provider/filesystem state to a checkpoint. + */ + readonly revertToCheckpoint: ( + input: ProviderRevertToCheckpointInput, + ) => Effect.Effect; +} + +/** + * CheckpointService - Service tag for checkpoint orchestration. + */ +export class CheckpointService extends ServiceMap.Service< + CheckpointService, + CheckpointServiceShape +>()("checkpointing/CheckpointService") {} diff --git a/apps/server/src/provider/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts similarity index 86% rename from apps/server/src/provider/Services/CheckpointStore.ts rename to apps/server/src/checkpointing/Services/CheckpointStore.ts index 98bbacbeda1b87cb49ff9a154966bf43ff4b1078..81fa60c305f6f3928c11506f80919e446479ce33 100644 --- a/apps/server/src/provider/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -1,8 +1,12 @@ /** * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. * + * Owns hidden Git-ref checkpoint capture/restore and diff computation for a + * workspace thread timeline. It does not store user-facing checkpoint metadata + * and does not coordinate provider conversation rollback. + * * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed - * domain errors for checkpoint lifecycle operations. + * domain errors for checkpoint storage operations. * * @module CheckpointStore */ @@ -83,9 +87,9 @@ export interface CheckpointStoreShape { } /** - * CheckpointStore - Context tag for checkpoint persistence and restore operations. + * CheckpointStore - Service tag for checkpoint persistence and restore operations. */ export class CheckpointStore extends ServiceMap.Service< CheckpointStore, CheckpointStoreShape ->()("provider/CheckpointStore") {} +>()("checkpointing/CheckpointStore") {} diff --git a/apps/server/src/orchestration/Service.ts b/apps/server/src/orchestration/Service.ts index 1dadacce97b56db17a02e5a59d1b72567a7ad112..e841d3afd1dacf653df21dd9501c382359b75ead 100644 --- a/apps/server/src/orchestration/Service.ts +++ b/apps/server/src/orchestration/Service.ts @@ -1,7 +1,11 @@ /** * OrchestrationEngineService - Service interface for orchestration command handling. * - * Uses Effect `Context.Tag` for dependency injection. Command dispatch, + * Owns command validation/dispatch and in-memory read-model updates backed by + * `OrchestrationEventRepository` persistence. It does not own provider process + * management or transport concerns (e.g. websocket request parsing). + * + * Uses Effect `ServiceMap.Service` for dependency injection. Command dispatch, * replay, and unknown-input decoding all return typed domain errors. * * @module OrchestrationEngineService @@ -79,7 +83,7 @@ export interface OrchestrationEngineShape { } /** - * OrchestrationEngineService - Context tag for orchestration engine access. + * OrchestrationEngineService - Service tag for orchestration engine access. * * @example * ```ts diff --git a/apps/server/src/persistence/Services/OrchestrationEvents.ts b/apps/server/src/persistence/Services/OrchestrationEvents.ts index 6761bf9823659769f354e0275e617f0e96965b25..88545f142df8c50816babeb88b1e27d238572a1e 100644 --- a/apps/server/src/persistence/Services/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Services/OrchestrationEvents.ts @@ -1,7 +1,10 @@ /** * OrchestrationEventRepository - Repository interface for orchestration events. * - * Uses Effect `Context.Tag` for dependency injection and exposes typed + * Owns durable append/replay access to the orchestration event stream. It does + * not reduce events into read models or apply command validation rules. + * + * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed * persistence/decode errors for event append and replay operations. * * @module OrchestrationEventRepository @@ -44,7 +47,7 @@ export interface OrchestrationEventRepositoryShape { } /** - * OrchestrationEventRepository - Context tag for orchestration event persistence. + * OrchestrationEventRepository - Service tag for orchestration event persistence. * * @example * ```ts diff --git a/apps/server/src/persistence/Services/Projects.ts b/apps/server/src/persistence/Services/Projects.ts index 5f8a67c7840593a963a06355d71a9c1097942021..c27ddab045d4dfcc8b416f38078cbce7f16c4569 100644 --- a/apps/server/src/persistence/Services/Projects.ts +++ b/apps/server/src/persistence/Services/Projects.ts @@ -1,7 +1,10 @@ /** * ProjectRepository - Repository interface for project persistence. * - * Uses Effect `Context.Tag` for dependency injection and returns typed + * Owns persisted project records (cwd, scripts, and cleanup of missing paths). + * It does not manage provider sessions, orchestration events, or websocket APIs. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns typed * domain errors for all operations. * * @module ProjectRepository @@ -62,7 +65,7 @@ export interface ProjectRepositoryShape { } /** - * ProjectRepository - Context tag for project persistence dependency injection. + * ProjectRepository - Service tag for project persistence dependency injection. * * @example * ```ts diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 9655b1f6521a8d7de980e575e4efeb76f28da190..6339abc05e7f8e5f6b964412d4986d04e685d674 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -1,163 +1,104 @@ import { Schema } from "effect"; -/** - * CheckpointValidationError - Invalid checkpoint API input. - */ -export class CheckpointValidationError extends Schema.TaggedErrorClass()( - "CheckpointValidationError", - { - operation: Schema.String, - issue: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint validation failed in ${this.operation}: ${this.issue}`; - } -} - -/** - * CheckpointGitCommandError - Git command execution failed in checkpoint store. - */ -export class CheckpointGitCommandError extends Schema.TaggedErrorClass()( - "CheckpointGitCommandError", - { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } -} - -/** - * CheckpointUnavailableError - Expected checkpoint does not exist. - */ -export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( - "CheckpointUnavailableError", - { - threadId: Schema.String, - turnCount: Schema.Number, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; - } -} - -/** - * CheckpointRepositoryError - Checkpointing unavailable for cwd/repository. - */ -export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( - "CheckpointRepositoryError", - { - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint repository error for ${this.cwd}: ${this.detail}`; - } -} +import type { CheckpointServiceError } from "../checkpointing/Errors.ts"; /** - * CodexValidationError - Invalid Codex API input. + * ProviderAdapterValidationError - Invalid adapter API input. */ -export class CodexValidationError extends Schema.TaggedErrorClass()( - "CodexValidationError", +export class ProviderAdapterValidationError extends Schema.TaggedErrorClass()( + "ProviderAdapterValidationError", { + provider: Schema.String, operation: Schema.String, issue: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex validation failed in ${this.operation}: ${this.issue}`; + return `Provider adapter validation failed (${this.provider}) in ${this.operation}: ${this.issue}`; } } /** - * CodexSessionNotFoundError - Session id is unknown. + * ProviderAdapterSessionNotFoundError - Adapter-owned session id is unknown. */ -export class CodexSessionNotFoundError extends Schema.TaggedErrorClass()( - "CodexSessionNotFoundError", +export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass()( + "ProviderAdapterSessionNotFoundError", { + provider: Schema.String, sessionId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Unknown codex session: ${this.sessionId}`; + return `Unknown ${this.provider} adapter session: ${this.sessionId}`; } } /** - * CodexSessionClosedError - Session exists but is closed. + * ProviderAdapterSessionClosedError - Adapter session exists but is closed. */ -export class CodexSessionClosedError extends Schema.TaggedErrorClass()( - "CodexSessionClosedError", +export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass()( + "ProviderAdapterSessionClosedError", { + provider: Schema.String, sessionId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex session is closed: ${this.sessionId}`; + return `${this.provider} adapter session is closed: ${this.sessionId}`; } } /** - * CodexProtocolError - Invalid/unexpected JSON-RPC payload from app-server. + * ProviderAdapterProtocolError - Invalid/unexpected provider protocol payload. */ -export class CodexProtocolError extends Schema.TaggedErrorClass()( - "CodexProtocolError", +export class ProviderAdapterProtocolError extends Schema.TaggedErrorClass()( + "ProviderAdapterProtocolError", { + provider: Schema.String, operation: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex protocol error in ${this.operation}: ${this.detail}`; + return `Provider adapter protocol error (${this.provider}) in ${this.operation}: ${this.detail}`; } } /** - * CodexRequestError - JSON-RPC request failed or timed out. + * ProviderAdapterRequestError - Provider protocol request failed or timed out. */ -export class CodexRequestError extends Schema.TaggedErrorClass()( - "CodexRequestError", +export class ProviderAdapterRequestError extends Schema.TaggedErrorClass()( + "ProviderAdapterRequestError", { + provider: Schema.String, method: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex request failed for ${this.method}: ${this.detail}`; + return `Provider adapter request failed (${this.provider}) for ${this.method}: ${this.detail}`; } } /** - * CodexProcessError - codex app-server process lifecycle failure. + * ProviderAdapterProcessError - Provider process lifecycle failure. */ -export class CodexProcessError extends Schema.TaggedErrorClass()( - "CodexProcessError", +export class ProviderAdapterProcessError extends Schema.TaggedErrorClass()( + "ProviderAdapterProcessError", { + provider: Schema.String, sessionId: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex process error for session ${this.sessionId}: ${this.detail}`; + return `Provider adapter process error (${this.provider}) for session ${this.sessionId}: ${this.detail}`; } } @@ -257,19 +198,13 @@ export class ProviderFilesystemError extends Schema.TaggedErrorClass; -} - -export interface CodexThreadSnapshot { - readonly threadId: string; - readonly turns: ReadonlyArray; -} - -export interface CodexServiceShape { - /** - * Start a codex app-server-backed provider session. - */ - readonly startSession: ( - input: ProviderSessionStartInput, - ) => Effect.Effect; - - /** - * Send a new turn to an active codex session. - */ - readonly sendTurn: ( - input: ProviderSendTurnInput, - ) => Effect.Effect; - - /** - * Interrupt an active turn. - */ - readonly interruptTurn: ( - sessionId: string, - turnId?: string, - ) => Effect.Effect; - - /** - * Read a thread snapshot (including turns). - */ - readonly readThread: (sessionId: string) => Effect.Effect; - - /** - * Roll back a thread by N turns. - */ - readonly rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Effect.Effect; - - /** - * Respond to an interactive approval request from codex app-server. - */ - readonly respondToRequest: ( - sessionId: string, - requestId: string, - decision: ProviderApprovalDecision, - ) => Effect.Effect; - - /** - * Stop one session. - */ - readonly stopSession: (sessionId: string) => Effect.Effect; - - /** - * List active sessions. - */ - readonly listSessions: () => Effect.Effect>; - - /** - * Check whether session id is currently active. - */ - readonly hasSession: (sessionId: string) => Effect.Effect; - - /** - * Stop all sessions. - */ - readonly stopAll: () => Effect.Effect; - - /** - * Subscribe to provider events emitted by codex app-server. - */ - readonly subscribeToEvents: ( - callback: (event: ProviderEvent) => void, - ) => Effect.Effect<() => void, CodexServiceError>; -} - -/** - * CodexService - Context tag for codex session and RPC operations. - */ -export class CodexService extends ServiceMap.Service()( - "provider/CodexService", -) {} diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c33eabb7794411dd66e5756c7f35d3b13480da2 --- /dev/null +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -0,0 +1,27 @@ +/** + * CodexAdapter - Codex implementation of the generic provider adapter contract. + * + * This service owns Codex app-server process / JSON-RPC semantics and emits + * Codex provider events. It does not perform cross-provider routing, shared + * event fan-out, or checkpoint orchestration. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "codex"` context. + * + * @module CodexAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CodexAdapterShape extends ProviderAdapterShape { + readonly provider: "codex"; +} + +/** + * CodexAdapter - Service tag for Codex provider adapter operations. + */ +export class CodexAdapter extends ServiceMap.Service()( + "provider/CodexAdapter", +) {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..21b6d8c3dde2456e0ca6ad6dfb3830a0dcd615bb --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -0,0 +1,104 @@ +/** + * ProviderAdapter - Provider-specific runtime adapter contract. + * + * Defines the provider-native session/protocol operations that `ProviderService` + * routes to after resolving the target provider. Implementations should focus + * on provider behavior only and avoid cross-provider orchestration concerns. + * + * @module ProviderAdapter + */ +import type { + ProviderApprovalDecision, + ProviderEvent, + ProviderKind, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import type { Effect } from "effect"; + +export interface ProviderThreadTurnSnapshot { + readonly id: string; + readonly items: ReadonlyArray; +} + +export interface ProviderThreadSnapshot { + readonly threadId: string; + readonly turns: ReadonlyArray; +} + +export interface ProviderAdapterShape { + /** + * Provider kind implemented by this adapter. + */ + readonly provider: ProviderKind; + + /** + * Start a provider-backed session. + */ + readonly startSession: ( + input: ProviderSessionStartInput, + ) => Effect.Effect; + + /** + * Send a turn to an active provider session. + */ + readonly sendTurn: ( + input: ProviderSendTurnInput, + ) => Effect.Effect; + + /** + * Interrupt an active turn. + */ + readonly interruptTurn: (sessionId: string, turnId?: string) => Effect.Effect; + + /** + * Respond to an interactive approval request. + */ + readonly respondToRequest: ( + sessionId: string, + requestId: string, + decision: ProviderApprovalDecision, + ) => Effect.Effect; + + /** + * Stop one provider session. + */ + readonly stopSession: (sessionId: string) => Effect.Effect; + + /** + * List currently active provider sessions for this adapter. + */ + readonly listSessions: () => Effect.Effect>; + + /** + * Check whether this adapter owns an active session id. + */ + readonly hasSession: (sessionId: string) => Effect.Effect; + + /** + * Read a provider thread snapshot. + */ + readonly readThread: (sessionId: string) => Effect.Effect; + + /** + * Roll back a provider thread by N turns. + */ + readonly rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Effect.Effect; + + /** + * Stop all sessions owned by this adapter. + */ + readonly stopAll: () => Effect.Effect; + + /** + * Subscribe to provider events emitted by this adapter. + */ + readonly subscribeToEvents: ( + callback: (event: ProviderEvent) => void, + ) => Effect.Effect<() => void, TError>; +} diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..23066db7911a3164937c045c9faf413b06beb21f --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -0,0 +1,37 @@ +/** + * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. + * + * Maps a provider kind to the concrete adapter service (Codex, Claude, etc). + * It does not own session lifecycle or routing rules; `ProviderService` uses + * this registry together with `ProviderSessionDirectory`. + * + * @module ProviderAdapterRegistry + */ +import type { ProviderKind } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface ProviderAdapterRegistryShape { + /** + * Resolve the adapter for a provider kind. + */ + readonly getByProvider: ( + provider: ProviderKind, + ) => Effect.Effect, ProviderUnsupportedError>; + + /** + * List provider kinds currently registered. + */ + readonly listProviders: () => Effect.Effect>; +} + +/** + * ProviderAdapterRegistry - Service tag for provider adapter lookup. + */ +export class ProviderAdapterRegistry extends ServiceMap.Service< + ProviderAdapterRegistry, + ProviderAdapterRegistryShape +>()("provider/ProviderAdapterRegistry") {} diff --git a/apps/server/src/provider/Services/Provider.ts b/apps/server/src/provider/Services/ProviderService.ts similarity index 84% rename from apps/server/src/provider/Services/Provider.ts rename to apps/server/src/provider/Services/ProviderService.ts index 6ae8ee35118767ca0b458287bcac4b93bb3599d1..6d546bee6e516eb517170c3f26dd1ad506966880 100644 --- a/apps/server/src/provider/Services/Provider.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -1,6 +1,11 @@ /** * ProviderService - Service interface for provider sessions, turns, and checkpoints. * + * Acts as the cross-provider facade used by transports (WebSocket/RPC). It + * resolves provider adapters through `ProviderAdapterRegistry`, routes + * session-scoped calls via `ProviderSessionDirectory`, and exposes one unified + * provider event stream to callers. + * * Uses Effect `ServiceMap.Service` for dependency injection and returns typed * domain errors for validation, session, codex, and checkpoint workflows. * @@ -59,7 +64,9 @@ export interface ProviderServiceShape { /** * Stop a provider session. */ - readonly stopSession: (input: ProviderStopSessionInput) => Effect.Effect; + readonly stopSession: ( + input: ProviderStopSessionInput, + ) => Effect.Effect; /** * List active provider sessions. @@ -92,13 +99,10 @@ export interface ProviderServiceShape { */ readonly stopAll: () => Effect.Effect; - /** - * Dispose provider resources (logs/listeners). - */ - readonly dispose: () => Effect.Effect; - /** * Subscribe to provider event stream. + * + * Fan-out is owned by ProviderService (not by a standalone event-bus service). */ readonly subscribeToEvents: ( callback: (event: ProviderEvent) => void, @@ -106,7 +110,7 @@ export interface ProviderServiceShape { } /** - * ProviderService - Context tag for provider orchestration. + * ProviderService - Service tag for provider orchestration. */ export class ProviderService extends ServiceMap.Service()( "provider/ProviderService", diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts new file mode 100644 index 0000000000000000000000000000000000000000..70cb78b7c02af83f2512d8b37f17d2f3f81848ac --- /dev/null +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -0,0 +1,61 @@ +/** + * ProviderSessionDirectory - Session ownership index across provider adapters. + * + * Tracks which provider owns each `sessionId` so `ProviderService` can route + * session-scoped calls to the correct adapter. It is metadata only and does not + * perform provider RPC or checkpoint operations. + * + * @module ProviderSessionDirectory + */ +import type { ProviderKind } from "@t3tools/contracts"; +import { Option, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; + +export interface ProviderSessionBinding { + readonly sessionId: string; + readonly provider: ProviderKind; + readonly threadId?: string; +} + +export interface ProviderSessionDirectoryShape { + /** + * Record or update ownership for one provider session. + */ + readonly upsert: ( + binding: ProviderSessionBinding, + ) => Effect.Effect; + + /** + * Resolve the provider owner for a session id. + */ + readonly getProvider: ( + sessionId: string, + ) => Effect.Effect; + + /** + * Resolve the tracked thread id for a session, if known. + */ + readonly getThreadId: ( + sessionId: string, + ) => Effect.Effect, ProviderSessionNotFoundError>; + + /** + * Remove a session binding. + */ + readonly remove: (sessionId: string) => Effect.Effect; + + /** + * List tracked session ids. + */ + readonly listSessionIds: () => Effect.Effect>; +} + +/** + * ProviderSessionDirectory - Service tag for session ownership lookup. + */ +export class ProviderSessionDirectory extends ServiceMap.Service< + ProviderSessionDirectory, + ProviderSessionDirectoryShape +>()("provider/ProviderSessionDirectory") {} diff --git a/apps/server/src/provider/contracts.test.ts b/apps/server/src/provider/contracts.test.ts index 3675af73ebda59fcb1c5cbd67b1a1b988040ebd2..73756e09cedc9ae74022c4a2c706ed0e847a35b0 100644 --- a/apps/server/src/provider/contracts.test.ts +++ b/apps/server/src/provider/contracts.test.ts @@ -1,22 +1,31 @@ import { describe, expect, it } from "vitest"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime, Option } from "effect"; import { - CheckpointValidationError, - CodexValidationError, + ProviderAdapterValidationError, ProviderFilesystemError, + ProviderSessionNotFoundError, ProviderUnsupportedError, - type CheckpointStoreError, - type CodexServiceError, + type ProviderAdapterError, type ProviderServiceError, } from "./Errors.ts"; -import { CheckpointStore } from "./Services/CheckpointStore.ts"; -import { CodexService } from "./Services/Codex.ts"; -import { ProviderService } from "./Services/Provider.ts"; +import { + CheckpointValidationError, + type CheckpointCatalogError, + type CheckpointServiceError, + type CheckpointStoreError, +} from "../checkpointing/Errors.ts"; +import { CheckpointCatalog } from "../checkpointing/Services/CheckpointCatalog.ts"; +import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts"; +import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; +import { CodexAdapter } from "./Services/CodexAdapter.ts"; +import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts"; +import { ProviderService } from "./Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "./Services/ProviderSessionDirectory.ts"; -describe("provider service contracts", () => { - it("resolves provider, codex, and checkpoint services from ServiceMap tags", async () => { +describe("provider/checkpoint service contracts", () => { + it("resolves all service tags from ServiceMap", async () => { const providerLive: typeof ProviderService.Service = { startSession: () => Effect.fail(new ProviderUnsupportedError({ provider: "x" })), @@ -35,34 +44,58 @@ describe("provider service contracts", () => { revertToCheckpoint: () => Effect.fail(new ProviderUnsupportedError({ provider: "x" })), stopAll: () => Effect.void, - dispose: () => Effect.void, subscribeToEvents: () => Effect.succeed(() => undefined), }; - const codexLive: typeof CodexService.Service = { + const codexLive: typeof CodexAdapter.Service = { + provider: "codex", startSession: () => - Effect.fail( - new CodexValidationError({ operation: "startSession", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "not implemented", + }), ), sendTurn: () => - Effect.fail( - new CodexValidationError({ operation: "sendTurn", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "sendTurn", + issue: "not implemented", + }), ), interruptTurn: () => - Effect.fail( - new CodexValidationError({ operation: "interruptTurn", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "interruptTurn", + issue: "not implemented", + }), ), readThread: () => - Effect.fail( - new CodexValidationError({ operation: "readThread", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "readThread", + issue: "not implemented", + }), ), rollbackThread: () => - Effect.fail( - new CodexValidationError({ operation: "rollbackThread", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "rollbackThread", + issue: "not implemented", + }), ), respondToRequest: () => - Effect.fail( - new CodexValidationError({ operation: "respondToRequest", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "respondToRequest", + issue: "not implemented", + }), ), stopSession: () => Effect.void, listSessions: () => Effect.succeed([]), @@ -71,7 +104,7 @@ describe("provider service contracts", () => { subscribeToEvents: () => Effect.succeed(() => undefined), }; - const checkpointLive: typeof CheckpointStore.Service = { + const checkpointStoreLive: typeof CheckpointStore.Service = { isGitRepository: () => Effect.succeed(false), captureCheckpoint: () => Effect.void, hasCheckpoint: () => Effect.succeed(false), @@ -87,39 +120,117 @@ describe("provider service contracts", () => { pruneAfterTurn: () => Effect.void, }; + const checkpointCatalogLive: typeof CheckpointCatalog.Service = { + upsertCheckpoint: () => Effect.void, + listCheckpoints: () => Effect.succeed([]), + getCheckpoint: () => Effect.succeed(Option.none()), + deleteAfterTurn: () => Effect.void, + deleteAllForSession: () => Effect.void, + }; + + const checkpointServiceLive: typeof CheckpointService.Service = { + initializeForSession: () => Effect.void, + captureCurrentTurn: () => Effect.void, + listCheckpoints: () => + Effect.fail( + new CheckpointValidationError({ operation: "listCheckpoints", issue: "not implemented" }), + ), + getCheckpointDiff: () => + Effect.fail( + new CheckpointValidationError({ + operation: "getCheckpointDiff", + issue: "not implemented", + }), + ), + revertToCheckpoint: () => + Effect.fail( + new CheckpointValidationError({ + operation: "revertToCheckpoint", + issue: "not implemented", + }), + ), + }; + + const providerAdapterRegistryLive: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codexLive) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + + const providerSessionDirectoryLive: typeof ProviderSessionDirectory.Service = { + upsert: () => Effect.void, + getProvider: (sessionId) => + Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + getThreadId: (sessionId) => + Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + remove: () => Effect.void, + listSessionIds: () => Effect.succeed([]), + }; + const runtime = ManagedRuntime.make( Layer.mergeAll( Layer.succeed(ProviderService, providerLive), - Layer.succeed(CodexService, codexLive), - Layer.succeed(CheckpointStore, checkpointLive), + Layer.succeed(CodexAdapter, codexLive), + Layer.succeed(CheckpointStore, checkpointStoreLive), + Layer.succeed(CheckpointCatalog, checkpointCatalogLive), + Layer.succeed(CheckpointService, checkpointServiceLive), + Layer.succeed(ProviderAdapterRegistry, providerAdapterRegistryLive), + Layer.succeed(ProviderSessionDirectory, providerSessionDirectoryLive), ), ); - const [provider, codex, checkpoint] = await Promise.all([ + const [ + provider, + codex, + checkpointStore, + checkpointCatalog, + checkpointService, + providerAdapterRegistry, + providerSessionDirectory, + ] = await Promise.all([ runtime.runPromise(Effect.service(ProviderService)), - runtime.runPromise(Effect.service(CodexService)), + runtime.runPromise(Effect.service(CodexAdapter)), runtime.runPromise(Effect.service(CheckpointStore)), + runtime.runPromise(Effect.service(CheckpointCatalog)), + runtime.runPromise(Effect.service(CheckpointService)), + runtime.runPromise(Effect.service(ProviderAdapterRegistry)), + runtime.runPromise(Effect.service(ProviderSessionDirectory)), ]); expect(provider).toBe(providerLive); expect(codex).toBe(codexLive); - expect(checkpoint).toBe(checkpointLive); + expect(checkpointStore).toBe(checkpointStoreLive); + expect(checkpointCatalog).toBe(checkpointCatalogLive); + expect(checkpointService).toBe(checkpointServiceLive); + expect(providerAdapterRegistry).toBe(providerAdapterRegistryLive); + expect(providerSessionDirectory).toBe(providerSessionDirectoryLive); await runtime.dispose(); }); }); -describe("provider error taxonomy", () => { - it("keeps defect cause and message on tagged errors", () => { - const cause = new Error("filesystem failed"); - const error = new ProviderFilesystemError({ +describe("provider/checkpoint errors", () => { + it("preserves cause and message on tagged errors", () => { + const fsCause = new Error("filesystem failed"); + const fsError = new ProviderFilesystemError({ sessionId: "sess-1", detail: "capture failed", - cause, + cause: fsCause, }); - expect(error._tag).toBe("ProviderFilesystemError"); - expect(error.message).toContain("sess-1"); - expect(error.cause).toBe(cause); + expect(fsError._tag).toBe("ProviderFilesystemError"); + expect(fsError.message).toContain("sess-1"); + expect(fsError.cause).toBe(fsCause); + }); + + it("accepts checkpoint catalog error channel type", () => { + const error: CheckpointCatalogError = new CheckpointValidationError({ + operation: "upsertCheckpoint", + issue: "turnCount must be >= 0", + }); + + expect(error._tag).toBe("CheckpointValidationError"); }); }); -- 2.54.0