refactor(protocol): extract server contracts (#33708)

This commit is contained in:
Kit Langton
2026-06-24 22:15:31 -04:00
committed by GitHub
parent f9dac262ff
commit 56a37c3640
77 changed files with 1070 additions and 878 deletions
+16
View File
@@ -539,6 +539,7 @@
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -656,6 +657,18 @@
"@opentui/solid",
],
},
"packages/protocol": {
"name": "@opencode-ai/protocol",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
@@ -697,6 +710,7 @@
"version": "1.17.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -1863,6 +1877,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
+2 -6
View File
@@ -6,7 +6,7 @@ import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, Match } from "@opencode-ai/schema/filesystem"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -28,11 +28,7 @@ export const ListInput = Schema.Struct({
})
export type ListInput = typeof ListInput.Type
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export { FindInput }
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
+6 -33
View File
@@ -16,9 +16,7 @@ import {
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { withStatics } from "./schema"
import { State } from "./state"
import { Identifier } from "./util/identifier"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
@@ -28,10 +26,7 @@ export type ID = Integration.ID
export const MethodID = Integration.MethodID
export type MethodID = Integration.MethodID
export const AttemptID = Schema.String.pipe(
Schema.brand("Integration.AttemptID"),
withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })),
)
export const AttemptID = Integration.AttemptID
export type AttemptID = typeof AttemptID.Type
export const When = Integration.When
@@ -58,12 +53,8 @@ export type EnvMethod = Integration.EnvMethod
export const Method = Integration.Method
export type Method = Integration.Method
export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {}
export const Info = Integration.Info
export type Info = Integration.Info
export const Inputs = Integration.Inputs
export type Inputs = Integration.Inputs
@@ -102,28 +93,10 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
attemptID: AttemptID,
url: Schema.String,
instructions: Schema.String,
mode: Schema.Literals(["auto", "code"]),
time: Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
}),
}) {}
export const Attempt = Integration.Attempt
export type Attempt = Integration.Attempt
const Time = Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
})
export const AttemptStatus = Schema.Union([
Schema.Struct({ status: Schema.Literal("pending"), time: Time }),
Schema.Struct({ status: Schema.Literal("complete"), time: Time }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }),
Schema.Struct({ status: Schema.Literal("expired"), time: Time }),
]).pipe(Schema.toTaggedUnion("status"))
export const AttemptStatus = Integration.AttemptStatus
export type AttemptStatus = typeof AttemptStatus.Type
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
+3 -18
View File
@@ -1,30 +1,15 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Ref } from "@opencode-ai/schema/location"
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export { Ref }
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
}),
}) {}
export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
}
export function response<S extends Schema.Top>(data: S) {
return Schema.Struct({ location: Info, data })
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const layer = (ref: Ref) =>
+3 -12
View File
@@ -4,22 +4,13 @@ import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { PermissionTable } from "./sql"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
)
export const ID = PermissionSaved.ID
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
projectID: ProjectV2.ID,
action: Schema.String,
resource: Schema.String,
}).annotate({ identifier: "PermissionSaved.Info" })
export const Info = PermissionSaved.Info
export type Info = typeof Info.Type
export const ListInput = Schema.Struct({
+5 -16
View File
@@ -14,24 +14,15 @@ import { EventV2 } from "../event"
import { Database } from "../database/database"
import { Location } from "../location"
import { ProjectDirectoriesEvent } from "@opencode-ai/schema/project-directories"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
export const StrategyID = ProjectCopy.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
projectID: Project.ID,
strategy: StrategyID,
sourceDirectory: AbsolutePath,
directory: AbsolutePath,
name: Schema.optional(Schema.String),
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export const CreateInput = ProjectCopy.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: Project.ID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export const RemoveInput = ProjectCopy.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
@@ -45,9 +36,7 @@ export const RefreshResult = Schema.Struct({
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Copy = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export const Copy = ProjectCopy.Copy
export type Copy = typeof Copy.Type
export const ListEntry = Schema.Struct({
+3 -18
View File
@@ -2,11 +2,10 @@ export * as Pty from "./pty"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { PtyEvent, PtyInfo } from "@opencode-ai/schema/pty"
import { PtyEvent, PtyInfo, Pty } from "@opencode-ai/schema/pty"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
@@ -40,25 +39,11 @@ export const Info = PtyInfo
export type Info = Types.DeepMutable<typeof Info.Type>
export const CreateInput = Schema.Struct({
command: Schema.optional(Schema.String),
args: Schema.optional(Schema.Array(Schema.String)),
cwd: Schema.optional(Schema.String),
title: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export const CreateInput = Pty.CreateInput
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
Schema.Struct({
rows: PositiveInt,
cols: PositiveInt,
}),
),
})
export const UpdateInput = Pty.UpdateInput
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
+3 -6
View File
@@ -1,18 +1,15 @@
export * as PtyTicket from "./ticket"
import { WorkspaceV2 } from "../workspace"
import { PositiveInt } from "../schema"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { LayerNode } from "../effect/layer-node"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: PtyID
+3 -8
View File
@@ -1,6 +1,6 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { Context, Effect, Layer, Scope, Types } from "effect"
import { Reference } from "@opencode-ai/schema/reference"
import { Global } from "./global"
import { EventV2 } from "./event"
@@ -20,13 +20,8 @@ export type Source = Reference.Source
export const Event = Reference.Event
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
source: Source,
}) {}
export const Info = Reference.Info
export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
+1
View File
@@ -86,6 +86,7 @@
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -25,7 +25,9 @@ import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { makeApi } from "@opencode-ai/server/api"
import { makeApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "@opencode-ai/server/location"
import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location"
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
@@ -43,7 +45,11 @@ const EventSchema = Schema.Union([
InstanceDisposed,
]).annotate({ identifier: "Event" })
export const ServerApi = makeApi(EventManifest.Latest.values().toArray())
export const ServerApi = makeApi({
definitions: EventManifest.Latest.values().toArray(),
locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
@@ -24,7 +24,7 @@ import {
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
import { SessionMessagesQuery } from "@opencode-ai/protocol/groups/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/protocol",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
"./*": "./src/*.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+86
View File
@@ -0,0 +1,86 @@
import { Context } from "effect"
import { HttpApi, HttpApiGroup, HttpApiMiddleware, 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 { makeSessionGroup } from "./groups/session"
import { makePermissionGroup } from "./groups/permission"
import { FileSystemGroup } from "./groups/fs"
import { CommandGroup } from "./groups/command"
import { SkillGroup } from "./groups/skill"
import { EventGroup, makeEventGroup } from "./groups/event"
import type { Definition } from "@opencode-ai/schema/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { PtyGroup } from "./groups/pty"
import { makeQuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
import { IntegrationGroup } from "./groups/integration"
import { CredentialGroup } from "./groups/credential"
import { ProjectCopyGroup } from "./groups/project-copy"
// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream.
const makeApiFromGroup = <
const Group extends HttpApiGroup.Any,
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
>(
eventGroup: Group,
locationMiddleware: Context.Key<LocationId, LocationService>,
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
) =>
HttpApi.make("server")
.add(HealthGroup)
.add(LocationGroup.middleware(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware))
.add(MessageGroup.middleware(sessionLocationMiddleware))
.add(ModelGroup.middleware(locationMiddleware))
.add(ProviderGroup.middleware(locationMiddleware))
.add(IntegrationGroup.middleware(locationMiddleware))
.add(CredentialGroup.middleware(locationMiddleware))
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
.add(FileSystemGroup.middleware(locationMiddleware))
.add(CommandGroup.middleware(locationMiddleware))
.add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
.middleware(Authorization)
.middleware(SchemaErrorMiddleware)
export const makeApi = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
>(options: {
readonly definitions: ReadonlyArray<Definition>
readonly locationMiddleware: Context.Key<LocationId, LocationService>
readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
}) =>
makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware)
export const makeDefaultApi = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
>(options: {
readonly locationMiddleware: Context.Key<LocationId, LocationService>
readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware)
+20
View File
@@ -0,0 +1,20 @@
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const AgentGroup = HttpApiGroup.make("server.agent").add(
HttpApiEndpoint.get("agent.list", "/api/agent", {
query: LocationQuery,
success: Location.response(Schema.Array(Agent.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.agent.list",
summary: "List agents",
description: "Retrieve currently registered agents.",
}),
),
)
@@ -1,14 +1,14 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { Command } from "@opencode-ai/schema/command"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const CommandGroup = HttpApiGroup.make("server.command")
.add(
HttpApiEndpoint.get("command.list", "/api/command", {
query: LocationQuery,
success: Location.response(Schema.Array(CommandV2.Info)),
success: Location.response(Schema.Array(Command.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -25,4 +25,3 @@ export const CommandGroup = HttpApiGroup.make("server.command")
description: "Experimental command routes.",
}),
)
.middleware(LocationMiddleware)
@@ -1,7 +1,7 @@
import { Credential } from "@opencode-ai/core/credential"
import { Credential } from "@opencode-ai/schema/credential"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const CredentialGroup = HttpApiGroup.make("server.credential")
.add(
@@ -35,4 +35,3 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
}),
),
)
.middleware(LocationMiddleware)
+59
View File
@@ -0,0 +1,59 @@
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Location } from "@opencode-ai/schema/location"
import type { Definition } from "@opencode-ai/schema/event"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const fields = {
id: Event.ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
location: Schema.optional(Location.Ref),
}
const schema = (definitions: ReadonlyArray<Definition>) =>
Schema.Union([
...definitions.map((definition) =>
Schema.Struct({
...fields,
type: Schema.Literal(definition.type),
data: definition.data,
}).annotate({ identifier: `V2Event.${definition.type}` }),
),
...(definitions.some((definition) => definition.type === "server.connected")
? []
: [
Schema.Struct({
...fields,
type: Schema.Literal("server.connected"),
data: Schema.Struct({}),
}).annotate({ identifier: "V2Event.server.connected" }),
]),
]).annotate({ identifier: "V2Event" })
const make = (definitions: ReadonlyArray<Definition>) => {
const EventSchema = schema(definitions)
return {
schema: EventSchema,
group: HttpApiGroup.make("server.event")
.add(
HttpApiEndpoint.get("event.subscribe", "/api/event", {
success: EventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for the server.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })),
}
}
export const makeEventGroup = (definitions: ReadonlyArray<Definition>) => make(definitions).group
const event = make(EventManifest.ServerDefinitions)
export const EventGroup = event.group
export type Event = typeof event.schema.Type
@@ -1,9 +1,9 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/schema/location"
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
const ListQuery = Schema.Struct({
...LocationQuery.fields,
@@ -66,4 +66,3 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
description: "Experimental location-scoped filesystem routes.",
}),
)
.middleware(LocationMiddleware)
@@ -1,9 +1,9 @@
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
const Inputs = Schema.Record(Schema.String, Schema.String)
@@ -128,4 +128,3 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(
OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }),
)
.middleware(LocationMiddleware)
+42
View File
@@ -0,0 +1,42 @@
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "LocationQuery" })
export const locationQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "location" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export const LocationGroup = HttpApiGroup.make("server.location").add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.Info,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
@@ -1,9 +1,8 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
export const SessionMessagesQuery = Schema.Struct({
limit: Schema.optional(
@@ -25,7 +24,7 @@ export const SessionMessagesQuery = Schema.Struct({
export const MessageGroup = HttpApiGroup.make("server.message")
.add(
HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionV2.ID },
params: { sessionID: Session.ID },
query: SessionMessagesQuery,
success: Schema.Struct({
data: Schema.Array(SessionMessage.Message),
@@ -35,16 +34,14 @@ export const MessageGroup = HttpApiGroup.make("server.message")
}),
}).annotate({ identifier: "SessionMessagesResponse" }),
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.messages",
summary: "Get session messages",
description:
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.messages",
summary: "Get session messages",
description:
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -1,15 +1,15 @@
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const ModelGroup = HttpApiGroup.make("server.model")
.add(
HttpApiEndpoint.get("model.list", "/api/model", {
query: LocationQuery,
success: Location.response(Schema.Array(ModelV2.Info)),
success: Location.response(Schema.Array(Model.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
@@ -27,4 +27,3 @@ export const ModelGroup = HttpApiGroup.make("server.model")
description: "Experimental model routes.",
}),
)
.middleware(LocationMiddleware)
@@ -0,0 +1,95 @@
import { Permission } from "@opencode-ai/schema/permission"
import { Location } from "@opencode-ai/schema/location"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Project } from "@opencode-ai/schema/project"
import { Session } from "@opencode-ai/schema/session"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const makePermissionGroup = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
>(
locationMiddleware: Context.Key<LocationId, LocationService>,
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
) =>
HttpApiGroup.make("server.permission")
.add(
HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
query: LocationQuery,
success: Location.response(Schema.Array(Permission.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.add(
HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", {
query: Schema.Struct({ projectID: Project.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
),
)
.add(
HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
),
)
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
.middleware(locationMiddleware)
.add(
HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
params: { sessionID: Session.ID, requestID: Permission.ID },
payload: Schema.Struct({
reply: Permission.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." }))
@@ -1,8 +1,8 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Project } from "@opencode-ai/schema/project"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
const root = "/experimental/project/:projectID/copy"
@@ -23,7 +23,7 @@ const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields,
export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
.add(
HttpApiEndpoint.post("projectCopy.create", root, {
params: { projectID: ProjectV2.ID },
params: { projectID: Project.ID },
query: LocationQuery,
payload: CreatePayload,
success: ProjectCopy.Copy,
@@ -34,7 +34,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
)
.add(
HttpApiEndpoint.delete("projectCopy.remove", root, {
params: { projectID: ProjectV2.ID },
params: { projectID: Project.ID },
query: LocationQuery,
payload: RemovePayload,
success: HttpApiSchema.NoContent,
@@ -45,7 +45,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
)
.add(
HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
params: { projectID: ProjectV2.ID },
params: { projectID: Project.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
@@ -54,4 +54,3 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.middleware(LocationMiddleware)
@@ -1,15 +1,15 @@
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/schema/provider"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProviderNotFoundError, ServiceUnavailableError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const ProviderGroup = HttpApiGroup.make("server.provider")
.add(
HttpApiEndpoint.get("provider.list", "/api/provider", {
query: LocationQuery,
success: Location.response(Schema.Array(ProviderV2.Info)),
success: Location.response(Schema.Array(Provider.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
@@ -23,9 +23,9 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
)
.add(
HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
params: { providerID: Provider.ID },
query: LocationQuery,
success: Location.response(ProviderV2.Info),
success: Location.response(Provider.Info),
error: [ProviderNotFoundError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
@@ -43,4 +43,3 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
description: "Experimental provider routes.",
}),
)
.middleware(LocationMiddleware)
@@ -1,11 +1,10 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ForbiddenError, PtyNotFoundError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const PTY_CONNECT_TICKET_QUERY = "ticket"
export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
@@ -51,7 +50,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
)
.add(
HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", {
params: { ptyID: PtyID },
params: { ptyID: Pty.ID },
query: LocationQuery,
success: Location.response(Pty.Info),
error: PtyNotFoundError,
@@ -67,7 +66,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
)
.add(
HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", {
params: { ptyID: PtyID },
params: { ptyID: Pty.ID },
query: LocationQuery,
payload: Pty.UpdateInput,
success: Location.response(Pty.Info),
@@ -84,7 +83,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
)
.add(
HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", {
params: { ptyID: PtyID },
params: { ptyID: Pty.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: PtyNotFoundError,
@@ -100,7 +99,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
)
.add(
HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: PtyID },
params: { ptyID: Pty.ID },
query: LocationQuery,
success: Location.response(PtyTicket.ConnectToken),
error: [ForbiddenError, PtyNotFoundError],
@@ -118,7 +117,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
// Query fields are decoded in the raw handler after the existence check so a missing
// session responds with an empty 404 before any upgrade work.
HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", {
params: { ptyID: PtyID },
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError],
}).annotateMerge(
@@ -141,4 +140,3 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." }))
.middleware(LocationMiddleware)
+84
View File
@@ -0,0 +1,84 @@
import { Question } from "@opencode-ai/schema/question"
import { Location } from "@opencode-ai/schema/location"
import { Session } from "@opencode-ai/schema/session"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../errors"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const makeQuestionGroup = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
>(
locationMiddleware: Context.Key<LocationId, LocationService>,
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
) =>
HttpApiGroup.make("server.question")
.add(
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
query: LocationQuery,
success: Location.response(Schema.Array(Question.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
.middleware(locationMiddleware)
.add(
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Question.Request) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.list",
summary: "List session question requests",
description: "Retrieve pending question requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
params: { sessionID: Session.ID, requestID: Question.ID },
payload: Question.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
params: { sessionID: Session.ID, requestID: Question.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
)
@@ -1,8 +1,8 @@
import { Location } from "@opencode-ai/core/location"
import { Reference } from "@opencode-ai/core/reference"
import { Location } from "@opencode-ai/schema/location"
import { Reference } from "@opencode-ai/schema/reference"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const ReferenceGroup = HttpApiGroup.make("server.reference")
.add(
@@ -25,4 +25,3 @@ export const ReferenceGroup = HttpApiGroup.make("server.reference")
description: "Location-scoped project references.",
}),
)
.middleware(LocationMiddleware)
+280
View File
@@ -0,0 +1,280 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Encoding, Result, Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
InvalidCursorError,
InvalidRequestError,
MessageNotFoundError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../errors"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "@opencode-ai/schema/location"
import { Revert } from "@opencode-ai/schema/revert"
const SessionsQueryFields = {
workspace: Workspace.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: Project.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: Session.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) => {
const make = schema.make.bind(schema)
return {
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))),
}
}),
)
export type SessionsCursor = typeof SessionsCursor.Type
const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
})
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsQueryCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
query: SessionsQuery,
success: Schema.Struct({
data: Schema.Array(Session.Info),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
summary: "List sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
}),
),
)
.add(
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Session.Info }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ agent: Agent.ID }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchAgent",
summary: "Switch session agent",
description: "Switch the agent used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ model: Model.Ref }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchModel",
summary: "Switch session model",
description: "Switch the model used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send message",
description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
}),
),
)
.add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact session",
description: "Compact a session conversation.",
}),
),
)
.add(
HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for session",
description: "Wait for a session agent loop to become idle.",
}),
),
)
.add(
HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
success: Schema.Struct({ data: Revert.State }),
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.revert.stage",
summary: "Stage session revert",
description: "Stage or move a reversible session boundary and optionally apply its file changes.",
}),
),
)
.add(
HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, UnknownError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
)
.add(
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
error: [SessionNotFoundError, UnknownError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get session context",
description: "Retrieve the active context messages for a session (all messages after the last compaction).",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sessions",
description: "Experimental session routes.",
}),
)
@@ -1,14 +1,14 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { Location } from "@opencode-ai/core/location"
import { Skill } from "@opencode-ai/schema/skill"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const SkillGroup = HttpApiGroup.make("server.skill")
.add(
HttpApiEndpoint.get("skill.list", "/api/skill", {
query: LocationQuery,
success: Location.response(Schema.Array(SkillV2.Info)),
success: Location.response(Schema.Array(Skill.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -25,4 +25,3 @@ export const SkillGroup = HttpApiGroup.make("server.skill")
description: "Experimental skill routes.",
}),
)
.middleware(LocationMiddleware)
@@ -0,0 +1,6 @@
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { UnauthorizedError } from "../errors"
export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
error: UnauthorizedError,
}) {}
@@ -0,0 +1,7 @@
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
"@opencode/HttpApiSchemaError",
{ error: InvalidRequestError },
) {}
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { SessionsCursor } from "../src/groups/session"
import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
}
const cursor = SessionsCursor.make(input)
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
})
})
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
export * as Credential from "./credential"
import { Schema } from "effect"
import { Integration } from "./integration"
import { IntegrationMethodID } from "./integration-id"
import { ascending } from "./identifier"
import { NonNegativeInt, withStatics } from "./schema"
@@ -14,7 +14,7 @@ export type ID = typeof ID.Type
export interface OAuth extends Schema.Schema.Type<typeof OAuth> {}
export const OAuth = Schema.Struct({
type: Schema.Literal("oauth"),
methodID: Integration.MethodID,
methodID: IntegrationMethodID,
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
+6
View File
@@ -31,3 +31,9 @@ export const Match = Schema.Struct({
text: Schema.String,
submatches: Schema.Array(Submatch),
}).annotate({ identifier: "FileSystem.Match" })
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
+5
View File
@@ -9,7 +9,9 @@ export { LLM } from "./llm"
export { Location } from "./location"
export { Model } from "./model"
export { Permission } from "./permission"
export { PermissionSaved } from "./permission-saved"
export { Project } from "./project"
export { ProjectCopy } from "./project-copy"
export { Provider } from "./provider"
export { Reference } from "./reference"
export { Revert } from "./revert"
@@ -17,6 +19,9 @@ export { Session } from "./session"
export { SessionInput } from "./session-input"
export { SessionMessage } from "./session-message"
export { Skill } from "./skill"
export { Pty } from "./pty"
export { PtyTicket } from "./pty-ticket"
export { Question } from "./question"
export { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export * from "./schema"
+7
View File
@@ -0,0 +1,7 @@
import { Schema } from "effect"
export const IntegrationID = Schema.String.pipe(Schema.brand("Integration.ID"))
export type IntegrationID = typeof IntegrationID.Type
export const IntegrationMethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
export type IntegrationMethodID = typeof IntegrationMethodID.Type
+40 -2
View File
@@ -2,11 +2,15 @@ export * as Integration from "./integration"
import { Schema } from "effect"
import { define, inventory } from "./event"
import { Connection } from "./connection"
import { ascending } from "./identifier"
import { withStatics } from "./schema"
import { IntegrationID, IntegrationMethodID } from "./integration-id"
export const ID = Schema.String.pipe(Schema.brand("Integration.ID"))
export const ID = IntegrationID
export type ID = typeof ID.Type
export const MethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
export const MethodID = IntegrationMethodID
export type MethodID = typeof MethodID.Type
export interface When extends Schema.Schema.Type<typeof When> {}
@@ -88,3 +92,37 @@ export const Ref = Schema.Struct({
id: ID,
name: Schema.String,
}).annotate({ identifier: "Integration.Ref" })
export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.mutable(Schema.Array(Connection.Info)),
}) {}
export const AttemptID = Schema.String.pipe(
Schema.brand("Integration.AttemptID"),
withStatics((schema) => ({ create: () => schema.make("con_" + ascending()) })),
)
export type AttemptID = typeof AttemptID.Type
const AttemptTime = Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
})
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
attemptID: AttemptID,
url: Schema.String,
instructions: Schema.String,
mode: Schema.Literals(["auto", "code"]),
time: AttemptTime,
}) {}
export const AttemptStatus = Schema.Union([
Schema.Struct({ status: Schema.Literal("pending"), time: AttemptTime }),
Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }),
Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }),
]).pipe(Schema.toTaggedUnion("status"))
export type AttemptStatus = typeof AttemptStatus.Type
+15 -1
View File
@@ -1,7 +1,8 @@
export * as Location from "./location"
import { Effect, Schema } from "effect"
import { AbsolutePath } from "./schema"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { ProjectID } from "./project-id"
import { WorkspaceID } from "./workspace-id"
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
@@ -12,3 +13,16 @@ export const Ref = Schema.Struct({
Schema.withConstructorDefault(Effect.succeed(undefined)),
),
}).annotate({ identifier: "Location.Ref" })
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: optionalOmitUndefined(WorkspaceID),
project: Schema.Struct({
id: ProjectID,
directory: AbsolutePath,
}),
}) {}
export function response<S extends Schema.Top>(data: S) {
return Schema.Struct({ location: Info, data })
}
+20
View File
@@ -0,0 +1,20 @@
export * as PermissionSaved from "./permission-saved"
import { Schema } from "effect"
import { ascending } from "./identifier"
import { ProjectID } from "./project-id"
import { withStatics } from "./schema"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
withStatics((schema) => ({ create: () => schema.make("psv_" + ascending()) })),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
projectID: ProjectID,
action: Schema.String,
resource: Schema.String,
}).annotate({ identifier: "PermissionSaved.Info" })
export type Info = typeof Info.Type
+29
View File
@@ -0,0 +1,29 @@
export * as ProjectCopy from "./project-copy"
import { Schema } from "effect"
import { ProjectID } from "./project-id"
import { AbsolutePath } from "./schema"
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
projectID: ProjectID,
strategy: StrategyID,
sourceDirectory: AbsolutePath,
directory: AbsolutePath,
name: Schema.optional(Schema.String),
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: ProjectID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export type RemoveInput = typeof RemoveInput.Type
export const Copy = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export type Copy = typeof Copy.Type
+8
View File
@@ -0,0 +1,8 @@
import { Schema } from "effect"
import { withStatics } from "./schema"
export const ProjectID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({ global: schema.make("global") })),
)
export type ProjectID = typeof ProjectID.Type
+3 -5
View File
@@ -2,12 +2,10 @@ export * as Project from "./project"
import { Schema } from "effect"
import { define, inventory } from "./event"
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "./schema"
import { NonNegativeInt, optionalOmitUndefined } from "./schema"
import { ProjectID } from "./project-id"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({ global: schema.make("global") })),
)
export const ID = ProjectID
export type ID = typeof ID.Type
export const Vcs = Schema.Literal("git")
+9
View File
@@ -0,0 +1,9 @@
export * as PtyTicket from "./pty-ticket"
import { Schema } from "effect"
import { PositiveInt } from "./schema"
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
+21 -1
View File
@@ -3,7 +3,7 @@ export * as Pty from "./pty"
import { Schema } from "effect"
import { define, inventory } from "./event"
import { ascending } from "./identifier"
import { NonNegativeInt } from "./schema"
import { NonNegativeInt, PositiveInt } from "./schema"
import { withStatics } from "./schema"
const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
@@ -33,3 +33,23 @@ const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegat
const Deleted = define({ type: "pty.deleted", schema: { id: ID } })
export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) }
export const PtyEvent = Event
export const CreateInput = Schema.Struct({
command: Schema.optional(Schema.String),
args: Schema.optional(Schema.Array(Schema.String)),
cwd: Schema.optional(Schema.String),
title: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type CreateInput = typeof CreateInput.Type
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
Schema.Struct({
rows: PositiveInt,
cols: PositiveInt,
}),
),
})
export type UpdateInput = typeof UpdateInput.Type
+8
View File
@@ -26,3 +26,11 @@ export const GitSource = Schema.Struct({
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
source: Source,
}) {}
@@ -0,0 +1,10 @@
import { describe, expect, test } from "bun:test"
import { FileSystem } from "../src/filesystem"
describe("schema compatibility", () => {
test("moved class schemas remain constructible", () => {
const input = new FileSystem.FindInput({ query: "src" })
expect(input).toBeInstanceOf(FileSystem.FindInput)
expect(input.query).toBe("src")
})
})
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
+7 -55
View File
@@ -1,56 +1,8 @@
import { HttpApi, HttpApiGroup, 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 { PermissionGroup } from "./groups/permission"
import { FileSystemGroup } from "./groups/fs"
import { CommandGroup } from "./groups/command"
import { SkillGroup } from "./groups/skill"
import { EventGroup, makeEventGroup } from "./groups/event"
import type { Definition } from "@opencode-ai/core/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { PtyGroup } from "./groups/pty"
import { QuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
import { IntegrationGroup } from "./groups/integration"
import { CredentialGroup } from "./groups/credential"
import { ProjectCopyGroup } from "./groups/project-copy"
import { makeDefaultApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "./location"
import { SessionLocationMiddleware } from "./middleware/session-location"
const makeApiFromGroup = <const Group extends HttpApiGroup.Any>(eventGroup: Group) =>
HttpApi.make("server")
.add(HealthGroup)
.add(LocationGroup)
.add(AgentGroup)
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(IntegrationGroup)
.add(CredentialGroup)
.add(PermissionGroup)
.add(FileSystemGroup)
.add(CommandGroup)
.add(SkillGroup)
.add(eventGroup)
.add(PtyGroup)
.add(QuestionGroup)
.add(ReferenceGroup)
.add(ProjectCopyGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
.middleware(Authorization)
.middleware(SchemaErrorMiddleware)
export const makeApi = (definitions: ReadonlyArray<Definition>) => makeApiFromGroup(makeEventGroup(definitions))
export const Api = makeApiFromGroup(EventGroup)
export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})
-22
View File
@@ -1,22 +0,0 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const AgentGroup = HttpApiGroup.make("server.agent")
.add(
HttpApiEndpoint.get("agent.list", "/api/agent", {
query: LocationQuery,
success: Location.response(Schema.Array(AgentV2.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.agent.list",
summary: "List agents",
description: "Retrieve currently registered agents.",
}),
),
)
.middleware(LocationMiddleware)
-65
View File
@@ -1,65 +0,0 @@
import { EventV2 } from "@opencode-ai/core/event"
import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
import { Location } from "@opencode-ai/core/location"
import type { Definition } from "@opencode-ai/core/event"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const fields = {
id: EventV2.ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
location: Schema.optional(Location.Ref),
}
const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
Schema.Union([
...definitions.map((definition) =>
Schema.Struct({
...fields,
type: Schema.Literal(definition.type),
data: definition.data,
}).annotate({ identifier: `V2Event.${definition.type}` }),
),
...(definitions.some((definition) => definition.type === "server.connected")
? []
: [
Schema.Struct({
...fields,
type: Schema.Literal("server.connected"),
data: Schema.Struct({}),
}).annotate({ identifier: "V2Event.server.connected" }),
]),
]).annotate({ identifier: "V2Event" })
export const makeEventGroup = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
HttpApiGroup.make("server.event")
.add(
HttpApiEndpoint.get("event.subscribe", "/api/event", {
success: schema(definitions),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for the server.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
const EventSchema = schema(PublicEventManifest.Definitions)
export const EventGroup = HttpApiGroup.make("server.event")
.add(
HttpApiEndpoint.get("event.subscribe", "/api/event", {
success: EventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for the server.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
export type Event = typeof EventSchema.Type
-86
View File
@@ -1,86 +0,0 @@
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Location } from "@opencode-ai/core/location"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const PermissionGroup = HttpApiGroup.make("server.permission")
.add(
HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
query: LocationQuery,
success: Location.response(Schema.Array(PermissionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.add(
HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
),
)
.add(
HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
),
)
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." }))
-75
View File
@@ -1,75 +0,0 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Location } from "@opencode-ai/core/location"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const QuestionGroup = HttpApiGroup.make("server.question")
.add(
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
query: LocationQuery,
success: Location.response(Schema.Array(QuestionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.list",
summary: "List session question requests",
description: "Retrieve pending question requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
payload: QuestionV2.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
)
-281
View File
@@ -1,281 +0,0 @@
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Prompt } from "@opencode-ai/core/session/prompt"
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 { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
InvalidCursorError,
InvalidRequestError,
MessageNotFoundError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
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) => {
const make = schema.make
return {
make: (input: typeof SessionsCursorInput.Type) =>
make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
}
}),
)
export type SessionsCursor = typeof SessionsCursor.Type
const SessionsCursorQuery = Schema.Struct({
cursor: SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}),
limit: SessionsQueryFields.limit,
})
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: ProjectV2.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
query: SessionsQuery,
success: Schema.Struct({
data: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
summary: "List sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
}),
),
)
.add(
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: SessionV2.ID.pipe(Schema.optional),
agent: AgentV2.ID.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionV2.Info }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: SessionV2.Info }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({ agent: AgentV2.ID }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchAgent",
summary: "Switch session agent",
description: "Switch the agent used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({ model: ModelV2.Ref }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchModel",
summary: "Switch session model",
description: "Switch the model used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send message",
description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
}),
),
)
.add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact session",
description: "Compact a session conversation.",
}),
),
)
.add(
HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for session",
description: "Wait for a session agent loop to become idle.",
}),
),
)
.add(
HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
success: Schema.Struct({ data: SessionV2.RevertState }),
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.revert.stage",
summary: "Stage session revert",
description: "Stage or move a reversible session boundary and optionally apply its file changes.",
}),
),
)
.add(
HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
)
.add(
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" })),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
error: [SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get session context",
description: "Retrieve the active context messages for a session (all messages after the last compaction).",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sessions",
description: "Experimental session routes.",
}),
)
+1 -1
View File
@@ -3,7 +3,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Layer } from "effect"
import { layer as locationLayer } from "./groups/location"
import { layer as locationLayer } from "./location"
import { sessionLocationLayer } from "./middleware/session-location"
import { MessageHandler } from "./handlers/message"
import { ModelHandler } from "./handlers/model"
+1 -1
View File
@@ -2,7 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { response } from "../location"
export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
handlers.handle("agent.list", () =>
+1 -2
View File
@@ -1,8 +1,7 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { response } from "../location"
export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) =>
handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))),
+1 -1
View File
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { response } from "../location"
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
Effect.gen(function* () {
+2 -2
View File
@@ -2,8 +2,8 @@ import { Integration } from "@opencode-ai/core/integration"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { InvalidRequestError } from "../errors"
import { response } from "../groups/location"
import { InvalidRequestError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
effect.pipe(
+1 -1
View File
@@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
const DefaultMessagesLimit = 50
+1 -1
View File
@@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { response } from "../location"
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
Effect.gen(function* () {
+2 -2
View File
@@ -4,8 +4,8 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "../errors"
import { response } from "../groups/location"
import { PermissionNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
+1 -1
View File
@@ -4,7 +4,7 @@ import { Git } from "@opencode-ai/core/git"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProjectCopyError } from "../groups/project-copy"
import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy"
export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
Effect.succeed(
+2 -3
View File
@@ -1,10 +1,9 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProviderNotFoundError } from "../errors"
import { response } from "../groups/location"
import { ProviderNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
+7 -3
View File
@@ -8,9 +8,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { Api } from "../api"
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
import { ForbiddenError, PtyNotFoundError } from "../errors"
import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty"
import { response } from "../groups/location"
import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@opencode-ai/protocol/groups/pty"
import { response } from "../location"
import { PtyEnvironment } from "../pty-environment"
const ticketScope = Effect.gen(function* () {
+2 -2
View File
@@ -2,8 +2,8 @@ import { QuestionV2 } from "@opencode-ai/core/question"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { QuestionNotFoundError } from "../errors"
import { response } from "../groups/location"
import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
function missingRequest(id: QuestionV2.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
+1 -1
View File
@@ -1,7 +1,7 @@
import { Reference } from "@opencode-ai/core/reference"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { response } from "../location"
export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
+2 -2
View File
@@ -2,7 +2,7 @@ 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"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
import {
ConflictError,
InvalidCursorError,
@@ -10,7 +10,7 @@ import {
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../errors"
} from "@opencode-ai/protocol/errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
const DefaultSessionsLimit = 50
+1 -1
View File
@@ -1,7 +1,7 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
import { response } from "../location"
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))),
@@ -1,35 +1,16 @@
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "LocationQuery" })
export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceMap)["get"]>>
export const locationQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "location" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
"@opencode/HttpApiLocation",
) {}
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
@@ -45,32 +26,6 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
export type LocationServices = Layer.Success<ReturnType<typeof LocationServiceMap.get>>
export class LocationMiddleware extends HttpApiMiddleware.Service<
LocationMiddleware,
{
provides: LocationServices
}
>()("@opencode/HttpApiLocation") {}
export const LocationGroup = HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.Info,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.middleware(LocationMiddleware)
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
@@ -1,17 +1,14 @@
import { ServerAuth } from "../auth"
import { UnauthorizedError } from "../errors"
import { hasPtyConnectTicketURL } from "../groups/pty"
import { UnauthorizedError } from "@opencode-ai/protocol/errors"
import { Authorization } from "@opencode-ai/protocol/middleware/authorization"
export { Authorization } from "@opencode-ai/protocol/middleware/authorization"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
const AUTH_TOKEN_QUERY = "auth_token"
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
error: UnauthorizedError,
}) {}
function emptyCredential() {
return { username: "", password: Redacted.make("") }
}
@@ -1,6 +1,8 @@
import { Effect } from "effect"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
import { InvalidRequestError } from "@opencode-ai/protocol/errors"
import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
const REASON_LIMIT = 1024
@@ -9,11 +11,6 @@ function truncateReason(reason: string) {
return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)`
}
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
"@opencode/HttpApiSchemaError",
{ error: InvalidRequestError },
) {}
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
const reason = truncateReason(error.cause.message)
return Effect.logWarning("schema rejection").pipe(
@@ -9,14 +9,12 @@ import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "../errors"
import type { LocationServices } from "../groups/location"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import type { LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
{
provides: LocationServices
}
{ provides: LocationServices }
>()("@opencode/HttpApiSessionLocation", {
error: [InvalidRequestError, SessionNotFoundError],
}) {}