From e03f5dd3317ab1a2996bfcc89d6958fe99be2527 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 23 Jun 2026 11:56:06 -0400 Subject: [PATCH] feat(client): add embedded V2 host --- CONTEXT.md | 4 +- bun.lock | 18 +- packages/api/package.json | 22 --- packages/api/tsconfig.json | 4 - packages/client/README.md | 13 +- packages/client/package.json | 3 +- packages/client/script/build.ts | 31 +++- packages/client/src/effect-embedded.ts | 39 ++++- .../client/src/generated-effect/client.ts | 21 ++- packages/client/src/generated/types.ts | 26 ++- packages/client/test/effect.test.ts | 3 +- packages/client/test/embedded.test.ts | 55 ++++++ packages/client/tsconfig.json | 3 +- packages/httpapi-codegen/src/index.ts | 39 ++++- .../httpapi-codegen/test/generate.test.ts | 18 ++ .../server/httpapi-public-openapi.test.ts | 10 ++ packages/server/package.json | 1 - packages/server/src/api.ts | 24 +-- .../src/groups/session-endpoints.ts} | 138 ++++----------- packages/server/src/groups/session.ts | 80 +++------ packages/server/src/handlers.ts | 6 +- .../server/src/handlers/public-session.ts | 163 ------------------ packages/server/src/handlers/session.ts | 162 ++++++++++++++++- packages/server/src/routes.ts | 20 ++- packages/server/src/session-cursor.ts | 36 ++++ 25 files changed, 507 insertions(+), 432 deletions(-) delete mode 100644 packages/api/package.json delete mode 100644 packages/api/tsconfig.json create mode 100644 packages/client/test/embedded.test.ts rename packages/{api/src/index.ts => server/src/groups/session-endpoints.ts} (57%) delete mode 100644 packages/server/src/handlers/public-session.ts create mode 100644 packages/server/src/session-cursor.ts diff --git a/CONTEXT.md b/CONTEXT.md index 690cd1fe9b..7faf5caf84 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -123,8 +123,8 @@ _Avoid_: Response envelope - The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server hosts those exact endpoint declarations and code generation consumes them directly. Public endpoints are not duplicated or projected from a separately named internal contract. - SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. - The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. -- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against the dependency-leaf `@opencode-ai/api` package instead; the Promise emitter still derives zero-Effect structural wire types from the same IR. -- `@opencode-ai/api` owns the lightweight authoritative public `HttpApi` and its runtime schemas. It depends only on Effect, does not import Core or server implementation packages, and is hosted by the server with server-only middleware added during composition. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against the canonical V2 server `HttpApi`; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/server` owns the authoritative V2 `HttpApi`. The real server and client generation consume that same API value; generator selection controls emitted capabilities without redefining their contracts. - The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. - The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. - Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. diff --git a/bun.lock b/bun.lock index 9019f077ab..45d43f2ee1 100644 --- a/bun.lock +++ b/bun.lock @@ -27,18 +27,6 @@ "turbo": "2.8.13", }, }, - "packages/api": { - "name": "@opencode-ai/api", - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "effect": "catalog:", - }, - "peerDependencies": { - "effect": "catalog:", - }, - }, "packages/app": { "name": "@opencode-ai/app", "version": "1.17.9", @@ -124,7 +112,8 @@ "packages/client": { "name": "@opencode-ai/client", "dependencies": { - "@opencode-ai/api": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", }, "devDependencies": { "@effect/platform-node": "catalog:", @@ -724,7 +713,6 @@ "name": "@opencode-ai/server", "version": "1.17.9", "dependencies": { - "@opencode-ai/api": "workspace:*", "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -1815,8 +1803,6 @@ "@openauthjs/openauth": ["@openauthjs/openauth@0.0.0-20250322224806", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-p5IWSRXvABcwocH2dNI0w8c1QJelIOFulwhKk+aLLFfUbs8u1pr7kQbYe8yCSM2+bcLHiwbogpUQc2ovrGwCuw=="], - "@opencode-ai/api": ["@opencode-ai/api@workspace:packages/api"], - "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], diff --git a/packages/api/package.json b/packages/api/package.json deleted file mode 100644 index 4143c631e1..0000000000 --- a/packages/api/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "name": "@opencode-ai/api", - "private": true, - "type": "module", - "license": "MIT", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsgo --noEmit" - }, - "peerDependencies": { - "effect": "catalog:" - }, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "effect": "catalog:" - } -} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json deleted file mode 100644 index 8fe3e33221..0000000000 --- a/packages/api/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/bun/tsconfig.json" -} diff --git a/packages/client/README.md b/packages/client/README.md index fae05a7c24..3b3b7e5a71 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,6 +8,15 @@ Private generation target for clients derived directly from OpenCode's authorita - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. - `@opencode-ai/client/effect/embedded`: scoped embedded OpenCode host backed by Core and the in-memory HTTP router. -The initial generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, and `prompt`, sourced from the public `HttpApi` in `@opencode-ai/api` and hosted by `@opencode-ai/server`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift. +The initial generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, and `prompt`, sourced directly from the V2 `HttpApi` hosted by `@opencode-ai/server`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift. -The embedded entrypoint remains intentionally empty until the scoped in-memory host is implemented. +The embedded entrypoint exposes a scoped host backed by the same server router, middleware, handlers, and HTTP codecs as the network client: + +```ts +import { OpenCode } from "@opencode-ai/client/effect/embedded" + +const opencode = yield * OpenCode.create() +const session = yield * opencode.sessions.get({ sessionID }) +``` + +It also exposes embedded-only `tools.register(...)`. Closing the owning Effect Scope releases the router resources, location services, fibers, and scoped tool registrations. diff --git a/packages/client/package.json b/packages/client/package.json index 97edae84ff..2681a9b2b9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -16,7 +16,8 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@opencode-ai/api": "workspace:*" + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*" }, "peerDependencies": { "effect": "4.0.0-beta.83" diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 4676a1e8e6..3fe98faaae 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,8 +1,25 @@ import { NodeFileSystem } from "@effect/platform-node" -import { Api } from "@opencode-ai/api" +import { + SessionsCreate, + SessionsGet, + SessionsList, + SessionsPrompt, + SessionsSwitchAgent, + SessionsSwitchModel, +} from "@opencode-ai/server/groups/session-endpoints" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" import { Effect } from "effect" +import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi" +const Api = HttpApi.make("opencode-client").add( + HttpApiGroup.make("sessions") + .add(SessionsList) + .add(SessionsCreate) + .add(SessionsGet) + .add(SessionsSwitchAgent) + .add(SessionsSwitchModel) + .add(SessionsPrompt), +) const contract = compile(Api) await Effect.runPromise( @@ -10,7 +27,17 @@ await Effect.runPromise( [ write(emitPromise(contract), new URL("../src/generated", import.meta.url).pathname), write( - emitEffectImported(contract, { module: "@opencode-ai/api", api: "Api" }), + emitEffectImported(contract, { + module: "@opencode-ai/server/groups/session-endpoints", + endpoints: { + "sessions.list": "SessionsList", + "sessions.create": "SessionsCreate", + "sessions.get": "SessionsGet", + "sessions.switchAgent": "SessionsSwitchAgent", + "sessions.switchModel": "SessionsSwitchModel", + "sessions.prompt": "SessionsPrompt", + }, + }), new URL("../src/generated-effect", import.meta.url).pathname, ), ], diff --git a/packages/client/src/effect-embedded.ts b/packages/client/src/effect-embedded.ts index 614e4f6a15..c1e2b8768a 100644 --- a/packages/client/src/effect-embedded.ts +++ b/packages/client/src/effect-embedded.ts @@ -1,2 +1,37 @@ -// Embedded Effect host target. Intentionally empty until the public HttpApi is available. -export {} +export * as OpenCode from "./effect-embedded" + +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { createEmbeddedRoutes } from "@opencode-ai/server/routes" +import { Context, Effect, Layer } from "effect" +import { HttpClient, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { OpenCode as Generated } from "./generated-effect/index" + +export const create = Effect.fn("OpenCode.create")(function* () { + const context = yield* Layer.build( + Layer.merge( + createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(HttpRouter.layer)), + ApplicationTools.layer, + ), + ) + const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect() + const httpClient = HttpClient.make( + Effect.fnUntraced(function* (request) { + const response = yield* handler.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), + Effect.orDie, + ) + return HttpServerResponse.toClientResponse(response) + }, Effect.scoped), + ) + const client = yield* Generated.make({ baseUrl: "http://opencode.local" }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ) + const tools = Context.get(context, ApplicationTools.Service) + return { + ...client, + tools: { register: tools.register }, + } +}) + +export { ClientError } from "./generated-effect/index" +export { Tool } from "@opencode-ai/core/public/tool" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 1d7b32a233..b781067c3e 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -2,10 +2,27 @@ import { Effect, Schema } from "effect" import { Sse } from "effect/unstable/encoding" import { HttpClientError } from "effect/unstable/http" -import { HttpApiClient } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/api" +import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi" +import { + SessionsList, + SessionsCreate, + SessionsGet, + SessionsSwitchAgent, + SessionsSwitchModel, + SessionsPrompt, +} from "@opencode-ai/server/groups/session-endpoints" import { ClientError } from "./client-error" +const Api = HttpApi.make("generated").add( + HttpApiGroup.make("sessions") + .add(SessionsList) + .add(SessionsCreate) + .add(SessionsGet) + .add(SessionsSwitchAgent) + .add(SessionsSwitchModel) + .add(SessionsPrompt), +) + type RawClient = HttpApiClient.ForApi const mapClientError = (error: E) => diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 779db6de3f..1f7f56b0e8 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -113,7 +113,7 @@ export type SessionsListInput = { export type SessionsListOutput = { readonly data: ReadonlyArray<{ readonly id: string - readonly parentID?: string + readonly parentID?: string | null readonly projectID: string readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null @@ -124,7 +124,11 @@ export type SessionsListOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null + } readonly title: string readonly location: { readonly directory: string; readonly workspaceID?: string | null } readonly subpath?: string | null @@ -170,7 +174,7 @@ export type SessionsCreateInput = { export type SessionsCreateOutput = { readonly data: { readonly id: string - readonly parentID?: string + readonly parentID?: string | null readonly projectID: string readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null @@ -181,7 +185,11 @@ export type SessionsCreateOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null + } readonly title: string readonly location: { readonly directory: string; readonly workspaceID?: string | null } readonly subpath?: string | null @@ -193,7 +201,7 @@ export type SessionsGetInput = { readonly sessionID: { readonly sessionID: strin export type SessionsGetOutput = { readonly data: { readonly id: string - readonly parentID?: string + readonly parentID?: string | null readonly projectID: string readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null @@ -204,7 +212,11 @@ export type SessionsGetOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly time: { + readonly created: number | "Infinity" | "-Infinity" | "NaN" + readonly updated: number | "Infinity" | "-Infinity" | "NaN" + readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null + } readonly title: string readonly location: { readonly directory: string; readonly workspaceID?: string | null } readonly subpath?: string | null @@ -343,7 +355,7 @@ export type SessionsPromptOutput = { }> | null } readonly delivery: "steer" | "queue" - readonly timeCreated: number + readonly timeCreated: number | "Infinity" | "-Infinity" | "NaN" readonly promotedSeq?: number | null } }["data"] diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 663172afdb..db11e1e052 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" -import { AbsolutePath, AgentID, ModelRef, SessionID } from "@opencode-ai/api" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AgentID, ModelRef, SessionID } from "@opencode-ai/server/groups/session-endpoints" import { DateTime, Effect } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { OpenCode } from "../src/effect" diff --git a/packages/client/test/embedded.test.ts b/packages/client/test/embedded.test.ts new file mode 100644 index 0000000000..01ee150a0a --- /dev/null +++ b/packages/client/test/embedded.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AgentID, ModelRef, SessionID } from "@opencode-ai/server/groups/session-endpoints" +import { Effect, Schema } from "effect" + +test("embedded client uses the real router and handlers", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { OpenCode, Tool } = await import("../src/effect-embedded") + const sessionID = SessionID.make(`ses_embedded_${crypto.randomUUID()}`) + const model = ModelRef.make({ id: "embedded", providerID: "test" }) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + yield* opencode.tools.register({ + embedded_tool: Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + + const created = yield* opencode.sessions.create({ + id: sessionID, + agent: AgentID.make("build"), + location: { directory: AbsolutePath.make(directory) }, + }) + yield* opencode.sessions.switchModel({ sessionID, model }) + const selected = yield* opencode.sessions.get({ sessionID }) + const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) + const admitted = yield* opencode.sessions.prompt({ + sessionID, + prompt: { text: "Do not run" }, + resume: false, + }) + + expect(created.id).toBe(sessionID) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === sessionID)).toBe(true) + expect(admitted.sessionID).toBe(sessionID) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index d42ebc06e0..47fc90bc55 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -2,7 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"] + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false }, "include": ["src"] } diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 41a311ae97..98807dc721 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -200,7 +200,9 @@ export function emitEffect(contract: Contract): Output { export function emitEffectImported( contract: Contract, - options: { readonly module: string; readonly api: string }, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly endpoints: Readonly> }, ): Output { return { operations: operations(contract.groups), @@ -291,7 +293,9 @@ function renderEffectFiles(groups: ReadonlyArray): Output["files"] { function renderImportedEffectFiles( groups: ReadonlyArray, - options: { readonly module: string; readonly api: string }, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly endpoints: Readonly> }, ): Output["files"] { const adapters = groups.map((group, groupIndex) => { const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.identifier)}]` @@ -328,7 +332,15 @@ function renderImportedEffectFiles( : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`], ) const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) - const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient } from "effect/unstable/httpapi"\nimport { ${options.api} } from ${JSON.stringify(options.module)}\nimport { ClientError } from "./client-error"\n\ntype RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${options.api}, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` + const imported = "api" in options + const projection = imported ? undefined : renderImportedProjection(groups, options.endpoints) + const api = imported ? options.api : "Api" + const imports = + projection === undefined + ? `import { ${api} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` return [ { path: "client-error.ts", @@ -343,6 +355,27 @@ function renderImportedEffectFiles( ] } +function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { + const imports = groups.flatMap((group) => + group.endpoints.map((endpoint) => { + const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`] + if (name === undefined) { + throw new GenerationError({ + reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return name + }), + ) + const source = `const Api = HttpApi.make("generated").${groups + .map((group) => { + const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : "" + return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})` + }) + .join(".")}\n\n` + return { imports: [...new Set(imports)], source } +} + function renderPromiseTypes(groups: ReadonlyArray) { const types = new Map() const typeOf = (schema: Schema.Top) => { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 91118e0b32..3859115f79 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -70,6 +70,24 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("projects imported endpoint constants into a generated API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", endpoints: { "session.get": "SessionGet" } }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGet } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') + }) + test("erases brands from Promise wire types", () => { const output = emitPromise( compileContract( diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index d13d19c506..7a687ad387 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -16,6 +16,7 @@ type OpenApiResponse = { readonly content?: Record } type OpenApiOperation = { + readonly operationId?: string readonly parameters?: ReadonlyArray<{ readonly name: string readonly in: string @@ -68,6 +69,15 @@ function isBuiltInEndpointError(name: string) { } describe("PublicApi OpenAPI v2 errors", () => { + test("keeps current and v2 session groups distinct", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(spec.paths["/session"]?.get?.operationId).toBe("session.list") + expect(spec.paths["/session/{sessionID}"]?.get?.operationId).toBe("session.get") + expect(spec.paths["/api/session"]?.get?.operationId).toBe("v2.session.list") + expect(spec.paths["/api/session/{sessionID}"]?.get?.operationId).toBe("v2.session.get") + }) + test("documents nested legacy global sync events", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec const schema = spec.components.schemas.SyncEventSessionCreated diff --git a/packages/server/package.json b/packages/server/package.json index d30c10b6fd..18cd2ab1f2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,7 +12,6 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@opencode-ai/api": "workspace:*", "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 3fadacedf3..b1cf1f5c05 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,17 +1,9 @@ -import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { - SessionsCreate, - SessionsGet, - SessionsList, - SessionsPrompt, - SessionsSwitchAgent, - SessionsSwitchModel, -} from "@opencode-ai/api" +import { HttpApi, OpenApi } from "effect/unstable/httpapi" import { SchemaErrorMiddleware } from "./middleware/schema-error" import { MessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" import { ProviderGroup } from "./groups/provider" -import { SessionGroup } from "./groups/session" +import { SessionsGroup } from "./groups/session" import { PermissionGroup } from "./groups/permission" import { FileSystemGroup } from "./groups/fs" import { CommandGroup } from "./groups/command" @@ -27,22 +19,12 @@ import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" import { ProjectCopyGroup } from "./groups/project-copy" -import { SessionLocationMiddleware } from "./middleware/session-location" export const Api = HttpApi.make("server") .add(HealthGroup) .add(LocationGroup) .add(AgentGroup) - .add( - HttpApiGroup.make("sessions") - .add(SessionsList) - .add(SessionsCreate) - .add(SessionsGet.middleware(SessionLocationMiddleware)) - .add(SessionsSwitchAgent.middleware(SessionLocationMiddleware)) - .add(SessionsSwitchModel.middleware(SessionLocationMiddleware)) - .add(SessionsPrompt.middleware(SessionLocationMiddleware)), - ) - .add(SessionGroup) + .add(SessionsGroup) .add(MessageGroup) .add(ModelGroup) .add(ProviderGroup) diff --git a/packages/api/src/index.ts b/packages/server/src/groups/session-endpoints.ts similarity index 57% rename from packages/api/src/index.ts rename to packages/server/src/groups/session-endpoints.ts index 3896433289..5ba5fb4edd 100644 --- a/packages/api/src/index.ts +++ b/packages/server/src/groups/session-endpoints.ts @@ -1,45 +1,22 @@ -import { DateTime, Option, Schema, SchemaGetter } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ConflictError, InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../errors" export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(Schema.brand("SessionID")) -export type SessionID = typeof SessionID.Type - -const ProjectID = Schema.String.pipe(Schema.brand("Project.ID")) export const AgentID = Schema.String.pipe(Schema.brand("AgentV2.ID")) -const ModelID = Schema.String.pipe(Schema.brand("ModelV2.ID")) -const ProviderID = Schema.String.pipe(Schema.brand("ProviderV2.ID")) -const VariantID = Schema.String.pipe(Schema.brand("VariantID")) -export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) -const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) -const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceV2.ID")) -export const MessageID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(Schema.brand("Session.Message.ID")) export const ModelRef = Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: VariantID.pipe(Schema.optional), + id: Schema.String.pipe(Schema.brand("ModelV2.ID")), + providerID: Schema.String.pipe(Schema.brand("ProviderV2.ID")), + variant: Schema.String.pipe(Schema.brand("VariantID"), Schema.optional), }) export const LocationRef = Schema.Struct({ - directory: AbsolutePath, - workspaceID: WorkspaceID.pipe(Schema.optional), + directory: Schema.String.pipe(Schema.brand("AbsolutePath")), + workspaceID: Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceV2.ID"), Schema.optional), }) -const DateTimeUtcFromMillis = Schema.Finite.pipe( - Schema.decodeTo(Schema.DateTimeUtc, { - decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), - encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), - }), -) -const optionalOmitUndefined = (schema: S) => - Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { - decode: SchemaGetter.passthrough({ strict: false }), - encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), - }), - ) - export const Session = Schema.Struct({ id: SessionID, - parentID: optionalOmitUndefined(SessionID), - projectID: ProjectID, + parentID: SessionID.pipe(Schema.optional), + projectID: Schema.String.pipe(Schema.brand("Project.ID")), agent: AgentID.pipe(Schema.optional), model: ModelRef.pipe(Schema.optional), cost: Schema.Finite, @@ -53,16 +30,14 @@ export const Session = Schema.Struct({ }), }), time: Schema.Struct({ - created: DateTimeUtcFromMillis, - updated: DateTimeUtcFromMillis, - archived: DateTimeUtcFromMillis.pipe(Schema.optional), + created: Schema.DateTimeUtcFromMillis, + updated: Schema.DateTimeUtcFromMillis, + archived: Schema.DateTimeUtcFromMillis.pipe(Schema.optional), }), title: Schema.String, location: LocationRef, - subpath: RelativePath.pipe(Schema.optional), + subpath: Schema.String.pipe(Schema.brand("RelativePath"), Schema.optional), }) -export type Session = typeof Session.Type - export const Prompt = Schema.Struct({ text: Schema.String, files: Schema.Array( @@ -89,66 +64,35 @@ export const Prompt = Schema.Struct({ }), ).pipe(Schema.optional), }) - +export const MessageID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(Schema.brand("Session.Message.ID")) export const Delivery = Schema.Literals(["steer", "queue"]) - export const Admission = Schema.Struct({ admittedSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), id: MessageID, sessionID: SessionID, prompt: Prompt, delivery: Delivery, - timeCreated: DateTimeUtcFromMillis, + timeCreated: Schema.DateTimeUtcFromMillis, promotedSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.optional), }) export const SessionsCursor = Schema.String.pipe(Schema.brand("SessionsCursor")) - export const SessionsQuery = Schema.Struct({ - workspace: WorkspaceID.pipe(Schema.optional), - limit: Schema.NumberFromString.pipe(Schema.decodeTo(Schema.Int.check(Schema.isGreaterThan(0))), Schema.optional), - order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), + workspace: Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceV2.ID"), Schema.optional), + limit: Schema.NumberFromString.pipe( + Schema.decodeTo(Schema.Int.check(Schema.isGreaterThan(0))), + Schema.optional, + ).annotate({ description: "Maximum number of sessions to return. Defaults to the newest 50 sessions." }), + order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), search: Schema.String.pipe(Schema.optional), - directory: AbsolutePath.pipe(Schema.optional), - project: ProjectID.pipe(Schema.optional), - subpath: RelativePath.pipe(Schema.optional), + directory: Schema.String.pipe(Schema.brand("AbsolutePath"), Schema.optional), + project: Schema.String.pipe(Schema.brand("Project.ID"), Schema.optional), + subpath: Schema.String.pipe(Schema.brand("RelativePath"), Schema.optional), cursor: SessionsCursor.pipe(Schema.optional), }) -export class SessionNotFoundError extends Schema.TaggedErrorClass()( - "SessionNotFoundError", - { - sessionID: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - -export class InvalidCursorError extends Schema.TaggedErrorClass()( - "InvalidCursorError", - { message: Schema.String }, - { httpApiStatus: 400 }, -) {} - -export class InvalidRequestError extends Schema.TaggedErrorClass()( - "InvalidRequestError", - { - message: Schema.String, - kind: Schema.String.pipe(Schema.optional), - field: Schema.String.pipe(Schema.optional), - }, - { httpApiStatus: 400 }, -) {} - -export class ConflictError extends Schema.TaggedErrorClass()( - "ConflictError", - { - message: Schema.String, - resource: Schema.String.pipe(Schema.optional), - }, - { httpApiStatus: 409 }, -) {} - export const SessionsList = HttpApiEndpoint.get("list", "/api/session", { query: SessionsQuery, success: Schema.Struct({ @@ -161,7 +105,7 @@ export const SessionsList = HttpApiEndpoint.get("list", "/api/session", { error: [InvalidCursorError, InvalidRequestError], }).annotateMerge( OpenApi.annotations({ - identifier: "sessions.list", + identifier: "v2.session.list", summary: "List sessions", description: "Retrieve an ordered page of sessions.", }), @@ -177,7 +121,7 @@ export const SessionsCreate = HttpApiEndpoint.post("create", "/api/session", { success: Schema.Struct({ data: Session }), }).annotateMerge( OpenApi.annotations({ - identifier: "sessions.create", + identifier: "v2.session.create", summary: "Create session", description: "Create a session at the requested location.", }), @@ -189,7 +133,7 @@ export const SessionsGet = HttpApiEndpoint.get("get", "/api/session/:sessionID", error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ - identifier: "sessions.get", + identifier: "v2.session.get", summary: "Get session", description: "Retrieve a session by ID.", }), @@ -202,7 +146,7 @@ export const SessionsSwitchAgent = HttpApiEndpoint.post("switchAgent", "/api/ses error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ - identifier: "sessions.switchAgent", + identifier: "v2.session.switchAgent", summary: "Switch session agent", description: "Switch the agent used by subsequent session activity.", }), @@ -215,7 +159,7 @@ export const SessionsSwitchModel = HttpApiEndpoint.post("switchModel", "/api/ses error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ - identifier: "sessions.switchModel", + identifier: "v2.session.switchModel", summary: "Switch session model", description: "Switch the model used by subsequent session activity.", }), @@ -233,24 +177,8 @@ export const SessionsPrompt = HttpApiEndpoint.post("prompt", "/api/session/:sess error: [ConflictError, SessionNotFoundError], }).annotateMerge( OpenApi.annotations({ - identifier: "sessions.prompt", + identifier: "v2.session.prompt", summary: "Send prompt", description: "Durably admit one session input and schedule execution unless resume is false.", }), ) - -export const SessionsGroup = HttpApiGroup.make("sessions") - .add(SessionsList) - .add(SessionsCreate) - .add(SessionsGet) - .add(SessionsSwitchAgent) - .add(SessionsSwitchModel) - .add(SessionsPrompt) - .annotateMerge( - OpenApi.annotations({ - title: "sessions", - description: "OpenCode sessions.", - }), - ) - -export const Api = HttpApi.make("opencode").add(SessionsGroup) diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index 52a8c31e4e..030b2e1380 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -1,67 +1,27 @@ -import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" -import { ProjectV2 } from "@opencode-ai/core/project" -import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" -import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Schema, Struct } from "effect" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../errors" import { SessionLocationMiddleware } from "../middleware/session-location" +import { + SessionsCreate, + SessionsGet, + SessionsList, + SessionsPrompt, + SessionsSwitchAgent, + SessionsSwitchModel, +} from "./session-endpoints" -const SessionsQueryFields = { - workspace: WorkspaceV2.ID.pipe(Schema.optional), - limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - search: Schema.optional(Schema.String), -} - -const SessionsDirectoryQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath, -}) - -const SessionsProjectQuery = Schema.Struct({ - ...SessionsQueryFields, - project: ProjectV2.ID, - subpath: RelativePath.pipe(Schema.optional), -}) - -const SessionsAllQuery = Schema.Struct(SessionsQueryFields) - -const withCursor = (schema: Schema.Struct) => - schema.mapFields((fields) => ({ - ...Struct.omit(fields, ["limit"]), - anchor: SessionV2.ListAnchor, - })) - -const SessionsCursorInput = Schema.Union([ - withCursor(SessionsDirectoryQuery), - withCursor(SessionsProjectQuery), - withCursor(SessionsAllQuery), -]) -const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) -const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) -const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) - -export const SessionsCursor = Schema.String.pipe( - Schema.brand("SessionsCursor"), - withStatics((schema) => { - return { - make: (input: typeof SessionsCursorInput.Type) => - schema.make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")), - parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")), - } - }), -) -export type SessionsCursor = typeof SessionsCursor.Type - -export const SessionGroup = HttpApiGroup.make("server.session") +export const SessionsGroup = HttpApiGroup.make("sessions") + .add(SessionsList) + .add(SessionsCreate) + .add(SessionsGet.middleware(SessionLocationMiddleware)) + .add(SessionsSwitchAgent.middleware(SessionLocationMiddleware)) + .add(SessionsSwitchModel.middleware(SessionLocationMiddleware)) + .add(SessionsPrompt.middleware(SessionLocationMiddleware)) .add( - HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { + HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, ServiceUnavailableError], @@ -76,7 +36,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") ), ) .add( - HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { + HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, ServiceUnavailableError], @@ -91,7 +51,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") ), ) .add( - HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { + HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionV2.ID }, success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), error: [SessionNotFoundError, UnknownError], diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 30d49c4eac..7cbc9cbe7e 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -8,7 +8,7 @@ import { sessionLocationLayer } from "./middleware/session-location" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" -import { SessionHandler } from "./handlers/session" +import { SessionsHandler } from "./handlers/session" import { PermissionHandler } from "./handlers/permission" import { FileSystemHandler } from "./handlers/fs" import { CommandHandler } from "./handlers/command" @@ -25,14 +25,12 @@ import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" -import { PublicSessionHandler } from "./handlers/public-session" export const handlers = Layer.mergeAll( HealthHandler, LocationHandler, AgentHandler, - PublicSessionHandler, - SessionHandler, + SessionsHandler, MessageHandler, ModelHandler, ProviderHandler, diff --git a/packages/server/src/handlers/public-session.ts b/packages/server/src/handlers/public-session.ts deleted file mode 100644 index 9b44a3f26f..0000000000 --- a/packages/server/src/handlers/public-session.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - ConflictError, - InvalidCursorError, - SessionNotFoundError, - SessionsCursor as PublicSessionsCursor, -} from "@opencode-ai/api" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect } from "effect" -import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "../api" -import { SessionsCursor } from "../groups/session" - -const DefaultSessionsLimit = 50 - -export const PublicSessionHandler = HttpApiBuilder.group(Api, "sessions", (handlers) => - Effect.gen(function* () { - const session = yield* SessionV2.Service - return handlers - .handle( - "list", - Effect.fn(function* (ctx) { - const query = - ctx.query.cursor !== undefined - ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( - Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), - ) - : ctx.query - const sessions = yield* session.list({ - ...query, - workspaceID: query.workspace, - limit: ctx.query.limit ?? DefaultSessionsLimit, - }) - const first = sessions[0] - const last = sessions.at(-1) - return { - data: sessions, - cursor: { - previous: first - ? PublicSessionsCursor.make( - SessionsCursor.make({ - ...query, - anchor: { - id: first.id, - time: DateTime.toEpochMillis(first.time.created), - direction: "previous", - }, - }), - ) - : undefined, - next: last - ? PublicSessionsCursor.make( - SessionsCursor.make({ - ...query, - anchor: { - id: last.id, - time: DateTime.toEpochMillis(last.time.created), - direction: "next", - }, - }), - ) - : undefined, - }, - } - }), - ) - .handle( - "create", - Effect.fn(function* (ctx) { - return { - data: yield* session.create({ - id: ctx.payload.id, - agent: ctx.payload.agent, - model: ctx.payload.model, - location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, - }), - } - }), - ) - .handle( - "get", - Effect.fn(function* (ctx) { - return { - data: yield* session.get(ctx.params.sessionID).pipe( - Effect.catchTag( - "Session.NotFoundError", - (error) => - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - } - }), - ) - .handle( - "switchAgent", - Effect.fn(function* (ctx) { - yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - ) - return HttpApiSchema.NoContent.make() - }), - ) - .handle( - "switchModel", - Effect.fn(function* (ctx) { - yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - ) - return HttpApiSchema.NoContent.make() - }), - ) - .handle( - "prompt", - Effect.fn(function* (ctx) { - return { - data: yield* session - .prompt({ - sessionID: ctx.params.sessionID, - id: ctx.payload.id, - prompt: ctx.payload.prompt, - delivery: ctx.payload.delivery, - resume: ctx.payload.resume, - }) - .pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - Effect.catchTag("Session.PromptConflictError", (error) => - Effect.fail( - new ConflictError({ - message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, - resource: error.messageID, - }), - ), - ), - ), - } - }), - ) - }), -) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 604bf75eb8..bab841ba00 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,16 +1,166 @@ +import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { Effect } from "effect" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { DateTime, Effect, Schema } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../errors" +import { + ConflictError, + InvalidCursorError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../errors" +import { SessionsCursor } from "../session-cursor" -export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => +const DefaultSessionsLimit = 50 +const decodePrompt = Schema.decodeUnknownSync(Prompt) + +export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service return handlers .handle( - "session.compact", + "list", + Effect.fn(function* (ctx) { + const query = + ctx.query.cursor !== undefined + ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( + Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), + ) + : ctx.query + const sessions = yield* session.list({ + ...query, + workspaceID: query.workspace, + limit: ctx.query.limit ?? DefaultSessionsLimit, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + data: sessions, + cursor: { + previous: first + ? SessionsCursor.make({ + ...query, + anchor: { + id: first.id, + time: DateTime.toEpochMillis(first.time.created), + direction: "previous", + }, + }) + : undefined, + next: last + ? SessionsCursor.make({ + ...query, + anchor: { + id: last.id, + time: DateTime.toEpochMillis(last.time.created), + direction: "next", + }, + }) + : undefined, + }, + } + }), + ) + .handle( + "create", + Effect.fn(function* (ctx) { + return { + data: yield* session.create({ + id: ctx.payload.id, + agent: ctx.payload.agent, + model: ctx.payload.model, + location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, + }), + } + }), + ) + .handle( + "get", + Effect.fn(function* (ctx) { + return { + data: yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) + .handle( + "switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return { + data: yield* session + .prompt({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + prompt: decodePrompt(ctx.payload.prompt), + delivery: ctx.payload.delivery, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.PromptConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, + resource: error.messageID, + }), + ), + ), + ), + } + }), + ) + .handle( + "compact", Effect.fn(function* (ctx) { yield* session.compact({ sessionID: ctx.params.sessionID }).pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -34,7 +184,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ) .handle( - "session.wait", + "wait", Effect.fn(function* (ctx) { yield* session.wait(ctx.params.sessionID).pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -58,7 +208,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ) .handle( - "session.context", + "context", Effect.fn(function* (ctx) { return { data: yield* session.context(ctx.params.sessionID).pipe( diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index da5cda1399..2c2c8fc334 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,6 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" @@ -12,17 +11,24 @@ import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" export function createRoutes(password?: string) { + return makeRoutes( + password + ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) + : ServerAuth.Config.defaultLayer, + ) +} + +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() })) +} + +function makeRoutes(auth: Layer.Layer) { return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), Layer.provide(PtyEnvironment.defaultLayer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), - Layer.provide( - password - ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) - : ServerAuth.Config.defaultLayer, - ), - Layer.provide(LocationServiceMap.layer), + Layer.provide(auth), Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(FetchHttpClient.layer), diff --git a/packages/server/src/session-cursor.ts b/packages/server/src/session-cursor.ts new file mode 100644 index 0000000000..63c0a34012 --- /dev/null +++ b/packages/server/src/session-cursor.ts @@ -0,0 +1,36 @@ +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Schema, Struct } from "effect" + +const fields = { + workspace: WorkspaceV2.ID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), + order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), +} +const withCursor = (schema: Schema.Struct) => + schema.mapFields((value) => ({ + ...Struct.omit(value, ["limit"]), + anchor: SessionV2.ListAnchor, + })) +const input = Schema.Union([ + withCursor(Schema.Struct({ ...fields, directory: AbsolutePath })), + withCursor(Schema.Struct({ ...fields, project: ProjectV2.ID, subpath: RelativePath.pipe(Schema.optional) })), + withCursor(Schema.Struct(fields)), +]) +const json = Schema.fromJsonString(input) +const encode = Schema.encodeSync(json) +const decode = Schema.decodeUnknownEffect(json) + +export const SessionsCursor = Schema.String.pipe( + Schema.brand("SessionsCursor"), + withStatics((schema) => { + const make = Schema.decodeUnknownSync(schema) + return { + make: (value: typeof input.Type) => make(Buffer.from(encode(value)).toString("base64url")), + parse: (value: string) => decode(Buffer.from(value, "base64url").toString("utf8")), + } + }), +)