From 5bfd0598e763ba8221ae90b6b68524391a8492a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 11:28:50 -0800 Subject: [PATCH] more effect: add new service definitions --- apps/server/src/provider/Errors.ts | 282 ++++++++++++++++++ .../src/provider/Services/CheckpointStore.ts | 91 ++++++ apps/server/src/provider/Services/Codex.ts | 110 +++++++ apps/server/src/provider/Services/Provider.ts | 113 +++++++ apps/server/src/provider/contracts.test.ts | 125 ++++++++ 5 files changed, 721 insertions(+) create mode 100644 apps/server/src/provider/Errors.ts create mode 100644 apps/server/src/provider/Services/CheckpointStore.ts create mode 100644 apps/server/src/provider/Services/Codex.ts create mode 100644 apps/server/src/provider/Services/Provider.ts create mode 100644 apps/server/src/provider/contracts.test.ts diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..9655b1f6521a8d7de980e575e4efeb76f28da190 --- /dev/null +++ b/apps/server/src/provider/Errors.ts @@ -0,0 +1,282 @@ +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}`; + } +} + +/** + * CodexValidationError - Invalid Codex API input. + */ +export class CodexValidationError extends Schema.TaggedErrorClass()( + "CodexValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CodexSessionNotFoundError - Session id is unknown. + */ +export class CodexSessionNotFoundError extends Schema.TaggedErrorClass()( + "CodexSessionNotFoundError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unknown codex session: ${this.sessionId}`; + } +} + +/** + * CodexSessionClosedError - Session exists but is closed. + */ +export class CodexSessionClosedError extends Schema.TaggedErrorClass()( + "CodexSessionClosedError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex session is closed: ${this.sessionId}`; + } +} + +/** + * CodexProtocolError - Invalid/unexpected JSON-RPC payload from app-server. + */ +export class CodexProtocolError extends Schema.TaggedErrorClass()( + "CodexProtocolError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex protocol error in ${this.operation}: ${this.detail}`; + } +} + +/** + * CodexRequestError - JSON-RPC request failed or timed out. + */ +export class CodexRequestError extends Schema.TaggedErrorClass()( + "CodexRequestError", + { + method: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex request failed for ${this.method}: ${this.detail}`; + } +} + +/** + * CodexProcessError - codex app-server process lifecycle failure. + */ +export class CodexProcessError extends Schema.TaggedErrorClass()( + "CodexProcessError", + { + 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}`; + } +} + +/** + * ProviderValidationError - Invalid provider API input. + */ +export class ProviderValidationError extends Schema.TaggedErrorClass()( + "ProviderValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * ProviderUnsupportedError - Requested provider is not implemented. + */ +export class ProviderUnsupportedError extends Schema.TaggedErrorClass()( + "ProviderUnsupportedError", + { + provider: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider '${this.provider}' is not implemented`; + } +} + +/** + * ProviderSessionNotFoundError - Provider-facing session not found. + */ +export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass()( + "ProviderSessionNotFoundError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unknown provider session: ${this.sessionId}`; + } +} + +/** + * ProviderCheckpointUnavailableError - Checkpointing unavailable for this session. + */ +export class ProviderCheckpointUnavailableError extends Schema.TaggedErrorClass()( + "ProviderCheckpointUnavailableError", + { + sessionId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider checkpoint unavailable for session ${this.sessionId}: ${this.detail}`; + } +} + +/** + * ProviderCheckpointRangeError - Requested checkpoint range is invalid. + */ +export class ProviderCheckpointRangeError extends Schema.TaggedErrorClass()( + "ProviderCheckpointRangeError", + { + sessionId: Schema.String, + fromTurnCount: Schema.Number, + toTurnCount: Schema.Number, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider checkpoint range error for session ${this.sessionId}: ${this.fromTurnCount}..${this.toTurnCount} (${this.detail})`; + } +} + +/** + * ProviderFilesystemError - Filesystem checkpoint capture/restore failure. + */ +export class ProviderFilesystemError extends Schema.TaggedErrorClass()( + "ProviderFilesystemError", + { + sessionId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider filesystem checkpoint error for session ${this.sessionId}: ${this.detail}`; + } +} + +export type CheckpointStoreError = + | CheckpointValidationError + | CheckpointGitCommandError + | CheckpointUnavailableError + | CheckpointRepositoryError; + +export type CodexServiceError = + | CodexValidationError + | CodexSessionNotFoundError + | CodexSessionClosedError + | CodexProtocolError + | CodexRequestError + | CodexProcessError; + +export type ProviderServiceError = + | ProviderValidationError + | ProviderUnsupportedError + | ProviderSessionNotFoundError + | ProviderCheckpointUnavailableError + | ProviderCheckpointRangeError + | ProviderFilesystemError + | CodexServiceError + | CheckpointStoreError; diff --git a/apps/server/src/provider/Services/CheckpointStore.ts b/apps/server/src/provider/Services/CheckpointStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..98bbacbeda1b87cb49ff9a154966bf43ff4b1078 --- /dev/null +++ b/apps/server/src/provider/Services/CheckpointStore.ts @@ -0,0 +1,91 @@ +/** + * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. + * + * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed + * domain errors for checkpoint lifecycle operations. + * + * @module CheckpointStore + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointStoreError } from "../Errors.ts"; + +export interface CaptureCheckpointInput { + readonly cwd: string; + readonly threadId: string; + readonly turnCount: number; +} + +export interface EnsureRootCheckpointInput { + readonly cwd: string; + readonly threadId: string; +} + +export interface RestoreCheckpointInput { + readonly cwd: string; + readonly threadId: string; + readonly turnCount: number; +} + +export interface DiffCheckpointsInput { + readonly cwd: string; + readonly threadId: string; + readonly fromTurnCount: number; + readonly toTurnCount: number; +} + +export interface PruneAfterTurnInput { + readonly cwd: string; + readonly threadId: string; + readonly maxTurnCount: number; +} + +export interface CheckpointStoreShape { + /** + * Check whether cwd is inside a Git worktree. + */ + readonly isGitRepository: (cwd: string) => Effect.Effect; + + /** + * Capture a checkpoint for a thread at a turn count. + */ + readonly captureCheckpoint: (input: CaptureCheckpointInput) => Effect.Effect; + + /** + * Check whether a checkpoint ref exists. + */ + readonly hasCheckpoint: (input: RestoreCheckpointInput) => Effect.Effect; + + /** + * Ensure a turn-0 checkpoint exists for a thread. + */ + readonly ensureRootCheckpoint: ( + input: EnsureRootCheckpointInput, + ) => Effect.Effect; + + /** + * Restore workspace/staging state to a checkpoint. + */ + readonly restoreCheckpoint: ( + input: RestoreCheckpointInput, + ) => Effect.Effect; + + /** + * Compute patch diff between two checkpoint turn counts. + */ + readonly diffCheckpoints: (input: DiffCheckpointsInput) => Effect.Effect; + + /** + * Delete checkpoints newer than the provided turn count. + */ + readonly pruneAfterTurn: (input: PruneAfterTurnInput) => Effect.Effect; +} + +/** + * CheckpointStore - Context tag for checkpoint persistence and restore operations. + */ +export class CheckpointStore extends ServiceMap.Service< + CheckpointStore, + CheckpointStoreShape +>()("provider/CheckpointStore") {} diff --git a/apps/server/src/provider/Services/Codex.ts b/apps/server/src/provider/Services/Codex.ts new file mode 100644 index 0000000000000000000000000000000000000000..06a556fa3e8203c6cf920c367ee412f20ba3b455 --- /dev/null +++ b/apps/server/src/provider/Services/Codex.ts @@ -0,0 +1,110 @@ +/** + * CodexService - Service interface for codex app-server session lifecycle and RPC. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns typed + * domain errors for process, protocol, and session operations. + * + * @module CodexService + */ +import type { + ProviderApprovalDecision, + ProviderEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CodexServiceError } from "../Errors.ts"; + +export interface CodexThreadTurnSnapshot { + readonly id: string; + readonly items: ReadonlyArray; +} + +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/Provider.ts b/apps/server/src/provider/Services/Provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ae8ee35118767ca0b458287bcac4b93bb3599d1 --- /dev/null +++ b/apps/server/src/provider/Services/Provider.ts @@ -0,0 +1,113 @@ +/** + * ProviderService - Service interface for provider sessions, turns, and checkpoints. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns typed + * domain errors for validation, session, codex, and checkpoint workflows. + * + * @module ProviderService + */ +import type { + ProviderEvent, + ProviderGetCheckpointDiffInput, + ProviderGetCheckpointDiffResult, + ProviderInterruptTurnInput, + ProviderListCheckpointsInput, + ProviderListCheckpointsResult, + ProviderRespondToRequestInput, + ProviderRevertToCheckpointInput, + ProviderRevertToCheckpointResult, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderStopSessionInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderServiceError } from "../Errors.ts"; + +export interface ProviderServiceShape { + /** + * Start a provider session. + */ + readonly startSession: ( + input: ProviderSessionStartInput, + ) => Effect.Effect; + + /** + * Send a provider turn. + */ + readonly sendTurn: ( + input: ProviderSendTurnInput, + ) => Effect.Effect; + + /** + * Interrupt a running provider turn. + */ + readonly interruptTurn: ( + input: ProviderInterruptTurnInput, + ) => Effect.Effect; + + /** + * Respond to a provider approval request. + */ + readonly respondToRequest: ( + input: ProviderRespondToRequestInput, + ) => Effect.Effect; + + /** + * Stop a provider session. + */ + readonly stopSession: (input: ProviderStopSessionInput) => Effect.Effect; + + /** + * List active provider sessions. + */ + readonly listSessions: () => Effect.Effect>; + + /** + * List checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ProviderListCheckpointsInput, + ) => Effect.Effect; + + /** + * Diff two checkpoints for a provider session. + */ + readonly getCheckpointDiff: ( + input: ProviderGetCheckpointDiffInput, + ) => Effect.Effect; + + /** + * Revert a provider session to a checkpoint. + */ + readonly revertToCheckpoint: ( + input: ProviderRevertToCheckpointInput, + ) => Effect.Effect; + + /** + * Stop all provider sessions. + */ + readonly stopAll: () => Effect.Effect; + + /** + * Dispose provider resources (logs/listeners). + */ + readonly dispose: () => Effect.Effect; + + /** + * Subscribe to provider event stream. + */ + readonly subscribeToEvents: ( + callback: (event: ProviderEvent) => void, + ) => Effect.Effect<() => void, ProviderServiceError>; +} + +/** + * ProviderService - Context tag for provider orchestration. + */ +export class ProviderService extends ServiceMap.Service()( + "provider/ProviderService", +) {} diff --git a/apps/server/src/provider/contracts.test.ts b/apps/server/src/provider/contracts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3675af73ebda59fcb1c5cbd67b1a1b988040ebd2 --- /dev/null +++ b/apps/server/src/provider/contracts.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { Effect, Layer, ManagedRuntime } from "effect"; + +import { + CheckpointValidationError, + CodexValidationError, + ProviderFilesystemError, + ProviderUnsupportedError, + type CheckpointStoreError, + type CodexServiceError, + type ProviderServiceError, +} from "./Errors.ts"; +import { CheckpointStore } from "./Services/CheckpointStore.ts"; +import { CodexService } from "./Services/Codex.ts"; +import { ProviderService } from "./Services/Provider.ts"; + +describe("provider service contracts", () => { + it("resolves provider, codex, and checkpoint services from ServiceMap tags", async () => { + const providerLive: typeof ProviderService.Service = { + startSession: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + sendTurn: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + interruptTurn: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + respondToRequest: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + stopSession: () => Effect.void, + listSessions: () => Effect.succeed([]), + listCheckpoints: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + getCheckpointDiff: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + revertToCheckpoint: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + stopAll: () => Effect.void, + dispose: () => Effect.void, + subscribeToEvents: () => Effect.succeed(() => undefined), + }; + + const codexLive: typeof CodexService.Service = { + startSession: () => + Effect.fail( + new CodexValidationError({ operation: "startSession", issue: "not implemented" }), + ), + sendTurn: () => + Effect.fail( + new CodexValidationError({ operation: "sendTurn", issue: "not implemented" }), + ), + interruptTurn: () => + Effect.fail( + new CodexValidationError({ operation: "interruptTurn", issue: "not implemented" }), + ), + readThread: () => + Effect.fail( + new CodexValidationError({ operation: "readThread", issue: "not implemented" }), + ), + rollbackThread: () => + Effect.fail( + new CodexValidationError({ operation: "rollbackThread", issue: "not implemented" }), + ), + respondToRequest: () => + Effect.fail( + new CodexValidationError({ operation: "respondToRequest", issue: "not implemented" }), + ), + stopSession: () => Effect.void, + listSessions: () => Effect.succeed([]), + hasSession: () => Effect.succeed(false), + stopAll: () => Effect.void, + subscribeToEvents: () => Effect.succeed(() => undefined), + }; + + const checkpointLive: typeof CheckpointStore.Service = { + isGitRepository: () => Effect.succeed(false), + captureCheckpoint: () => Effect.void, + hasCheckpoint: () => Effect.succeed(false), + ensureRootCheckpoint: () => Effect.succeed(false), + restoreCheckpoint: () => Effect.succeed(false), + diffCheckpoints: () => + Effect.fail( + new CheckpointValidationError({ + operation: "diffCheckpoints", + issue: "not implemented", + }), + ), + pruneAfterTurn: () => Effect.void, + }; + + const runtime = ManagedRuntime.make( + Layer.mergeAll( + Layer.succeed(ProviderService, providerLive), + Layer.succeed(CodexService, codexLive), + Layer.succeed(CheckpointStore, checkpointLive), + ), + ); + + const [provider, codex, checkpoint] = await Promise.all([ + runtime.runPromise(Effect.service(ProviderService)), + runtime.runPromise(Effect.service(CodexService)), + runtime.runPromise(Effect.service(CheckpointStore)), + ]); + + expect(provider).toBe(providerLive); + expect(codex).toBe(codexLive); + expect(checkpoint).toBe(checkpointLive); + + 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({ + sessionId: "sess-1", + detail: "capture failed", + cause, + }); + + expect(error._tag).toBe("ProviderFilesystemError"); + expect(error.message).toContain("sess-1"); + expect(error.cause).toBe(cause); + }); +}); -- 2.54.0