feat(client): add embedded V2 host
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/api": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -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)
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user