feat(client): add embedded V2 host

This commit is contained in:
Kit Langton
2026-06-23 11:56:06 -04:00
parent 924e00776b
commit e03f5dd331
25 changed files with 507 additions and 432 deletions
+2 -2
View File
@@ -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.
+2 -16
View File
@@ -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"],
-22
View File
@@ -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:"
}
}
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json"
}
+11 -2
View File
@@ -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.
+2 -1
View File
@@ -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"
+29 -2
View File
@@ -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,
),
],
+37 -2
View File
@@ -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"
+19 -2
View File
@@ -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<typeof Api>
const mapClientError = <E,>(error: E) =>
+19 -7
View File
@@ -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"]
+2 -1
View File
@@ -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"
+55
View File
@@ -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 })
}
})
+2 -1
View File
@@ -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"]
}
+36 -3
View File
@@ -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<Record<string, string>> },
): Output {
return {
operations: operations(contract.groups),
@@ -291,7 +293,9 @@ function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
function renderImportedEffectFiles(
groups: ReadonlyArray<Group>,
options: { readonly module: string; readonly api: string },
options:
| { readonly module: string; readonly api: string }
| { readonly module: string; readonly endpoints: Readonly<Record<string, string>> },
): 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<typeof ${options.api}>\n\nconst mapClientError = <E>(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<typeof ${api}>\n\nconst mapClientError = <E>(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<Group>, endpoints: Readonly<Record<string, string>>) {
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<Group>) {
const types = new Map<SchemaAST.AST, string>()
const typeOf = (schema: Schema.Top) => {
@@ -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(
@@ -16,6 +16,7 @@ type OpenApiResponse = {
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
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
-1
View File
@@ -12,7 +12,6 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/api": "workspace:*",
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
+3 -21
View File
@@ -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)
@@ -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 = <S extends Schema.Top>(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>()(
"SessionNotFoundError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"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>()(
"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)
+20 -60
View File
@@ -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 = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
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],
+2 -4
View File
@@ -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,
@@ -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,
}),
),
),
),
}
}),
)
}),
)
+156 -6
View File
@@ -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(
+13 -7
View File
@@ -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<E, R>(auth: Layer.Layer<ServerAuth.Config, E, R>) {
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),
+36
View File
@@ -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 = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
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")),
}
}),
)