From 6ac446eaff0a2adc8bb15fa93b0c3cafab8ca8fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 24 Jun 2026 17:13:18 -0400 Subject: [PATCH 1/5] refactor(protocol): extract server contracts --- bun.lock | 16 ++ packages/core/src/filesystem.ts | 8 +- packages/core/src/integration.ts | 39 +-- packages/core/src/location.ts | 21 +- packages/core/src/permission/saved.ts | 15 +- packages/core/src/project/copy.ts | 21 +- packages/core/src/pty.ts | 21 +- packages/core/src/pty/ticket.ts | 9 +- packages/core/src/reference.ts | 11 +- packages/protocol/package.json | 23 ++ packages/protocol/src/api.ts | 56 ++++ packages/protocol/src/errors.ts | 101 +++++++ packages/protocol/src/groups/agent.ts | 22 ++ packages/protocol/src/groups/command.ts | 28 ++ packages/protocol/src/groups/credential.ts | 38 +++ packages/protocol/src/groups/event.ts | 59 +++++ packages/protocol/src/groups/fs.ts | 69 +++++ packages/protocol/src/groups/health.ts | 14 + packages/protocol/src/groups/integration.ts | 131 ++++++++++ packages/protocol/src/groups/location.ts | 53 ++++ packages/protocol/src/groups/message.ts | 54 ++++ packages/protocol/src/groups/model.ts | 30 +++ packages/protocol/src/groups/permission.ts | 86 ++++++ packages/protocol/src/groups/project-copy.ts | 57 ++++ packages/protocol/src/groups/provider.ts | 46 ++++ packages/protocol/src/groups/pty.ts | 143 ++++++++++ packages/protocol/src/groups/question.ts | 75 ++++++ packages/protocol/src/groups/reference.ts | 28 ++ packages/protocol/src/groups/session.ts | 245 +++++++++++++++++ packages/protocol/src/groups/skill.ts | 28 ++ .../protocol/src/middleware/authorization.ts | 6 + .../protocol/src/middleware/schema-error.ts | 7 + .../src/middleware/session-location.ts | 10 + packages/protocol/test/session-cursor.test.ts | 18 ++ packages/protocol/tsconfig.json | 8 + packages/schema/src/credential.ts | 4 +- packages/schema/src/filesystem.ts | 6 + packages/schema/src/index.ts | 5 + packages/schema/src/integration-id.ts | 7 + packages/schema/src/integration.ts | 42 ++- packages/schema/src/location.ts | 16 +- packages/schema/src/permission-saved.ts | 20 ++ packages/schema/src/project-copy.ts | 29 ++ packages/schema/src/project-id.ts | 8 + packages/schema/src/project.ts | 8 +- packages/schema/src/pty-ticket.ts | 9 + packages/schema/src/pty.ts | 20 ++ packages/schema/src/reference.ts | 8 + packages/schema/test/compatibility.test.ts | 10 + packages/server/package.json | 1 + packages/server/src/api.ts | 57 +--- packages/server/src/errors.ts | 102 +------- packages/server/src/groups/agent.ts | 23 +- packages/server/src/groups/command.ts | 29 +- packages/server/src/groups/credential.ts | 39 +-- packages/server/src/groups/event.ts | 66 +---- packages/server/src/groups/fs.ts | 70 +---- packages/server/src/groups/health.ts | 15 +- packages/server/src/groups/integration.ts | 132 +--------- packages/server/src/groups/location.ts | 62 +---- packages/server/src/groups/message.ts | 55 +--- packages/server/src/groups/model.ts | 31 +-- packages/server/src/groups/permission.ts | 87 +----- packages/server/src/groups/project-copy.ts | 58 +--- packages/server/src/groups/provider.ts | 47 +--- packages/server/src/groups/pty.ts | 145 +--------- packages/server/src/groups/question.ts | 76 +----- packages/server/src/groups/reference.ts | 29 +- packages/server/src/groups/session.ts | 247 +----------------- packages/server/src/groups/skill.ts | 29 +- packages/server/src/handlers/agent.ts | 2 +- packages/server/src/handlers/command.ts | 3 +- packages/server/src/handlers/credential.ts | 2 +- packages/server/src/handlers/event.ts | 2 +- packages/server/src/handlers/fs.ts | 2 +- packages/server/src/handlers/health.ts | 2 +- packages/server/src/handlers/integration.ts | 4 +- packages/server/src/handlers/location.ts | 2 +- packages/server/src/handlers/message.ts | 4 +- packages/server/src/handlers/model.ts | 2 +- packages/server/src/handlers/permission.ts | 4 +- packages/server/src/handlers/project-copy.ts | 4 +- packages/server/src/handlers/provider.ts | 5 +- packages/server/src/handlers/pty.ts | 10 +- packages/server/src/handlers/question.ts | 4 +- packages/server/src/handlers/reference.ts | 2 +- packages/server/src/handlers/session.ts | 6 +- packages/server/src/handlers/skill.ts | 2 +- .../server/src/middleware/authorization.ts | 11 +- .../server/src/middleware/schema-error.ts | 9 +- .../server/src/middleware/session-location.ts | 15 +- packages/server/src/routes.ts | 2 +- 92 files changed, 1733 insertions(+), 1554 deletions(-) create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/api.ts create mode 100644 packages/protocol/src/errors.ts create mode 100644 packages/protocol/src/groups/agent.ts create mode 100644 packages/protocol/src/groups/command.ts create mode 100644 packages/protocol/src/groups/credential.ts create mode 100644 packages/protocol/src/groups/event.ts create mode 100644 packages/protocol/src/groups/fs.ts create mode 100644 packages/protocol/src/groups/health.ts create mode 100644 packages/protocol/src/groups/integration.ts create mode 100644 packages/protocol/src/groups/location.ts create mode 100644 packages/protocol/src/groups/message.ts create mode 100644 packages/protocol/src/groups/model.ts create mode 100644 packages/protocol/src/groups/permission.ts create mode 100644 packages/protocol/src/groups/project-copy.ts create mode 100644 packages/protocol/src/groups/provider.ts create mode 100644 packages/protocol/src/groups/pty.ts create mode 100644 packages/protocol/src/groups/question.ts create mode 100644 packages/protocol/src/groups/reference.ts create mode 100644 packages/protocol/src/groups/session.ts create mode 100644 packages/protocol/src/groups/skill.ts create mode 100644 packages/protocol/src/middleware/authorization.ts create mode 100644 packages/protocol/src/middleware/schema-error.ts create mode 100644 packages/protocol/src/middleware/session-location.ts create mode 100644 packages/protocol/test/session-cursor.test.ts create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/schema/src/integration-id.ts create mode 100644 packages/schema/src/permission-saved.ts create mode 100644 packages/schema/src/project-copy.ts create mode 100644 packages/schema/src/project-id.ts create mode 100644 packages/schema/src/pty-ticket.ts create mode 100644 packages/schema/test/compatibility.test.ts diff --git a/bun.lock b/bun.lock index 5dd59c634bbc..321ce0306c5d 100644 --- a/bun.lock +++ b/bun.lock @@ -656,6 +656,19 @@ "@opentui/solid", ], }, + "packages/protocol": { + "name": "@opencode-ai/protocol", + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@opencode-ai/core": "workspace:*", + "@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"], diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index e55ebbb66d37..c02595463fcd 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -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("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("FileSystem.GlobInput")({ pattern: Schema.String, diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 19daef9ae19a..af096df2fd57 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -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("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("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()("Integration.CodeRequired", { diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index d9f0ac1c2df0..21f2150c5cdd 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -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("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(data: S) { - return Schema.Struct({ location: Info, data }) -} - export class Service extends Context.Service()("@opencode/Location") {} export const layer = (ref: Ref) => diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index 4c57ef2aa02c..dd9fa5a75032 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -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({ diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 8030a1dfea9e..2a8000ada309 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -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({ diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 16e09ab57977..b0b207726406 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -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 -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 -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 diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index c625390be02f..70b853799bfd 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -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 diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index f03a69d49c2f..04907ede75ea 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -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("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> diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 000000000000..826fe3954402 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,23 @@ +{ + "$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": { + "@opencode-ai/core": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts new file mode 100644 index 000000000000..4f5615779195 --- /dev/null +++ b/packages/protocol/src/api.ts @@ -0,0 +1,56 @@ +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/schema/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" + +const makeApiFromGroup = (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) => makeApiFromGroup(makeEventGroup(definitions)) + +export const Api = makeApiFromGroup(EventGroup) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts new file mode 100644 index 000000000000..2cf1eea58319 --- /dev/null +++ b/packages/protocol/src/errors.ts @@ -0,0 +1,101 @@ +import { Schema } from "effect" + +export class InvalidRequestError extends Schema.TaggedErrorClass()( + "InvalidRequestError", + { + message: Schema.String, + kind: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + }, + { httpApiStatus: 400 }, +) {} + +export class UnauthorizedError extends Schema.TaggedErrorClass()( + "UnauthorizedError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class ConflictError extends Schema.TaggedErrorClass()( + "ConflictError", + { + message: Schema.String, + resource: Schema.optional(Schema.String), + }, + { httpApiStatus: 409 }, +) {} + +export class ServiceUnavailableError extends Schema.TaggedErrorClass()( + "ServiceUnavailableError", + { + message: Schema.String, + service: Schema.optional(Schema.String), + }, + { httpApiStatus: 503 }, +) {} + +export class UnknownError extends Schema.TaggedErrorClass()( + "UnknownError", + { + message: Schema.String, + ref: Schema.optional(Schema.String), + }, + { httpApiStatus: 500 }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProviderNotFoundError", + { + providerID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "SessionNotFoundError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class InvalidCursorError extends Schema.TaggedErrorClass()( + "InvalidCursorError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class PermissionNotFoundError extends Schema.TaggedErrorClass()( + "PermissionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "QuestionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class ForbiddenError extends Schema.TaggedErrorClass()( + "ForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class PtyNotFoundError extends Schema.TaggedErrorClass()( + "PtyNotFoundError", + { + ptyID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts new file mode 100644 index 000000000000..ba4e4059b061 --- /dev/null +++ b/packages/protocol/src/groups/agent.ts @@ -0,0 +1,22 @@ +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, LocationMiddleware } 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.", + }), + ), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/command.ts b/packages/protocol/src/groups/command.ts new file mode 100644 index 000000000000..345867757169 --- /dev/null +++ b/packages/protocol/src/groups/command.ts @@ -0,0 +1,28 @@ +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" + +export const CommandGroup = HttpApiGroup.make("server.command") + .add( + HttpApiEndpoint.get("command.list", "/api/command", { + query: LocationQuery, + success: Location.response(Schema.Array(Command.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.command.list", + summary: "List commands", + description: "Retrieve currently registered commands.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "commands", + description: "Experimental command routes.", + }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts new file mode 100644 index 000000000000..7b4761d6a869 --- /dev/null +++ b/packages/protocol/src/groups/credential.ts @@ -0,0 +1,38 @@ +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" + +export const CredentialGroup = HttpApiGroup.make("server.credential") + .add( + HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { + params: { credentialID: Credential.ID }, + query: LocationQuery, + payload: Schema.Struct({ label: Schema.String }), + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.update", + summary: "Update credential", + description: "Update a stored credential label.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { + params: { credentialID: Credential.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.remove", + summary: "Remove credential", + description: "Remove a stored integration credential.", + }), + ), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts new file mode 100644 index 000000000000..862337e54b8c --- /dev/null +++ b/packages/protocol/src/groups/event.ts @@ -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) => + 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) => { + 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) => make(definitions).group + +const event = make(EventManifest.ServerDefinitions) +export const EventGroup = event.group +export type Event = typeof event.schema.Type diff --git a/packages/protocol/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts new file mode 100644 index 000000000000..a6c94c82da7a --- /dev/null +++ b/packages/protocol/src/groups/fs.ts @@ -0,0 +1,69 @@ +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" + +const ListQuery = Schema.Struct({ + ...LocationQuery.fields, + path: RelativePath.pipe(Schema.optional), +}) + +const FindQuery = Schema.Struct({ + ...LocationQuery.fields, + query: FileSystem.FindInput.fields.query, + type: FileSystem.FindInput.fields.type, + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), +}) + +export const FileSystemGroup = HttpApiGroup.make("server.fs") + .add( + HttpApiEndpoint.get("fs.read", "/api/fs/read/*", { + query: LocationQuery, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.read", + summary: "Read file", + description: "Serve one file relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("fs.list", "/api/fs/list", { + query: ListQuery, + success: Location.response(Schema.Array(FileSystem.Entry)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.list", + summary: "List directory", + description: "List direct children of one directory relative to the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("fs.find", "/api/fs/find", { + query: FindQuery, + success: Location.response(Schema.Array(FileSystem.Entry)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.fs.find", + summary: "Find files", + description: "Find recursively ranked filesystem entries relative to the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "filesystem", + description: "Experimental location-scoped filesystem routes.", + }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts new file mode 100644 index 000000000000..18618164f04e --- /dev/null +++ b/packages/protocol/src/groups/health.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const HealthGroup = HttpApiGroup.make("server.health").add( + HttpApiEndpoint.get("health.get", "/api/health", { + success: Schema.Struct({ healthy: Schema.Literal(true) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.health.get", + summary: "Check server health", + description: "Check whether the API server is ready to accept requests.", + }), + ), +) diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts new file mode 100644 index 000000000000..201259c4df1e --- /dev/null +++ b/packages/protocol/src/groups/integration.ts @@ -0,0 +1,131 @@ +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" + +const Inputs = Schema.Record(Schema.String, Schema.String) + +export const IntegrationGroup = HttpApiGroup.make("server.integration") + .add( + HttpApiEndpoint.get("integration.list", "/api/integration", { + query: LocationQuery, + success: Location.response(Schema.Array(Integration.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.list", + summary: "List integrations", + description: "Retrieve available integrations and their authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.get", "/api/integration/:integrationID", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Integration.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.get", + summary: "Get integration", + description: "Retrieve one integration and its authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + key: Schema.String, + label: Schema.optional(Schema.String), + }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.connect.key", + summary: "Connect with key", + description: "Run a key authentication method and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.connect.oauth", "/api/integration/:integrationID/connect/oauth", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + methodID: Integration.MethodID, + inputs: Inputs, + label: Schema.optional(Schema.String), + }), + success: Location.response(Integration.Attempt), + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.connect.oauth", + summary: "Begin OAuth connection", + description: "Start an OAuth attempt and return the authorization details.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.attempt.status", "/api/integration/attempt/:attemptID", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + success: Location.response(Integration.AttemptStatus), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.status", + summary: "Get OAuth attempt status", + description: "Poll the current status of an OAuth attempt.", + }), + ), + ) + .add( + HttpApiEndpoint.post("integration.attempt.complete", "/api/integration/attempt/:attemptID/complete", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + payload: Schema.Struct({ code: Schema.optional(Schema.String) }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.complete", + summary: "Complete OAuth connection", + description: "Complete a code-based OAuth attempt and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("integration.attempt.cancel", "/api/integration/attempt/:attemptID", { + params: { attemptID: Integration.AttemptID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.attempt.cancel", + summary: "Cancel OAuth connection", + description: "Cancel an OAuth attempt and release its resources.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts new file mode 100644 index 000000000000..b99ecb5c0b5b --- /dev/null +++ b/packages/protocol/src/groups/location.ts @@ -0,0 +1,53 @@ +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import type { Layer } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, 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 type LocationServices = Layer.Success< + ReturnType<(typeof import("@opencode-ai/core/location-layer"))["LocationServiceMap"]["get"]> +> + +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@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) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts new file mode 100644 index 000000000000..85574840ce39 --- /dev/null +++ b/packages/protocol/src/groups/message.ts @@ -0,0 +1,54 @@ +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( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "SessionMessagesQuery" }) + +export const MessageGroup = HttpApiGroup.make("server.message") + .add( + HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { + params: { sessionID: Session.ID }, + query: SessionMessagesQuery, + success: Schema.Struct({ + data: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).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({ + title: "messages", + description: "Experimental message routes.", + }), + ) diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts new file mode 100644 index 000000000000..e5caa42d0509 --- /dev/null +++ b/packages/protocol/src/groups/model.ts @@ -0,0 +1,30 @@ +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" + +export const ModelGroup = HttpApiGroup.make("server.model") + .add( + HttpApiEndpoint.get("model.list", "/api/model", { + query: LocationQuery, + success: Location.response(Schema.Array(Model.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.list", + summary: "List models", + description: "Retrieve available models ordered by release date.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "models", + description: "Experimental model routes.", + }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts new file mode 100644 index 000000000000..b98ebb0173b0 --- /dev/null +++ b/packages/protocol/src/groups/permission.ts @@ -0,0 +1,86 @@ +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 { 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(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.", + }), + ), + ) + .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." })) diff --git a/packages/protocol/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts new file mode 100644 index 000000000000..8754bbf1ae90 --- /dev/null +++ b/packages/protocol/src/groups/project-copy.ts @@ -0,0 +1,57 @@ +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" + +const root = "/experimental/project/:projectID/copy" + +export class ProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( + { + name: Schema.Literal("ProjectCopyError"), + data: Schema.Struct({ + message: Schema.String, + forceRequired: Schema.optional(Schema.Boolean), + }), + }, + { httpApiStatus: 400 }, +) {} + +const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"])) +const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"])) + +export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") + .add( + HttpApiEndpoint.post("projectCopy.create", root, { + params: { projectID: Project.ID }, + query: LocationQuery, + payload: CreatePayload, + success: ProjectCopy.Copy, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })), + ) + .add( + HttpApiEndpoint.delete("projectCopy.remove", root, { + params: { projectID: Project.ID }, + query: LocationQuery, + payload: RemovePayload, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })), + ) + .add( + HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { + params: { projectID: Project.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: ProjectCopyError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), + ) + .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts new file mode 100644 index 000000000000..7da6c01030df --- /dev/null +++ b/packages/protocol/src/groups/provider.ts @@ -0,0 +1,46 @@ +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" + +export const ProviderGroup = HttpApiGroup.make("server.provider") + .add( + HttpApiEndpoint.get("provider.list", "/api/provider", { + query: LocationQuery, + success: Location.response(Schema.Array(Provider.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.list", + summary: "List providers", + description: "Retrieve active AI providers so clients can show provider availability and configuration.", + }), + ), + ) + .add( + HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", { + params: { providerID: Provider.ID }, + query: LocationQuery, + success: Location.response(Provider.Info), + error: [ProviderNotFoundError, ServiceUnavailableError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.get", + summary: "Get provider", + description: "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "providers", + description: "Experimental provider routes.", + }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts new file mode 100644 index 000000000000..1c818ece2f86 --- /dev/null +++ b/packages/protocol/src/groups/pty.ts @@ -0,0 +1,143 @@ +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" + +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ + +// Authorization middleware skips credential checks when this matches; the PTY connect handler +// is then responsible for consuming and validating the ticket. +export function hasPtyConnectTicketURL(url: URL) { + return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} + +export const PtyGroup = HttpApiGroup.make("server.pty") + .add( + HttpApiEndpoint.get("pty.list", "/api/pty", { + query: LocationQuery, + success: Location.response(Schema.Array(Pty.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.list", + summary: "List PTY sessions", + description: "List PTY sessions for a location, including exited sessions retained until removal.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.create", "/api/pty", { + query: LocationQuery, + payload: Pty.CreateInput, + success: Location.response(Pty.Info), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.create", + summary: "Create PTY session", + description: "Create a pseudo-terminal session for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.get", + summary: "Get PTY session", + description: "Get one PTY session, including its exit code once exited.", + }), + ), + ) + .add( + HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + payload: Pty.UpdateInput, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.update", + summary: "Update PTY session", + description: "Update the title or viewport size of one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.remove", + summary: "Remove PTY session", + description: "Terminate and remove one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { + params: { ptyID: Pty.ID }, + query: LocationQuery, + success: Location.response(PtyTicket.ConnectToken), + error: [ForbiddenError, PtyNotFoundError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + }), + ), + ) + .add( + // 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: Pty.ID }, + success: Schema.Boolean, + error: [ForbiddenError, PtyNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + transform: (operation) => ({ + ...operation, + parameters: [ + ...(operation.parameters ?? []), + ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + in: "query", + name, + schema: { type: "string" }, + })), + ], + }), + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts new file mode 100644 index 000000000000..88e7f8e4d842 --- /dev/null +++ b/packages/protocol/src/groups/question.ts @@ -0,0 +1,75 @@ +import { Question } from "@opencode-ai/schema/question" +import { Location } from "@opencode-ai/schema/location" +import { Session } from "@opencode-ai/schema/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(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." })) + .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." }), + ) diff --git a/packages/protocol/src/groups/reference.ts b/packages/protocol/src/groups/reference.ts new file mode 100644 index 000000000000..b95599bf28a1 --- /dev/null +++ b/packages/protocol/src/groups/reference.ts @@ -0,0 +1,28 @@ +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" + +export const ReferenceGroup = HttpApiGroup.make("server.reference") + .add( + HttpApiEndpoint.get("reference.list", "/api/reference", { + query: LocationQuery, + success: Location.response(Schema.Array(Reference.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.reference.list", + summary: "List references", + description: "List references available in the requested location.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "reference", + description: "Location-scoped project references.", + }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts new file mode 100644 index 000000000000..8a5b6003b6c9 --- /dev/null +++ b/packages/protocol/src/groups/session.ts @@ -0,0 +1,245 @@ +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 { Encoding, Result, Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { + ConflictError, + InvalidCursorError, + InvalidRequestError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../errors" +import { SessionLocationMiddleware } from "../middleware/session-location" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" + +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 = (schema: Schema.Struct) => + 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 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: Project.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(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.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.", + }), + ) diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts new file mode 100644 index 000000000000..e03e1346f95c --- /dev/null +++ b/packages/protocol/src/groups/skill.ts @@ -0,0 +1,28 @@ +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" + +export const SkillGroup = HttpApiGroup.make("server.skill") + .add( + HttpApiEndpoint.get("skill.list", "/api/skill", { + query: LocationQuery, + success: Location.response(Schema.Array(Skill.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skill.list", + summary: "List skills", + description: "Retrieve currently registered skills.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "skills", + description: "Experimental skill routes.", + }), + ) + .middleware(LocationMiddleware) diff --git a/packages/protocol/src/middleware/authorization.ts b/packages/protocol/src/middleware/authorization.ts new file mode 100644 index 000000000000..ed1c3caf6675 --- /dev/null +++ b/packages/protocol/src/middleware/authorization.ts @@ -0,0 +1,6 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { UnauthorizedError } from "../errors" + +export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { + error: UnauthorizedError, +}) {} diff --git a/packages/protocol/src/middleware/schema-error.ts b/packages/protocol/src/middleware/schema-error.ts new file mode 100644 index 000000000000..635ecec197b8 --- /dev/null +++ b/packages/protocol/src/middleware/schema-error.ts @@ -0,0 +1,7 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" + +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", + { error: InvalidRequestError }, +) {} diff --git a/packages/protocol/src/middleware/session-location.ts b/packages/protocol/src/middleware/session-location.ts new file mode 100644 index 000000000000..4083a2b83be1 --- /dev/null +++ b/packages/protocol/src/middleware/session-location.ts @@ -0,0 +1,10 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError, SessionNotFoundError } from "../errors" +import type { LocationServices } from "../groups/location" + +export class SessionLocationMiddleware extends HttpApiMiddleware.Service< + SessionLocationMiddleware, + { provides: LocationServices } +>()("@opencode/HttpApiSessionLocation", { + error: [InvalidRequestError, SessionNotFoundError], +}) {} diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts new file mode 100644 index 000000000000..60755f63a598 --- /dev/null +++ b/packages/protocol/test/session-cursor.test.ts @@ -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) + }) +}) diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 000000000000..00ef12546856 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index a4303a28f9c3..f94046885b2e 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -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 {} export const OAuth = Schema.Struct({ type: Schema.Literal("oauth"), - methodID: Integration.MethodID, + methodID: IntegrationMethodID, refresh: Schema.String, access: Schema.String, expires: NonNegativeInt, diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index 045f7a7d2ebe..7c8a96d8673f 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -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("FileSystem.FindInput")({ + query: Schema.String, + type: Schema.Literals(["file", "directory"]).pipe(Schema.optional), + limit: PositiveInt.pipe(Schema.optional), +}) {} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index afeffe069028..8808271c3f4c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,13 +9,18 @@ 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 { 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" diff --git a/packages/schema/src/integration-id.ts b/packages/schema/src/integration-id.ts new file mode 100644 index 000000000000..2590dfe9f691 --- /dev/null +++ b/packages/schema/src/integration-id.ts @@ -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 diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 78bbbe6602f7..81abfd0ae763 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -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 {} @@ -88,3 +92,37 @@ export const Ref = Schema.Struct({ id: ID, name: Schema.String, }).annotate({ identifier: "Integration.Ref" }) + +export class Info extends Schema.Class("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("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 diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index 02aa65017b2a..d6832758b00f 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -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 {} @@ -12,3 +13,16 @@ export const Ref = Schema.Struct({ Schema.withConstructorDefault(Effect.succeed(undefined)), ), }).annotate({ identifier: "Location.Ref" }) + +export class Info extends Schema.Class("Location.Info")({ + directory: AbsolutePath, + workspaceID: optionalOmitUndefined(WorkspaceID), + project: Schema.Struct({ + id: ProjectID, + directory: AbsolutePath, + }), +}) {} + +export function response(data: S) { + return Schema.Struct({ location: Info, data }) +} diff --git a/packages/schema/src/permission-saved.ts b/packages/schema/src/permission-saved.ts new file mode 100644 index 000000000000..09096c043101 --- /dev/null +++ b/packages/schema/src/permission-saved.ts @@ -0,0 +1,20 @@ +export * as PermissionSaved from "./permission-saved" + +import { Schema } from "effect" +import { ascending } from "./identifier" +import { Project } from "./project" +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: Project.ID, + action: Schema.String, + resource: Schema.String, +}).annotate({ identifier: "PermissionSaved.Info" }) +export type Info = typeof Info.Type diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts new file mode 100644 index 000000000000..1962bd3da17e --- /dev/null +++ b/packages/schema/src/project-copy.ts @@ -0,0 +1,29 @@ +export * as ProjectCopy from "./project-copy" + +import { Schema } from "effect" +import { Project } from "./project" +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: Project.ID, + 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: Project.ID, + 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 diff --git a/packages/schema/src/project-id.ts b/packages/schema/src/project-id.ts new file mode 100644 index 000000000000..c2590983a0c3 --- /dev/null +++ b/packages/schema/src/project-id.ts @@ -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 diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index dd2ae6554132..bab52f1b9e88 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -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") diff --git a/packages/schema/src/pty-ticket.ts b/packages/schema/src/pty-ticket.ts new file mode 100644 index 000000000000..4d1bdedfb8a5 --- /dev/null +++ b/packages/schema/src/pty-ticket.ts @@ -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, +}) diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts index 1836d9da4460..d3c00283cf4e 100644 --- a/packages/schema/src/pty.ts +++ b/packages/schema/src/pty.ts @@ -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: Schema.Int.check(Schema.isGreaterThan(0)), + cols: Schema.Int.check(Schema.isGreaterThan(0)), + }), + ), +}) +export type UpdateInput = typeof UpdateInput.Type diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts index 054139101685..ca80ff33f096 100644 --- a/packages/schema/src/reference.ts +++ b/packages/schema/src/reference.ts @@ -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("Reference.Info")({ + name: Schema.String, + path: AbsolutePath, + description: Schema.String.pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), + source: Source, +}) {} diff --git a/packages/schema/test/compatibility.test.ts b/packages/schema/test/compatibility.test.ts new file mode 100644 index 000000000000..fa2cf265c429 --- /dev/null +++ b/packages/schema/test/compatibility.test.ts @@ -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") + }) +}) diff --git a/packages/server/package.json b/packages/server/package.json index 478a0e98be70..3bffa03590e1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index a404d53f687d..9cbbaa652b1f 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,56 +1 @@ -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" - -const makeApiFromGroup = (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) => makeApiFromGroup(makeEventGroup(definitions)) - -export const Api = makeApiFromGroup(EventGroup) +export { Api, makeApi } from "@opencode-ai/protocol/api" diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 2cf1eea58319..77c8d1667ae4 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -1,101 +1 @@ -import { Schema } from "effect" - -export class InvalidRequestError extends Schema.TaggedErrorClass()( - "InvalidRequestError", - { - message: Schema.String, - kind: Schema.optional(Schema.String), - field: Schema.optional(Schema.String), - }, - { httpApiStatus: 400 }, -) {} - -export class UnauthorizedError extends Schema.TaggedErrorClass()( - "UnauthorizedError", - { message: Schema.String }, - { httpApiStatus: 401 }, -) {} - -export class ConflictError extends Schema.TaggedErrorClass()( - "ConflictError", - { - message: Schema.String, - resource: Schema.optional(Schema.String), - }, - { httpApiStatus: 409 }, -) {} - -export class ServiceUnavailableError extends Schema.TaggedErrorClass()( - "ServiceUnavailableError", - { - message: Schema.String, - service: Schema.optional(Schema.String), - }, - { httpApiStatus: 503 }, -) {} - -export class UnknownError extends Schema.TaggedErrorClass()( - "UnknownError", - { - message: Schema.String, - ref: Schema.optional(Schema.String), - }, - { httpApiStatus: 500 }, -) {} - -export class ProviderNotFoundError extends Schema.TaggedErrorClass()( - "ProviderNotFoundError", - { - providerID: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - -export class SessionNotFoundError extends Schema.TaggedErrorClass()( - "SessionNotFoundError", - { - sessionID: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - -export class InvalidCursorError extends Schema.TaggedErrorClass()( - "InvalidCursorError", - { message: Schema.String }, - { httpApiStatus: 400 }, -) {} - -export class PermissionNotFoundError extends Schema.TaggedErrorClass()( - "PermissionNotFoundError", - { - requestID: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - -export class QuestionNotFoundError extends Schema.TaggedErrorClass()( - "QuestionNotFoundError", - { - requestID: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - -export class ForbiddenError extends Schema.TaggedErrorClass()( - "ForbiddenError", - { message: Schema.String }, - { httpApiStatus: 403 }, -) {} - -export class PtyNotFoundError extends Schema.TaggedErrorClass()( - "PtyNotFoundError", - { - ptyID: Schema.String, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} +export * from "@opencode-ai/protocol/errors" diff --git a/packages/server/src/groups/agent.ts b/packages/server/src/groups/agent.ts index c9dd5398c571..ccb0bbc1d2d9 100644 --- a/packages/server/src/groups/agent.ts +++ b/packages/server/src/groups/agent.ts @@ -1,22 +1 @@ -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) +export * from "@opencode-ai/protocol/groups/agent" diff --git a/packages/server/src/groups/command.ts b/packages/server/src/groups/command.ts index 581dd19e0300..3737f21b3a5a 100644 --- a/packages/server/src/groups/command.ts +++ b/packages/server/src/groups/command.ts @@ -1,28 +1 @@ -import { CommandV2 } from "@opencode-ai/core/command" -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 CommandGroup = HttpApiGroup.make("server.command") - .add( - HttpApiEndpoint.get("command.list", "/api/command", { - query: LocationQuery, - success: Location.response(Schema.Array(CommandV2.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.command.list", - summary: "List commands", - description: "Retrieve currently registered commands.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "commands", - description: "Experimental command routes.", - }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/command" diff --git a/packages/server/src/groups/credential.ts b/packages/server/src/groups/credential.ts index b6e21ca30b7c..d546c0ca5607 100644 --- a/packages/server/src/groups/credential.ts +++ b/packages/server/src/groups/credential.ts @@ -1,38 +1 @@ -import { Credential } from "@opencode-ai/core/credential" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" - -export const CredentialGroup = HttpApiGroup.make("server.credential") - .add( - HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { - params: { credentialID: Credential.ID }, - query: LocationQuery, - payload: Schema.Struct({ label: Schema.String }), - success: HttpApiSchema.NoContent, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.credential.update", - summary: "Update credential", - description: "Update a stored credential label.", - }), - ), - ) - .add( - HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { - params: { credentialID: Credential.ID }, - query: LocationQuery, - success: HttpApiSchema.NoContent, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.credential.remove", - summary: "Remove credential", - description: "Remove a stored integration credential.", - }), - ), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/credential" diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts index 7e8c02902047..a8c1163648d5 100644 --- a/packages/server/src/groups/event.ts +++ b/packages/server/src/groups/event.ts @@ -1,65 +1 @@ -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 = >(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 = >(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 +export * from "@opencode-ai/protocol/groups/event" diff --git a/packages/server/src/groups/fs.ts b/packages/server/src/groups/fs.ts index 96ce404619ff..ecd68e52da13 100644 --- a/packages/server/src/groups/fs.ts +++ b/packages/server/src/groups/fs.ts @@ -1,69 +1 @@ -import { FileSystem } from "@opencode-ai/core/filesystem" -import { Location } from "@opencode-ai/core/location" -import { PositiveInt, RelativePath } from "@opencode-ai/core/schema" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -const ListQuery = Schema.Struct({ - ...LocationQuery.fields, - path: RelativePath.pipe(Schema.optional), -}) - -const FindQuery = Schema.Struct({ - ...LocationQuery.fields, - query: FileSystem.FindInput.fields.query, - type: FileSystem.FindInput.fields.type, - limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), -}) - -export const FileSystemGroup = HttpApiGroup.make("server.fs") - .add( - HttpApiEndpoint.get("fs.read", "/api/fs/read/*", { - query: LocationQuery, - success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.fs.read", - summary: "Read file", - description: "Serve one file relative to the requested location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("fs.list", "/api/fs/list", { - query: ListQuery, - success: Location.response(Schema.Array(FileSystem.Entry)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.fs.list", - summary: "List directory", - description: "List direct children of one directory relative to the requested location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("fs.find", "/api/fs/find", { - query: FindQuery, - success: Location.response(Schema.Array(FileSystem.Entry)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.fs.find", - summary: "Find files", - description: "Find recursively ranked filesystem entries relative to the requested location.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "filesystem", - description: "Experimental location-scoped filesystem routes.", - }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/fs" diff --git a/packages/server/src/groups/health.ts b/packages/server/src/groups/health.ts index 18618164f04e..42025741c4d4 100644 --- a/packages/server/src/groups/health.ts +++ b/packages/server/src/groups/health.ts @@ -1,14 +1 @@ -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -export const HealthGroup = HttpApiGroup.make("server.health").add( - HttpApiEndpoint.get("health.get", "/api/health", { - success: Schema.Struct({ healthy: Schema.Literal(true) }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.health.get", - summary: "Check server health", - description: "Check whether the API server is ready to accept requests.", - }), - ), -) +export * from "@opencode-ai/protocol/groups/health" diff --git a/packages/server/src/groups/integration.ts b/packages/server/src/groups/integration.ts index 30e82ace6e1e..db7df00bae01 100644 --- a/packages/server/src/groups/integration.ts +++ b/packages/server/src/groups/integration.ts @@ -1,131 +1 @@ -import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { InvalidRequestError } from "../errors" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" - -const Inputs = Schema.Record(Schema.String, Schema.String) - -export const IntegrationGroup = HttpApiGroup.make("server.integration") - .add( - HttpApiEndpoint.get("integration.list", "/api/integration", { - query: LocationQuery, - success: Location.response(Schema.Array(Integration.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.list", - summary: "List integrations", - description: "Retrieve available integrations and their authentication methods.", - }), - ), - ) - .add( - HttpApiEndpoint.get("integration.get", "/api/integration/:integrationID", { - params: { integrationID: Integration.ID }, - query: LocationQuery, - success: Location.response(Schema.UndefinedOr(Integration.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.get", - summary: "Get integration", - description: "Retrieve one integration and its authentication methods.", - }), - ), - ) - .add( - HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { - params: { integrationID: Integration.ID }, - query: LocationQuery, - payload: Schema.Struct({ - key: Schema.String, - label: Schema.optional(Schema.String), - }), - success: HttpApiSchema.NoContent, - error: InvalidRequestError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.connect.key", - summary: "Connect with key", - description: "Run a key authentication method and store the resulting credential.", - }), - ), - ) - .add( - HttpApiEndpoint.post("integration.connect.oauth", "/api/integration/:integrationID/connect/oauth", { - params: { integrationID: Integration.ID }, - query: LocationQuery, - payload: Schema.Struct({ - methodID: Integration.MethodID, - inputs: Inputs, - label: Schema.optional(Schema.String), - }), - success: Location.response(Integration.Attempt), - error: InvalidRequestError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.connect.oauth", - summary: "Begin OAuth connection", - description: "Start an OAuth attempt and return the authorization details.", - }), - ), - ) - .add( - HttpApiEndpoint.get("integration.attempt.status", "/api/integration/attempt/:attemptID", { - params: { attemptID: Integration.AttemptID }, - query: LocationQuery, - success: Location.response(Integration.AttemptStatus), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.attempt.status", - summary: "Get OAuth attempt status", - description: "Poll the current status of an OAuth attempt.", - }), - ), - ) - .add( - HttpApiEndpoint.post("integration.attempt.complete", "/api/integration/attempt/:attemptID/complete", { - params: { attemptID: Integration.AttemptID }, - query: LocationQuery, - payload: Schema.Struct({ code: Schema.optional(Schema.String) }), - success: HttpApiSchema.NoContent, - error: InvalidRequestError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.attempt.complete", - summary: "Complete OAuth connection", - description: "Complete a code-based OAuth attempt and store the resulting credential.", - }), - ), - ) - .add( - HttpApiEndpoint.delete("integration.attempt.cancel", "/api/integration/attempt/:attemptID", { - params: { attemptID: Integration.AttemptID }, - query: LocationQuery, - success: HttpApiSchema.NoContent, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.integration.attempt.cancel", - summary: "Cancel OAuth connection", - description: "Cancel an OAuth attempt and release its resources.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/integration" diff --git a/packages/server/src/groups/location.ts b/packages/server/src/groups/location.ts index 6979c6703f34..c5fbfbe3f18e 100644 --- a/packages/server/src/groups/location.ts +++ b/packages/server/src/groups/location.ts @@ -1,35 +1,17 @@ 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" - -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, - ), - } - }, -}) +import { LocationMiddleware } from "@opencode-ai/protocol/groups/location" +export { + LocationGroup, + LocationMiddleware, + LocationQuery, + locationQueryOpenApi, +} from "@opencode-ai/protocol/groups/location" +export type { LocationServices } from "@opencode-ai/protocol/groups/location" export function response(data: Effect.Effect) { return Effect.gen(function* () { @@ -45,32 +27,6 @@ export function response(data: Effect.Effect) { }) } -export type LocationServices = Layer.Success> - -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"] diff --git a/packages/server/src/groups/message.ts b/packages/server/src/groups/message.ts index b8a6e19ab647..18d04e778c31 100644 --- a/packages/server/src/groups/message.ts +++ b/packages/server/src/groups/message.ts @@ -1,54 +1 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { SessionMessage } from "@opencode-ai/core/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( - Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), - ).annotate({ - description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional( - Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - ), -}).annotate({ identifier: "SessionMessagesQuery" }) - -export const MessageGroup = HttpApiGroup.make("server.message") - .add( - HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { - params: { sessionID: SessionV2.ID }, - query: SessionMessagesQuery, - success: Schema.Struct({ - data: Schema.Array(SessionMessage.Message), - cursor: Schema.Struct({ - previous: Schema.String.pipe(Schema.optional), - next: Schema.String.pipe(Schema.optional), - }), - }).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({ - title: "messages", - description: "Experimental message routes.", - }), - ) +export * from "@opencode-ai/protocol/groups/message" diff --git a/packages/server/src/groups/model.ts b/packages/server/src/groups/model.ts index 3964ae4789ee..f49feffa23e4 100644 --- a/packages/server/src/groups/model.ts +++ b/packages/server/src/groups/model.ts @@ -1,30 +1 @@ -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { ServiceUnavailableError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } 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)), - error: ServiceUnavailableError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.model.list", - summary: "List models", - description: "Retrieve available models ordered by release date.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "models", - description: "Experimental model routes.", - }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/model" diff --git a/packages/server/src/groups/permission.ts b/packages/server/src/groups/permission.ts index a5b9e89a960a..3527e4222b6c 100644 --- a/packages/server/src/groups/permission.ts +++ b/packages/server/src/groups/permission.ts @@ -1,86 +1 @@ -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." })) +export * from "@opencode-ai/protocol/groups/permission" diff --git a/packages/server/src/groups/project-copy.ts b/packages/server/src/groups/project-copy.ts index e9b5e8f6748d..398e6ecca1fc 100644 --- a/packages/server/src/groups/project-copy.ts +++ b/packages/server/src/groups/project-copy.ts @@ -1,57 +1 @@ -import { ProjectCopy } from "@opencode-ai/core/project/copy" -import { ProjectV2 } from "@opencode-ai/core/project" -import { Schema, Struct } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" - -const root = "/experimental/project/:projectID/copy" - -export class ProjectCopyError extends Schema.ErrorClass("ProjectCopyError")( - { - name: Schema.Literal("ProjectCopyError"), - data: Schema.Struct({ - message: Schema.String, - forceRequired: Schema.optional(Schema.Boolean), - }), - }, - { httpApiStatus: 400 }, -) {} - -const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"])) -const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"])) - -export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") - .add( - HttpApiEndpoint.post("projectCopy.create", root, { - params: { projectID: ProjectV2.ID }, - query: LocationQuery, - payload: CreatePayload, - success: ProjectCopy.Copy, - error: ProjectCopyError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })), - ) - .add( - HttpApiEndpoint.delete("projectCopy.remove", root, { - params: { projectID: ProjectV2.ID }, - query: LocationQuery, - payload: RemovePayload, - success: HttpApiSchema.NoContent, - error: ProjectCopyError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })), - ) - .add( - HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { - params: { projectID: ProjectV2.ID }, - query: LocationQuery, - success: HttpApiSchema.NoContent, - error: ProjectCopyError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), - ) - .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/project-copy" diff --git a/packages/server/src/groups/provider.ts b/packages/server/src/groups/provider.ts index 4b861ccc9e83..ea2041c33c21 100644 --- a/packages/server/src/groups/provider.ts +++ b/packages/server/src/groups/provider.ts @@ -1,46 +1 @@ -import { ProviderV2 } from "@opencode-ai/core/provider" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } 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)), - error: ServiceUnavailableError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.provider.list", - summary: "List providers", - description: "Retrieve active AI providers so clients can show provider availability and configuration.", - }), - ), - ) - .add( - HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", { - params: { providerID: ProviderV2.ID }, - query: LocationQuery, - success: Location.response(ProviderV2.Info), - error: [ProviderNotFoundError, ServiceUnavailableError], - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.provider.get", - summary: "Get provider", - description: "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "providers", - description: "Experimental provider routes.", - }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/provider" diff --git a/packages/server/src/groups/pty.ts b/packages/server/src/groups/pty.ts index b5bbc7bf5a83..8fae40eebbab 100644 --- a/packages/server/src/groups/pty.ts +++ b/packages/server/src/groups/pty.ts @@ -1,144 +1 @@ -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 { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { ForbiddenError, PtyNotFoundError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const PTY_CONNECT_TICKET_QUERY = "ticket" -export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" -export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" - -const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ - -// Authorization middleware skips credential checks when this matches; the PTY connect handler -// is then responsible for consuming and validating the ticket. -export function hasPtyConnectTicketURL(url: URL) { - return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) -} - -export const PtyGroup = HttpApiGroup.make("server.pty") - .add( - HttpApiEndpoint.get("pty.list", "/api/pty", { - query: LocationQuery, - success: Location.response(Schema.Array(Pty.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.list", - summary: "List PTY sessions", - description: "List PTY sessions for a location, including exited sessions retained until removal.", - }), - ), - ) - .add( - HttpApiEndpoint.post("pty.create", "/api/pty", { - query: LocationQuery, - payload: Pty.CreateInput, - success: Location.response(Pty.Info), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.create", - summary: "Create PTY session", - description: "Create a pseudo-terminal session for a location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, - query: LocationQuery, - success: Location.response(Pty.Info), - error: PtyNotFoundError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.get", - summary: "Get PTY session", - description: "Get one PTY session, including its exit code once exited.", - }), - ), - ) - .add( - HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, - query: LocationQuery, - payload: Pty.UpdateInput, - success: Location.response(Pty.Info), - error: PtyNotFoundError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.update", - summary: "Update PTY session", - description: "Update the title or viewport size of one PTY session.", - }), - ), - ) - .add( - HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, - query: LocationQuery, - success: HttpApiSchema.NoContent, - error: PtyNotFoundError, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.remove", - summary: "Remove PTY session", - description: "Terminate and remove one PTY session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { - params: { ptyID: PtyID }, - query: LocationQuery, - success: Location.response(PtyTicket.ConnectToken), - error: [ForbiddenError, PtyNotFoundError], - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.connectToken", - summary: "Create PTY WebSocket token", - description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", - }), - ), - ) - .add( - // 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 }, - success: Schema.Boolean, - error: [ForbiddenError, PtyNotFoundError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.pty.connect", - summary: "Connect to PTY session", - description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.", - transform: (operation) => ({ - ...operation, - parameters: [ - ...(operation.parameters ?? []), - ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ - in: "query", - name, - schema: { type: "string" }, - })), - ], - }), - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/pty" diff --git a/packages/server/src/groups/question.ts b/packages/server/src/groups/question.ts index cb8932129e7b..e47189eea042 100644 --- a/packages/server/src/groups/question.ts +++ b/packages/server/src/groups/question.ts @@ -1,75 +1 @@ -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." }), - ) +export * from "@opencode-ai/protocol/groups/question" diff --git a/packages/server/src/groups/reference.ts b/packages/server/src/groups/reference.ts index f27a934db2ea..a718d2802884 100644 --- a/packages/server/src/groups/reference.ts +++ b/packages/server/src/groups/reference.ts @@ -1,28 +1 @@ -import { Location } from "@opencode-ai/core/location" -import { Reference } from "@opencode-ai/core/reference" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" - -export const ReferenceGroup = HttpApiGroup.make("server.reference") - .add( - HttpApiEndpoint.get("reference.list", "/api/reference", { - query: LocationQuery, - success: Location.response(Schema.Array(Reference.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.reference.list", - summary: "List references", - description: "List references available in the requested location.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "reference", - description: "Location-scoped project references.", - }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/reference" diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index 6a4f4aff609c..6509e3af05bf 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -1,246 +1 @@ -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, - 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 = (schema: Schema.Struct) => - schema.mapFields((fields) => ({ - ...Struct.omit(fields, ["limit"]), - anchor: SessionV2.ListAnchor, - })) - -const SessionsCursorInput = Schema.Union([ - withCursor(SessionsDirectoryQuery), - withCursor(SessionsProjectQuery), - withCursor(SessionsAllQuery), -]) -const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) -const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) -const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) - -export const SessionsCursor = Schema.String.pipe( - Schema.brand("SessionsCursor"), - withStatics((schema) => { - 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.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.", - }), - ) +export * from "@opencode-ai/protocol/groups/session" diff --git a/packages/server/src/groups/skill.ts b/packages/server/src/groups/skill.ts index a43cb83c525e..bd5521d0f056 100644 --- a/packages/server/src/groups/skill.ts +++ b/packages/server/src/groups/skill.ts @@ -1,28 +1 @@ -import { SkillV2 } from "@opencode-ai/core/skill" -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 SkillGroup = HttpApiGroup.make("server.skill") - .add( - HttpApiEndpoint.get("skill.list", "/api/skill", { - query: LocationQuery, - success: Location.response(Schema.Array(SkillV2.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.skill.list", - summary: "List skills", - description: "Retrieve currently registered skills.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "skills", - description: "Experimental skill routes.", - }), - ) - .middleware(LocationMiddleware) +export * from "@opencode-ai/protocol/groups/skill" diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index 3be2c9d5ea9d..e579b7663a1c 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -1,7 +1,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" import { response } from "../groups/location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => diff --git a/packages/server/src/handlers/command.ts b/packages/server/src/handlers/command.ts index b09d52bee930..efbb5fdd5b85 100644 --- a/packages/server/src/handlers/command.ts +++ b/packages/server/src/handlers/command.ts @@ -1,7 +1,6 @@ import { CommandV2 } from "@opencode-ai/core/command" -import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" import { response } from "../groups/location" export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) => diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 7e138a5d5a20..f01175c3719c 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -1,7 +1,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) => handlers diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 8001fb874812..278fdcc51fb6 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -3,7 +3,7 @@ import { Effect, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" function eventData(data: unknown): Sse.Event { return { diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index 963bf51d852f..c3d644a3bf2e 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -3,7 +3,7 @@ import { RelativePath } from "@opencode-ai/core/schema" import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" import { response } from "../groups/location" export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 60000b3fc009..91650d7fc998 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })), diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index d7c651e847a6..99d8a20980fa 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -1,8 +1,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 { Api } from "@opencode-ai/protocol/api" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" import { response } from "../groups/location" const authorize = (effect: Effect.Effect) => diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index ded8c8c2e0ac..8a4a32115350 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,7 +1,7 @@ import { Location } from "@opencode-ai/core/location" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => handlers.handle( diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index eb5758c2472c..fac7dae0e470 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -2,8 +2,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message" 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 { Api } from "@opencode-ai/protocol/api" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" const DefaultMessagesLimit = 50 diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 542717351b9a..48de94554294 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,7 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" import { response } from "../groups/location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 34fa3d428e5e..3c47746b737d 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -3,8 +3,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission" 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 { Api } from "@opencode-ai/protocol/api" +import { PermissionNotFoundError } from "@opencode-ai/protocol/errors" import { response } from "../groups/location" function missingRequest(id: PermissionV2.ID) { diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts index 91b48fb1d578..f1bed194855c 100644 --- a/packages/server/src/handlers/project-copy.ts +++ b/packages/server/src/handlers/project-copy.ts @@ -3,8 +3,8 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy" 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 { Api } from "@opencode-ai/protocol/api" +import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy" export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => Effect.succeed( diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 4d84179ed36c..5f64e91809cd 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,9 +1,8 @@ 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 { Api } from "@opencode-ai/protocol/api" +import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" import { response } from "../groups/location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index a59afb3b3160..bcb2704a5e7e 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -6,10 +6,14 @@ import { Effect, Queue } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/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 { 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 "../groups/location" import { PtyEnvironment } from "../pty-environment" diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 151557c508d0..3fe7c24cfeb8 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -1,8 +1,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 { Api } from "@opencode-ai/protocol/api" +import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" import { response } from "../groups/location" function missingRequest(id: QuestionV2.ID) { diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts index 894f76aa8e66..aebafdf0584c 100644 --- a/packages/server/src/handlers/reference.ts +++ b/packages/server/src/handlers/reference.ts @@ -1,6 +1,6 @@ import { Reference } from "@opencode-ai/core/reference" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" import { response } from "../groups/location" export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) => diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 1fe860e5284e..6116c7b92867 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,15 +1,15 @@ 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 { Api } from "@opencode-ai/protocol/api" +import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError, -} from "../errors" +} from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index cf3f92c2a855..84be822553a0 100644 --- a/packages/server/src/handlers/skill.ts +++ b/packages/server/src/handlers/skill.ts @@ -1,6 +1,6 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "../api" +import { Api } from "@opencode-ai/protocol/api" import { response } from "../groups/location" export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) => diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index 5a8dae205db0..cc785ee2472a 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -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()("@opencode/HttpApiAuthorization", { - error: UnauthorizedError, -}) {} - function emptyCredential() { return { username: "", password: Redacted.make("") } } diff --git a/packages/server/src/middleware/schema-error.ts b/packages/server/src/middleware/schema-error.ts index 8061a524bde0..37013285b9d7 100644 --- a/packages/server/src/middleware/schema-error.ts +++ b/packages/server/src/middleware/schema-error.ts @@ -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()( - "@opencode/HttpApiSchemaError", - { error: InvalidRequestError }, -) {} - export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { const reason = truncateReason(error.cause.message) return Effect.logWarning("schema rejection").pipe( diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index 7306cf76b86e..6e8e6b9ab105 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -8,18 +8,9 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" 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" - -export class SessionLocationMiddleware extends HttpApiMiddleware.Service< - SessionLocationMiddleware, - { - provides: LocationServices - } ->()("@opencode/HttpApiSessionLocation", { - error: [InvalidRequestError, SessionNotFoundError], -}) {} +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { SessionLocationMiddleware } from "@opencode-ai/protocol/middleware/session-location" +export { SessionLocationMiddleware } from "@opencode-ai/protocol/middleware/session-location" const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index da5cda139918..481ae84fd857 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -4,7 +4,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" -import { Api } from "./api" +import { Api } from "@opencode-ai/protocol/api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" From 4aa626d546acdc440252e0d843badf12cc75976a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 24 Jun 2026 20:53:01 -0400 Subject: [PATCH 2/5] refactor(protocol): remove server contract shims --- bun.lock | 1 + packages/opencode/package.json | 1 + .../opencode/src/server/routes/instance/httpapi/api.ts | 2 +- .../src/server/routes/instance/httpapi/server.ts | 2 +- .../test/server/httpapi-query-schema-drift.test.ts | 2 +- packages/protocol/src/groups/session.ts | 9 +++------ packages/schema/src/permission-saved.ts | 4 ++-- packages/schema/src/project-copy.ts | 6 +++--- packages/schema/src/pty.ts | 6 +++--- packages/server/src/api.ts | 1 - packages/server/src/errors.ts | 1 - packages/server/src/groups/agent.ts | 1 - packages/server/src/groups/command.ts | 1 - packages/server/src/groups/credential.ts | 1 - packages/server/src/groups/event.ts | 1 - packages/server/src/groups/fs.ts | 1 - packages/server/src/groups/health.ts | 1 - packages/server/src/groups/integration.ts | 1 - packages/server/src/groups/message.ts | 1 - packages/server/src/groups/model.ts | 1 - packages/server/src/groups/permission.ts | 1 - packages/server/src/groups/project-copy.ts | 1 - packages/server/src/groups/provider.ts | 1 - packages/server/src/groups/pty.ts | 1 - packages/server/src/groups/question.ts | 1 - packages/server/src/groups/reference.ts | 1 - packages/server/src/groups/session.ts | 1 - packages/server/src/groups/skill.ts | 1 - packages/server/src/handlers.ts | 2 +- packages/server/src/handlers/agent.ts | 2 +- packages/server/src/handlers/command.ts | 2 +- packages/server/src/handlers/fs.ts | 2 +- packages/server/src/handlers/integration.ts | 2 +- packages/server/src/handlers/model.ts | 2 +- packages/server/src/handlers/permission.ts | 2 +- packages/server/src/handlers/provider.ts | 2 +- packages/server/src/handlers/pty.ts | 2 +- packages/server/src/handlers/question.ts | 2 +- packages/server/src/handlers/reference.ts | 2 +- packages/server/src/handlers/skill.ts | 2 +- packages/server/src/{groups => }/location.ts | 9 +-------- 41 files changed, 29 insertions(+), 56 deletions(-) delete mode 100644 packages/server/src/api.ts delete mode 100644 packages/server/src/errors.ts delete mode 100644 packages/server/src/groups/agent.ts delete mode 100644 packages/server/src/groups/command.ts delete mode 100644 packages/server/src/groups/credential.ts delete mode 100644 packages/server/src/groups/event.ts delete mode 100644 packages/server/src/groups/fs.ts delete mode 100644 packages/server/src/groups/health.ts delete mode 100644 packages/server/src/groups/integration.ts delete mode 100644 packages/server/src/groups/message.ts delete mode 100644 packages/server/src/groups/model.ts delete mode 100644 packages/server/src/groups/permission.ts delete mode 100644 packages/server/src/groups/project-copy.ts delete mode 100644 packages/server/src/groups/provider.ts delete mode 100644 packages/server/src/groups/pty.ts delete mode 100644 packages/server/src/groups/question.ts delete mode 100644 packages/server/src/groups/reference.ts delete mode 100644 packages/server/src/groups/session.ts delete mode 100644 packages/server/src/groups/skill.ts rename packages/server/src/{groups => }/location.ts (89%) diff --git a/bun.lock b/bun.lock index 321ce0306c5d..ca3079c621e9 100644 --- a/bun.lock +++ b/bun.lock @@ -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:*", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 748786f08a5e..281fe449aa2e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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:*", diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 7cf59ec2371e..045459a43686 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -25,7 +25,7 @@ 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 { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b74df8deb83a..49bf4f18609b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -66,7 +66,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/ import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" -import { Api } from "@opencode-ai/server/api" +import { Api } from "@opencode-ai/protocol/api" import { PublicApi } from "./public" import { authorizationLayer, diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 1871521749a2..f5411767ed1f 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -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" diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 8a5b6003b6c9..ee43cf78a10a 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -71,11 +71,8 @@ export const SessionsCursor = Schema.String.pipe( ) 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, +const SessionsQueryCursor = SessionsCursor.annotate({ + description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", }) export const SessionsQuery = Schema.Struct({ @@ -83,7 +80,7 @@ export const SessionsQuery = Schema.Struct({ directory: AbsolutePath.pipe(Schema.optional), project: Project.ID.pipe(Schema.optional), subpath: RelativePath.pipe(Schema.optional), - cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional), + cursor: SessionsQueryCursor.pipe(Schema.optional), }).annotate({ identifier: "SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("server.session") diff --git a/packages/schema/src/permission-saved.ts b/packages/schema/src/permission-saved.ts index 09096c043101..927a493069e1 100644 --- a/packages/schema/src/permission-saved.ts +++ b/packages/schema/src/permission-saved.ts @@ -2,7 +2,7 @@ export * as PermissionSaved from "./permission-saved" import { Schema } from "effect" import { ascending } from "./identifier" -import { Project } from "./project" +import { ProjectID } from "./project-id" import { withStatics } from "./schema" export const ID = Schema.String.pipe( @@ -13,7 +13,7 @@ export type ID = typeof ID.Type export const Info = Schema.Struct({ id: ID, - projectID: Project.ID, + projectID: ProjectID, action: Schema.String, resource: Schema.String, }).annotate({ identifier: "PermissionSaved.Info" }) diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts index 1962bd3da17e..d90c323d5961 100644 --- a/packages/schema/src/project-copy.ts +++ b/packages/schema/src/project-copy.ts @@ -1,14 +1,14 @@ export * as ProjectCopy from "./project-copy" import { Schema } from "effect" -import { Project } from "./project" +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: Project.ID, + projectID: ProjectID, strategy: StrategyID, sourceDirectory: AbsolutePath, directory: AbsolutePath, @@ -17,7 +17,7 @@ export const CreateInput = Schema.Struct({ export type CreateInput = typeof CreateInput.Type export const RemoveInput = Schema.Struct({ - projectID: Project.ID, + projectID: ProjectID, directory: AbsolutePath, force: Schema.Boolean, }).annotate({ identifier: "ProjectCopy.RemoveInput" }) diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts index d3c00283cf4e..026f49bd363e 100644 --- a/packages/schema/src/pty.ts +++ b/packages/schema/src/pty.ts @@ -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")) @@ -47,8 +47,8 @@ export const UpdateInput = Schema.Struct({ title: Schema.optional(Schema.String), size: Schema.optional( Schema.Struct({ - rows: Schema.Int.check(Schema.isGreaterThan(0)), - cols: Schema.Int.check(Schema.isGreaterThan(0)), + rows: PositiveInt, + cols: PositiveInt, }), ), }) diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts deleted file mode 100644 index 9cbbaa652b1f..000000000000 --- a/packages/server/src/api.ts +++ /dev/null @@ -1 +0,0 @@ -export { Api, makeApi } from "@opencode-ai/protocol/api" diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts deleted file mode 100644 index 77c8d1667ae4..000000000000 --- a/packages/server/src/errors.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/errors" diff --git a/packages/server/src/groups/agent.ts b/packages/server/src/groups/agent.ts deleted file mode 100644 index ccb0bbc1d2d9..000000000000 --- a/packages/server/src/groups/agent.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/agent" diff --git a/packages/server/src/groups/command.ts b/packages/server/src/groups/command.ts deleted file mode 100644 index 3737f21b3a5a..000000000000 --- a/packages/server/src/groups/command.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/command" diff --git a/packages/server/src/groups/credential.ts b/packages/server/src/groups/credential.ts deleted file mode 100644 index d546c0ca5607..000000000000 --- a/packages/server/src/groups/credential.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/credential" diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts deleted file mode 100644 index a8c1163648d5..000000000000 --- a/packages/server/src/groups/event.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/event" diff --git a/packages/server/src/groups/fs.ts b/packages/server/src/groups/fs.ts deleted file mode 100644 index ecd68e52da13..000000000000 --- a/packages/server/src/groups/fs.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/fs" diff --git a/packages/server/src/groups/health.ts b/packages/server/src/groups/health.ts deleted file mode 100644 index 42025741c4d4..000000000000 --- a/packages/server/src/groups/health.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/health" diff --git a/packages/server/src/groups/integration.ts b/packages/server/src/groups/integration.ts deleted file mode 100644 index db7df00bae01..000000000000 --- a/packages/server/src/groups/integration.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/integration" diff --git a/packages/server/src/groups/message.ts b/packages/server/src/groups/message.ts deleted file mode 100644 index 18d04e778c31..000000000000 --- a/packages/server/src/groups/message.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/message" diff --git a/packages/server/src/groups/model.ts b/packages/server/src/groups/model.ts deleted file mode 100644 index f49feffa23e4..000000000000 --- a/packages/server/src/groups/model.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/model" diff --git a/packages/server/src/groups/permission.ts b/packages/server/src/groups/permission.ts deleted file mode 100644 index 3527e4222b6c..000000000000 --- a/packages/server/src/groups/permission.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/permission" diff --git a/packages/server/src/groups/project-copy.ts b/packages/server/src/groups/project-copy.ts deleted file mode 100644 index 398e6ecca1fc..000000000000 --- a/packages/server/src/groups/project-copy.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/project-copy" diff --git a/packages/server/src/groups/provider.ts b/packages/server/src/groups/provider.ts deleted file mode 100644 index ea2041c33c21..000000000000 --- a/packages/server/src/groups/provider.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/provider" diff --git a/packages/server/src/groups/pty.ts b/packages/server/src/groups/pty.ts deleted file mode 100644 index 8fae40eebbab..000000000000 --- a/packages/server/src/groups/pty.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/pty" diff --git a/packages/server/src/groups/question.ts b/packages/server/src/groups/question.ts deleted file mode 100644 index e47189eea042..000000000000 --- a/packages/server/src/groups/question.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/question" diff --git a/packages/server/src/groups/reference.ts b/packages/server/src/groups/reference.ts deleted file mode 100644 index a718d2802884..000000000000 --- a/packages/server/src/groups/reference.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/reference" diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts deleted file mode 100644 index 6509e3af05bf..000000000000 --- a/packages/server/src/groups/session.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/session" diff --git a/packages/server/src/groups/skill.ts b/packages/server/src/groups/skill.ts deleted file mode 100644 index bd5521d0f056..000000000000 --- a/packages/server/src/groups/skill.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@opencode-ai/protocol/groups/skill" diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index d26c1618f0d6..f12e9b625faf 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -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" diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index e579b7663a1c..e941d2c7e364 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -2,7 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" -import { response } from "../groups/location" +import { response } from "../location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => handlers.handle("agent.list", () => diff --git a/packages/server/src/handlers/command.ts b/packages/server/src/handlers/command.ts index efbb5fdd5b85..0967333e0e05 100644 --- a/packages/server/src/handlers/command.ts +++ b/packages/server/src/handlers/command.ts @@ -1,7 +1,7 @@ import { CommandV2 } from "@opencode-ai/core/command" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/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()))), diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index c3d644a3bf2e..ba20f54b4b4a 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" -import { response } from "../groups/location" +import { response } from "../location" export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => Effect.gen(function* () { diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 99d8a20980fa..2388c045efa8 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" import { InvalidRequestError } from "@opencode-ai/protocol/errors" -import { response } from "../groups/location" +import { response } from "../location" const authorize = (effect: Effect.Effect) => effect.pipe( diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 48de94554294..20d72b76114a 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" -import { response } from "../groups/location" +import { response } from "../location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 3c47746b737d..c6e74190b2cf 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -5,7 +5,7 @@ import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" import { PermissionNotFoundError } from "@opencode-ai/protocol/errors" -import { response } from "../groups/location" +import { response } from "../location" function missingRequest(id: PermissionV2.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 5f64e91809cd..da12aeb4fd58 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" -import { response } from "../groups/location" +import { response } from "../location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => Effect.gen(function* () { diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index bcb2704a5e7e..92ada50f942f 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -14,7 +14,7 @@ import { PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, } from "@opencode-ai/protocol/groups/pty" -import { response } from "../groups/location" +import { response } from "../location" import { PtyEnvironment } from "../pty-environment" const ticketScope = Effect.gen(function* () { diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 3fe7c24cfeb8..af57983c3aa1 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/api" import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" -import { response } from "../groups/location" +import { response } from "../location" function missingRequest(id: QuestionV2.ID) { return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` }) diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts index aebafdf0584c..6084f0628623 100644 --- a/packages/server/src/handlers/reference.ts +++ b/packages/server/src/handlers/reference.ts @@ -1,7 +1,7 @@ import { Reference } from "@opencode-ai/core/reference" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/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()))), diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index 84be822553a0..ab76ba9e90b5 100644 --- a/packages/server/src/handlers/skill.ts +++ b/packages/server/src/handlers/skill.ts @@ -1,7 +1,7 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "@opencode-ai/protocol/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()))), diff --git a/packages/server/src/groups/location.ts b/packages/server/src/location.ts similarity index 89% rename from packages/server/src/groups/location.ts rename to packages/server/src/location.ts index c5fbfbe3f18e..722ff19f1d9a 100644 --- a/packages/server/src/groups/location.ts +++ b/packages/server/src/location.ts @@ -2,16 +2,9 @@ import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { LocationMiddleware } from "@opencode-ai/protocol/groups/location" import { Effect, Layer } from "effect" import { HttpServerRequest } from "effect/unstable/http" -import { LocationMiddleware } from "@opencode-ai/protocol/groups/location" -export { - LocationGroup, - LocationMiddleware, - LocationQuery, - locationQueryOpenApi, -} from "@opencode-ai/protocol/groups/location" -export type { LocationServices } from "@opencode-ai/protocol/groups/location" export function response(data: Effect.Effect) { return Effect.gen(function* () { From 573d7e3a3260785a331c81482f60dc1fabce1188 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 24 Jun 2026 20:54:16 -0400 Subject: [PATCH 3/5] fix(protocol): resolve session contract merge --- packages/protocol/src/groups/session.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index f82bf95dfe34..22a140b65c54 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -20,6 +20,7 @@ import { SessionLocationMiddleware } from "../middleware/session-location" 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), @@ -222,9 +223,9 @@ export const SessionGroup = HttpApiGroup.make("server.session") ) .add( HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { - params: { sessionID: SessionV2.ID }, + params: { sessionID: Session.ID }, payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), - success: Schema.Struct({ data: SessionV2.RevertState }), + success: Schema.Struct({ data: Revert.State }), error: [MessageNotFoundError, SessionNotFoundError, UnknownError], }) .middleware(SessionLocationMiddleware) @@ -238,7 +239,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") ) .add( HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { - params: { sessionID: SessionV2.ID }, + params: { sessionID: Session.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, UnknownError], }) @@ -247,7 +248,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") ) .add( HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", { - params: { sessionID: SessionV2.ID }, + params: { sessionID: Session.ID }, success: HttpApiSchema.NoContent, error: SessionNotFoundError, }) From b9d142506b635d41c22cec9edbd49f77f0e84ec5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 24 Jun 2026 21:06:46 -0400 Subject: [PATCH 4/5] refactor(protocol): inject server middleware --- bun.lock | 1 - .../src/server/routes/instance/httpapi/api.ts | 8 +- .../server/routes/instance/httpapi/server.ts | 2 +- packages/protocol/package.json | 1 - packages/protocol/src/api.ts | 85 ++-- packages/protocol/src/groups/agent.ts | 32 +- packages/protocol/src/groups/command.ts | 3 +- packages/protocol/src/groups/credential.ts | 3 +- packages/protocol/src/groups/fs.ts | 3 +- packages/protocol/src/groups/integration.ts | 3 +- packages/protocol/src/groups/location.ts | 41 +- packages/protocol/src/groups/message.ts | 66 ++-- packages/protocol/src/groups/model.ts | 3 +- packages/protocol/src/groups/permission.ts | 152 +++---- packages/protocol/src/groups/project-copy.ts | 3 +- packages/protocol/src/groups/provider.ts | 3 +- packages/protocol/src/groups/pty.ts | 3 +- packages/protocol/src/groups/question.ts | 148 +++---- packages/protocol/src/groups/reference.ts | 3 +- packages/protocol/src/groups/session.ts | 374 +++++++++--------- packages/protocol/src/groups/skill.ts | 3 +- .../src/middleware/session-location.ts | 10 - packages/server/src/api.ts | 8 + packages/server/src/handlers/agent.ts | 2 +- packages/server/src/handlers/command.ts | 2 +- packages/server/src/handlers/credential.ts | 2 +- packages/server/src/handlers/event.ts | 2 +- packages/server/src/handlers/fs.ts | 2 +- packages/server/src/handlers/health.ts | 2 +- packages/server/src/handlers/integration.ts | 2 +- packages/server/src/handlers/location.ts | 2 +- packages/server/src/handlers/message.ts | 2 +- packages/server/src/handlers/model.ts | 2 +- packages/server/src/handlers/permission.ts | 2 +- packages/server/src/handlers/project-copy.ts | 2 +- packages/server/src/handlers/provider.ts | 2 +- packages/server/src/handlers/pty.ts | 2 +- packages/server/src/handlers/question.ts | 2 +- packages/server/src/handlers/reference.ts | 2 +- packages/server/src/handlers/session.ts | 2 +- packages/server/src/handlers/skill.ts | 2 +- packages/server/src/location.ts | 8 +- .../server/src/middleware/session-location.ts | 11 +- packages/server/src/routes.ts | 2 +- 44 files changed, 530 insertions(+), 485 deletions(-) delete mode 100644 packages/protocol/src/middleware/session-location.ts create mode 100644 packages/server/src/api.ts diff --git a/bun.lock b/bun.lock index ca3079c621e9..580e4f9625e5 100644 --- a/bun.lock +++ b/bun.lock @@ -664,7 +664,6 @@ "effect": "catalog:", }, "devDependencies": { - "@opencode-ai/core": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 045459a43686..f5076ad08082 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -26,6 +26,8 @@ import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" 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) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 49bf4f18609b..b74df8deb83a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -66,7 +66,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/ import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "@opencode-ai/server/api" import { PublicApi } from "./public" import { authorizationLayer, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 826fe3954402..7d4c41dfc7f7 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -15,7 +15,6 @@ "effect": "catalog:" }, "devDependencies": { - "@opencode-ai/core": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 4f5615779195..b84841bab60f 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -1,19 +1,21 @@ -import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +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 { makeMessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" import { ProviderGroup } from "./groups/provider" -import { SessionGroup } from "./groups/session" -import { PermissionGroup } from "./groups/permission" +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 { makeEventGroup } from "./groups/event" import type { Definition } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" import { AgentGroup } from "./groups/agent" import { HealthGroup } from "./groups/health" import { PtyGroup } from "./groups/pty" -import { QuestionGroup } from "./groups/question" +import { makeQuestionGroup } from "./groups/question" import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" @@ -21,26 +23,36 @@ import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" import { ProjectCopyGroup } from "./groups/project-copy" -const makeApiFromGroup = (eventGroup: Group) => +const makeApiFromGroup = < + const Group extends HttpApiGroup.Any, + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + eventGroup: Group, + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => 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(LocationGroup.middleware(locationMiddleware)) + .add(AgentGroup.middleware(locationMiddleware)) + .add(makeSessionGroup(sessionLocationMiddleware)) + .add(makeMessageGroup(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) - .add(QuestionGroup) - .add(ReferenceGroup) - .add(ProjectCopyGroup) + .add(PtyGroup.middleware(locationMiddleware)) + .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(ReferenceGroup.middleware(locationMiddleware)) + .add(ProjectCopyGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", @@ -51,6 +63,29 @@ const makeApiFromGroup = (eventGroup: Grou .middleware(Authorization) .middleware(SchemaErrorMiddleware) -export const makeApi = (definitions: ReadonlyArray) => makeApiFromGroup(makeEventGroup(definitions)) +export const makeApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly definitions: ReadonlyArray + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => + makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware) -export const Api = makeApiFromGroup(EventGroup) +export const makeDefaultApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => + makeApi({ + definitions: EventManifest.ServerDefinitions, + locationMiddleware: options.locationMiddleware, + sessionLocationMiddleware: options.sessionLocationMiddleware, + }) diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts index ba4e4059b061..0d499e187f9a 100644 --- a/packages/protocol/src/groups/agent.ts +++ b/packages/protocol/src/groups/agent.ts @@ -2,21 +2,19 @@ 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, LocationMiddleware } from "./location" +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.", - }), - ), - ) - .middleware(LocationMiddleware) +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.", + }), + ), +) diff --git a/packages/protocol/src/groups/command.ts b/packages/protocol/src/groups/command.ts index 345867757169..eac33cc29270 100644 --- a/packages/protocol/src/groups/command.ts +++ b/packages/protocol/src/groups/command.ts @@ -2,7 +2,7 @@ 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( @@ -25,4 +25,3 @@ export const CommandGroup = HttpApiGroup.make("server.command") description: "Experimental command routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts index 7b4761d6a869..4f6ce8461bb6 100644 --- a/packages/protocol/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -1,7 +1,7 @@ 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) diff --git a/packages/protocol/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts index a6c94c82da7a..f5fc00e02132 100644 --- a/packages/protocol/src/groups/fs.ts +++ b/packages/protocol/src/groups/fs.ts @@ -3,7 +3,7 @@ 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) diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index 201259c4df1e..304681d33055 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -3,7 +3,7 @@ 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) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts index b99ecb5c0b5b..1752bae9bebe 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -1,7 +1,6 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" -import type { Layer } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" export const LocationQuery = Schema.Struct({ location: Schema.optional( @@ -27,27 +26,17 @@ export const locationQueryOpenApi = OpenApi.annotations({ }, }) -export type LocationServices = Layer.Success< - ReturnType<(typeof import("@opencode-ai/core/location-layer"))["LocationServiceMap"]["get"]> -> - -export class LocationMiddleware extends HttpApiMiddleware.Service()( - "@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) +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.", + }), + ), +) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index 85574840ce39..abfbe86daa80 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -1,9 +1,8 @@ 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 { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, 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( @@ -22,33 +21,34 @@ export const SessionMessagesQuery = Schema.Struct({ ), }).annotate({ identifier: "SessionMessagesQuery" }) -export const MessageGroup = HttpApiGroup.make("server.message") - .add( - HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { - params: { sessionID: Session.ID }, - query: SessionMessagesQuery, - success: Schema.Struct({ - data: Schema.Array(SessionMessage.Message), - cursor: Schema.Struct({ - previous: Schema.String.pipe(Schema.optional), - next: Schema.String.pipe(Schema.optional), - }), - }).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({ - title: "messages", - description: "Experimental message routes.", - }), - ) +export const makeMessageGroup = (sessionLocationMiddleware: Context.Key) => + HttpApiGroup.make("server.message") + .add( + HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { + params: { sessionID: Session.ID }, + query: SessionMessagesQuery, + success: Schema.Struct({ + data: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).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({ + title: "messages", + description: "Experimental message routes.", + }), + ) diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index e5caa42d0509..9125f9528929 100644 --- a/packages/protocol/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -3,7 +3,7 @@ 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( @@ -27,4 +27,3 @@ export const ModelGroup = HttpApiGroup.make("server.model") description: "Experimental model routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts index b98ebb0173b0..cb0ac3970714 100644 --- a/packages/protocol/src/groups/permission.ts +++ b/packages/protocol/src/groups/permission.ts @@ -3,84 +3,92 @@ 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 { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { PermissionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } 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(Permission.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( +export const makePermissionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + 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.request.list", - summary: "List pending permission requests", - description: "Retrieve pending permission requests for a location.", + identifier: "v2.permission.saved.list", + summary: "List saved permissions", + description: "Retrieve saved permissions, optionally filtered by project.", }), ), - ) - .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.", - }), - ), - ) - .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( + ) + .add( + HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { + params: { id: PermissionSaved.ID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( OpenApi.annotations({ - identifier: "v2.session.permission.list", - summary: "List session permission requests", - description: "Retrieve pending permission requests owned by a session.", + identifier: "v2.permission.saved.remove", + summary: "Remove saved permission", + description: "Remove a saved permission by ID.", }), ), - ) - .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.", + ) + .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), }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) + 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." })) diff --git a/packages/protocol/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts index 8754bbf1ae90..c4f0240fe335 100644 --- a/packages/protocol/src/groups/project-copy.ts +++ b/packages/protocol/src/groups/project-copy.ts @@ -2,7 +2,7 @@ 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" @@ -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) diff --git a/packages/protocol/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts index 7da6c01030df..9089b1a09c7a 100644 --- a/packages/protocol/src/groups/provider.ts +++ b/packages/protocol/src/groups/provider.ts @@ -3,7 +3,7 @@ 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( @@ -43,4 +43,3 @@ export const ProviderGroup = HttpApiGroup.make("server.provider") description: "Experimental provider routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index 1c818ece2f86..a6ec1b66b6f9 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -4,7 +4,7 @@ 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" @@ -140,4 +140,3 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts index 88e7f8e4d842..b6208be0518b 100644 --- a/packages/protocol/src/groups/question.ts +++ b/packages/protocol/src/groups/question.ts @@ -1,75 +1,83 @@ import { Question } from "@opencode-ai/schema/question" import { Location } from "@opencode-ai/schema/location" import { Session } from "@opencode-ai/schema/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { QuestionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } 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(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." })) - .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." }), - ) +export const makeQuestionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + 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." })) + .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." }), + ) diff --git a/packages/protocol/src/groups/reference.ts b/packages/protocol/src/groups/reference.ts index b95599bf28a1..d953cd530a87 100644 --- a/packages/protocol/src/groups/reference.ts +++ b/packages/protocol/src/groups/reference.ts @@ -2,7 +2,7 @@ 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) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 22a140b65c54..dba39e765d76 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -5,8 +5,8 @@ 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 { Encoding, Result, Schema, Struct } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Context, Encoding, Result, Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ConflictError, InvalidCursorError, @@ -16,7 +16,6 @@ import { SessionNotFoundError, UnknownError, } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" import { Location } from "@opencode-ai/schema/location" @@ -85,194 +84,197 @@ export const SessionsQuery = Schema.Struct({ cursor: SessionsQueryCursor.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(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( +export const makeSessionGroup = (sessionLocationMiddleware: Context.Key) => + 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.get", - summary: "Get session", - description: "Retrieve a session by ID.", + 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.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.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), }), - ), - ) - .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( + success: Schema.Struct({ data: Session.Info }), + }).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.", + identifier: "v2.session.create", + summary: "Create session", + description: "Create a session at the requested location.", }), ), - ) - .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.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), }), - ), - ) - .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.", - }), - ) + 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.", + }), + ) diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts index e03e1346f95c..ab998a538e0d 100644 --- a/packages/protocol/src/groups/skill.ts +++ b/packages/protocol/src/groups/skill.ts @@ -2,7 +2,7 @@ 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( @@ -25,4 +25,3 @@ export const SkillGroup = HttpApiGroup.make("server.skill") description: "Experimental skill routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/middleware/session-location.ts b/packages/protocol/src/middleware/session-location.ts deleted file mode 100644 index 4083a2b83be1..000000000000 --- a/packages/protocol/src/middleware/session-location.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError, SessionNotFoundError } from "../errors" -import type { LocationServices } from "../groups/location" - -export class SessionLocationMiddleware extends HttpApiMiddleware.Service< - SessionLocationMiddleware, - { provides: LocationServices } ->()("@opencode/HttpApiSessionLocation", { - error: [InvalidRequestError, SessionNotFoundError], -}) {} diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts new file mode 100644 index 000000000000..981ad28db93d --- /dev/null +++ b/packages/server/src/api.ts @@ -0,0 +1,8 @@ +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "./location" +import { SessionLocationMiddleware } from "./middleware/session-location" + +export const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index e941d2c7e364..c1511e3c62cd 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -1,7 +1,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { response } from "../location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => diff --git a/packages/server/src/handlers/command.ts b/packages/server/src/handlers/command.ts index 0967333e0e05..bf41e79f835b 100644 --- a/packages/server/src/handlers/command.ts +++ b/packages/server/src/handlers/command.ts @@ -1,6 +1,6 @@ import { CommandV2 } from "@opencode-ai/core/command" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { response } from "../location" export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) => diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index f01175c3719c..7e138a5d5a20 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -1,7 +1,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) => handlers diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 278fdcc51fb6..8001fb874812 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -3,7 +3,7 @@ import { Effect, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" function eventData(data: unknown): Sse.Event { return { diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index ba20f54b4b4a..c7d1d43bab7a 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -3,7 +3,7 @@ import { RelativePath } from "@opencode-ai/core/schema" import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { response } from "../location" export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 91650d7fc998..60000b3fc009 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })), diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 2388c045efa8..6c29d5877607 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -1,7 +1,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { InvalidRequestError } from "@opencode-ai/protocol/errors" import { response } from "../location" diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index 8a4a32115350..ded8c8c2e0ac 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,7 +1,7 @@ import { Location } from "@opencode-ai/core/location" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => handlers.handle( diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index ec9334eece01..93734c628d8e 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -2,7 +2,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" const DefaultMessagesLimit = 50 diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 20d72b76114a..36639ae7b1e6 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,7 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { response } from "../location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index c6e74190b2cf..b54d6c67d1a3 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -3,7 +3,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { PermissionNotFoundError } from "@opencode-ai/protocol/errors" import { response } from "../location" diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts index f1bed194855c..3733db6771a4 100644 --- a/packages/server/src/handlers/project-copy.ts +++ b/packages/server/src/handlers/project-copy.ts @@ -3,7 +3,7 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy" import { Git } from "@opencode-ai/core/git" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy" export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index da12aeb4fd58..c3f25ab0dfc3 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,7 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" import { response } from "../location" diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index 92ada50f942f..cda2cf43e9ea 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -6,7 +6,7 @@ import { Effect, Queue } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" import { diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index af57983c3aa1..954afe0df587 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -1,7 +1,7 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" import { response } from "../location" diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts index 6084f0628623..543c9790dc87 100644 --- a/packages/server/src/handlers/reference.ts +++ b/packages/server/src/handlers/reference.ts @@ -1,6 +1,6 @@ import { Reference } from "@opencode-ai/core/reference" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { response } from "../location" export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) => diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 78bb6157040b..2ac6b1774261 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,7 +1,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { DateTime, Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index ab76ba9e90b5..8ffeaca8ea21 100644 --- a/packages/server/src/handlers/skill.ts +++ b/packages/server/src/handlers/skill.ts @@ -1,6 +1,6 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "../api" import { response } from "../location" export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) => diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 722ff19f1d9a..483a0733fd45 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -2,9 +2,15 @@ import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { LocationMiddleware } from "@opencode-ai/protocol/groups/location" import { Effect, Layer } from "effect" import { HttpServerRequest } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +export type LocationServices = Layer.Success> + +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", +) {} export function response(data: Effect.Effect) { return Effect.gen(function* () { diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index 6e8e6b9ab105..d4e691954b68 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -8,9 +8,16 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" 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 "@opencode-ai/protocol/errors" -import { SessionLocationMiddleware } from "@opencode-ai/protocol/middleware/session-location" -export { SessionLocationMiddleware } from "@opencode-ai/protocol/middleware/session-location" +import type { LocationServices } from "../location" + +export class SessionLocationMiddleware extends HttpApiMiddleware.Service< + SessionLocationMiddleware, + { provides: LocationServices } +>()("@opencode/HttpApiSessionLocation", { + error: [InvalidRequestError, SessionNotFoundError], +}) {} const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 481ae84fd857..da5cda139918 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -4,7 +4,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" -import { Api } from "@opencode-ai/protocol/api" +import { Api } from "./api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" From c2de56af95c92c260b1dc82ee0cd6cb0c895f18d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 24 Jun 2026 22:01:45 -0400 Subject: [PATCH 5/5] refactor(protocol): simplify middleware assembly --- packages/protocol/src/api.ts | 15 ++---- packages/protocol/src/groups/message.ts | 59 ++++++++++------------ packages/protocol/src/groups/permission.ts | 1 + packages/protocol/src/groups/question.ts | 1 + 4 files changed, 35 insertions(+), 41 deletions(-) diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index b84841bab60f..7b422d06007d 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -1,7 +1,7 @@ import { Context } from "effect" import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" import { SchemaErrorMiddleware } from "./middleware/schema-error" -import { makeMessageGroup } from "./groups/message" +import { MessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" import { ProviderGroup } from "./groups/provider" import { makeSessionGroup } from "./groups/session" @@ -9,9 +9,8 @@ import { makePermissionGroup } from "./groups/permission" import { FileSystemGroup } from "./groups/fs" import { CommandGroup } from "./groups/command" import { SkillGroup } from "./groups/skill" -import { makeEventGroup } from "./groups/event" +import { EventGroup, makeEventGroup } from "./groups/event" import type { Definition } from "@opencode-ai/schema/event" -import { EventManifest } from "@opencode-ai/schema/event-manifest" import { AgentGroup } from "./groups/agent" import { HealthGroup } from "./groups/health" import { PtyGroup } from "./groups/pty" @@ -23,6 +22,7 @@ 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, @@ -39,7 +39,7 @@ const makeApiFromGroup = < .add(LocationGroup.middleware(locationMiddleware)) .add(AgentGroup.middleware(locationMiddleware)) .add(makeSessionGroup(sessionLocationMiddleware)) - .add(makeMessageGroup(sessionLocationMiddleware)) + .add(MessageGroup.middleware(sessionLocationMiddleware)) .add(ModelGroup.middleware(locationMiddleware)) .add(ProviderGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) @@ -83,9 +83,4 @@ export const makeDefaultApi = < >(options: { readonly locationMiddleware: Context.Key readonly sessionLocationMiddleware: Context.Key -}) => - makeApi({ - definitions: EventManifest.ServerDefinitions, - locationMiddleware: options.locationMiddleware, - sessionLocationMiddleware: options.sessionLocationMiddleware, - }) +}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index abfbe86daa80..7ace0ada994f 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -1,7 +1,7 @@ import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" -import { Context, Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" export const SessionMessagesQuery = Schema.Struct({ @@ -21,34 +21,31 @@ export const SessionMessagesQuery = Schema.Struct({ ), }).annotate({ identifier: "SessionMessagesQuery" }) -export const makeMessageGroup = (sessionLocationMiddleware: Context.Key) => - HttpApiGroup.make("server.message") - .add( - HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { - params: { sessionID: Session.ID }, - query: SessionMessagesQuery, - success: Schema.Struct({ - data: Schema.Array(SessionMessage.Message), - cursor: Schema.Struct({ - previous: Schema.String.pipe(Schema.optional), - next: Schema.String.pipe(Schema.optional), - }), - }).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( +export const MessageGroup = HttpApiGroup.make("server.message") + .add( + HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { + params: { sessionID: Session.ID }, + query: SessionMessagesQuery, + success: Schema.Struct({ + data: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "SessionMessagesResponse" }), + error: [InvalidCursorError, SessionNotFoundError, UnknownError], + }).annotateMerge( OpenApi.annotations({ - title: "messages", - description: "Experimental message routes.", + 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({ + title: "messages", + description: "Experimental message routes.", + }), + ) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts index cb0ac3970714..7e2bbfaa035f 100644 --- a/packages/protocol/src/groups/permission.ts +++ b/packages/protocol/src/groups/permission.ts @@ -56,6 +56,7 @@ export const makePermissionGroup = < }), ), ) + // 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", { diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts index b6208be0518b..e6d862334d8a 100644 --- a/packages/protocol/src/groups/question.ts +++ b/packages/protocol/src/groups/question.ts @@ -31,6 +31,7 @@ export const makeQuestionGroup = < ), ) .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", {