diff --git a/bun.lock b/bun.lock index 5dd59c634bbc..580e4f9625e5 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:*", @@ -656,6 +657,18 @@ "@opentui/solid", ], }, + "packages/protocol": { + "name": "@opencode-ai/protocol", + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/schema": { "name": "@opencode-ai/schema", "dependencies": { @@ -697,6 +710,7 @@ "version": "1.17.10", "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -1863,6 +1877,8 @@ "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], + "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], + "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], 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/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..f5076ad08082 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -25,7 +25,9 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" -import { makeApi } from "@opencode-ai/server/api" +import { makeApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "@opencode-ai/server/location" +import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location" import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" @@ -43,7 +45,11 @@ const EventSchema = Schema.Union([ InstanceDisposed, ]).annotate({ identifier: "Event" }) -export const ServerApi = makeApi(EventManifest.Latest.values().toArray()) +export const ServerApi = makeApi({ + definitions: EventManifest.Latest.values().toArray(), + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) 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/package.json b/packages/protocol/package.json new file mode 100644 index 000000000000..7d4c41dfc7f7 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/protocol", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts new file mode 100644 index 000000000000..7b422d06007d --- /dev/null +++ b/packages/protocol/src/api.ts @@ -0,0 +1,86 @@ +import { Context } from "effect" +import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { SchemaErrorMiddleware } from "./middleware/schema-error" +import { MessageGroup } from "./groups/message" +import { ModelGroup } from "./groups/model" +import { ProviderGroup } from "./groups/provider" +import { makeSessionGroup } from "./groups/session" +import { makePermissionGroup } from "./groups/permission" +import { FileSystemGroup } from "./groups/fs" +import { CommandGroup } from "./groups/command" +import { SkillGroup } from "./groups/skill" +import { EventGroup, makeEventGroup } from "./groups/event" +import type { Definition } from "@opencode-ai/schema/event" +import { AgentGroup } from "./groups/agent" +import { HealthGroup } from "./groups/health" +import { PtyGroup } from "./groups/pty" +import { makeQuestionGroup } from "./groups/question" +import { ReferenceGroup } from "./groups/reference" +import { Authorization } from "./middleware/authorization" +import { LocationGroup } from "./groups/location" +import { IntegrationGroup } from "./groups/integration" +import { CredentialGroup } from "./groups/credential" +import { ProjectCopyGroup } from "./groups/project-copy" + +// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. +const makeApiFromGroup = < + const Group extends HttpApiGroup.Any, + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + eventGroup: Group, + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApi.make("server") + .add(HealthGroup) + .add(LocationGroup.middleware(locationMiddleware)) + .add(AgentGroup.middleware(locationMiddleware)) + .add(makeSessionGroup(sessionLocationMiddleware)) + .add(MessageGroup.middleware(sessionLocationMiddleware)) + .add(ModelGroup.middleware(locationMiddleware)) + .add(ProviderGroup.middleware(locationMiddleware)) + .add(IntegrationGroup.middleware(locationMiddleware)) + .add(CredentialGroup.middleware(locationMiddleware)) + .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(FileSystemGroup.middleware(locationMiddleware)) + .add(CommandGroup.middleware(locationMiddleware)) + .add(SkillGroup.middleware(locationMiddleware)) + .add(eventGroup) + .add(PtyGroup.middleware(locationMiddleware)) + .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(ReferenceGroup.middleware(locationMiddleware)) + .add(ProjectCopyGroup.middleware(locationMiddleware)) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(Authorization) + .middleware(SchemaErrorMiddleware) + +export const makeApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly definitions: ReadonlyArray + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => + makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware) + +export const makeDefaultApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware) diff --git a/packages/server/src/errors.ts b/packages/protocol/src/errors.ts similarity index 100% rename from packages/server/src/errors.ts rename to packages/protocol/src/errors.ts diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts new file mode 100644 index 000000000000..0d499e187f9a --- /dev/null +++ b/packages/protocol/src/groups/agent.ts @@ -0,0 +1,20 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const AgentGroup = HttpApiGroup.make("server.agent").add( + HttpApiEndpoint.get("agent.list", "/api/agent", { + query: LocationQuery, + success: Location.response(Schema.Array(Agent.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.agent.list", + summary: "List agents", + description: "Retrieve currently registered agents.", + }), + ), +) diff --git a/packages/server/src/groups/command.ts b/packages/protocol/src/groups/command.ts similarity index 69% rename from packages/server/src/groups/command.ts rename to packages/protocol/src/groups/command.ts index 581dd19e0300..eac33cc29270 100644 --- a/packages/server/src/groups/command.ts +++ b/packages/protocol/src/groups/command.ts @@ -1,14 +1,14 @@ -import { CommandV2 } from "@opencode-ai/core/command" -import { Location } from "@opencode-ai/core/location" +import { Command } from "@opencode-ai/schema/command" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const CommandGroup = HttpApiGroup.make("server.command") .add( HttpApiEndpoint.get("command.list", "/api/command", { query: LocationQuery, - success: Location.response(Schema.Array(CommandV2.Info)), + success: Location.response(Schema.Array(Command.Info)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -25,4 +25,3 @@ export const CommandGroup = HttpApiGroup.make("server.command") description: "Experimental command routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts similarity index 87% rename from packages/server/src/groups/credential.ts rename to packages/protocol/src/groups/credential.ts index b6e21ca30b7c..4f6ce8461bb6 100644 --- a/packages/server/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -1,7 +1,7 @@ -import { Credential } from "@opencode-ai/core/credential" +import { Credential } from "@opencode-ai/schema/credential" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const CredentialGroup = HttpApiGroup.make("server.credential") .add( @@ -35,4 +35,3 @@ export const CredentialGroup = HttpApiGroup.make("server.credential") }), ), ) - .middleware(LocationMiddleware) 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/server/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts similarity index 87% rename from packages/server/src/groups/fs.ts rename to packages/protocol/src/groups/fs.ts index 96ce404619ff..f5fc00e02132 100644 --- a/packages/server/src/groups/fs.ts +++ b/packages/protocol/src/groups/fs.ts @@ -1,9 +1,9 @@ -import { FileSystem } from "@opencode-ai/core/filesystem" -import { Location } from "@opencode-ai/core/location" -import { PositiveInt, RelativePath } from "@opencode-ai/core/schema" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { Location } from "@opencode-ai/schema/location" +import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" const ListQuery = Schema.Struct({ ...LocationQuery.fields, @@ -66,4 +66,3 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs") description: "Experimental location-scoped filesystem routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/health.ts b/packages/protocol/src/groups/health.ts similarity index 100% rename from packages/server/src/groups/health.ts rename to packages/protocol/src/groups/health.ts diff --git a/packages/server/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts similarity index 95% rename from packages/server/src/groups/integration.ts rename to packages/protocol/src/groups/integration.ts index 30e82ace6e1e..304681d33055 100644 --- a/packages/server/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -1,9 +1,9 @@ -import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" +import { Integration } from "@opencode-ai/schema/integration" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { InvalidRequestError } from "../errors" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" const Inputs = Schema.Record(Schema.String, Schema.String) @@ -128,4 +128,3 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") .annotateMerge( OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts new file mode 100644 index 000000000000..1752bae9bebe --- /dev/null +++ b/packages/protocol/src/groups/location.ts @@ -0,0 +1,42 @@ +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const LocationQuery = Schema.Struct({ + location: Schema.optional( + Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + }), + ), +}).annotate({ identifier: "LocationQuery" }) + +export const locationQueryOpenApi = OpenApi.annotations({ + transform: (operation) => { + const parameters = operation.parameters + if (!Array.isArray(parameters)) return operation + return { + ...operation, + parameters: parameters.map((parameter) => + parameter?.name === "location" && parameter?.in === "query" + ? { ...parameter, style: "deepObject", explode: true } + : parameter, + ), + } + }, +}) + +export const LocationGroup = HttpApiGroup.make("server.location").add( + HttpApiEndpoint.get("location.get", "/api/location", { + query: LocationQuery, + success: Location.Info, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.location.get", + summary: "Get location", + description: "Resolve the requested location or the server default location.", + }), + ), +) diff --git a/packages/server/src/groups/message.ts b/packages/protocol/src/groups/message.ts similarity index 72% rename from packages/server/src/groups/message.ts rename to packages/protocol/src/groups/message.ts index b8a6e19ab647..7ace0ada994f 100644 --- a/packages/server/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -1,9 +1,8 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { SessionMessage } from "@opencode-ai/core/session/message" +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" export const SessionMessagesQuery = Schema.Struct({ limit: Schema.optional( @@ -25,7 +24,7 @@ export const SessionMessagesQuery = Schema.Struct({ export const MessageGroup = HttpApiGroup.make("server.message") .add( HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { - params: { sessionID: SessionV2.ID }, + params: { sessionID: Session.ID }, query: SessionMessagesQuery, success: Schema.Struct({ data: Schema.Array(SessionMessage.Message), @@ -35,16 +34,14 @@ export const MessageGroup = HttpApiGroup.make("server.message") }), }).annotate({ identifier: "SessionMessagesResponse" }), error: [InvalidCursorError, SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.messages", - summary: "Get session messages", - description: - "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", - }), - ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get session messages", + description: + "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/server/src/groups/model.ts b/packages/protocol/src/groups/model.ts similarity index 72% rename from packages/server/src/groups/model.ts rename to packages/protocol/src/groups/model.ts index 3964ae4789ee..9125f9528929 100644 --- a/packages/server/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -1,15 +1,15 @@ -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ServiceUnavailableError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const ModelGroup = HttpApiGroup.make("server.model") .add( HttpApiEndpoint.get("model.list", "/api/model", { query: LocationQuery, - success: Location.response(Schema.Array(ModelV2.Info)), + success: Location.response(Schema.Array(Model.Info)), error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) @@ -27,4 +27,3 @@ export const ModelGroup = HttpApiGroup.make("server.model") description: "Experimental model routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts new file mode 100644 index 000000000000..7e2bbfaa035f --- /dev/null +++ b/packages/protocol/src/groups/permission.ts @@ -0,0 +1,95 @@ +import { Permission } from "@opencode-ai/schema/permission" +import { Location } from "@opencode-ai/schema/location" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" +import { Project } from "@opencode-ai/schema/project" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { PermissionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makePermissionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + 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.saved.list", + summary: "List saved permissions", + description: "Retrieve saved permissions, optionally filtered by project.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { + params: { id: PermissionSaved.ID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.remove", + summary: "Remove saved permission", + description: "Remove a saved permission by ID.", + }), + ), + ) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Permission.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.list", + summary: "List session permission requests", + description: "Retrieve pending permission requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + payload: Schema.Struct({ + reply: Permission.Reply, + message: Schema.String.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.reply", + summary: "Reply to pending permission request", + description: "Respond to a pending permission request owned by a session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/server/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts similarity index 83% rename from packages/server/src/groups/project-copy.ts rename to packages/protocol/src/groups/project-copy.ts index e9b5e8f6748d..c4f0240fe335 100644 --- a/packages/server/src/groups/project-copy.ts +++ b/packages/protocol/src/groups/project-copy.ts @@ -1,8 +1,8 @@ -import { ProjectCopy } from "@opencode-ai/core/project/copy" -import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectCopy } from "@opencode-ai/schema/project-copy" +import { Project } from "@opencode-ai/schema/project" import { Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" const root = "/experimental/project/:projectID/copy" @@ -23,7 +23,7 @@ const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") .add( HttpApiEndpoint.post("projectCopy.create", root, { - params: { projectID: ProjectV2.ID }, + params: { projectID: Project.ID }, query: LocationQuery, payload: CreatePayload, success: ProjectCopy.Copy, @@ -34,7 +34,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") ) .add( HttpApiEndpoint.delete("projectCopy.remove", root, { - params: { projectID: ProjectV2.ID }, + params: { projectID: Project.ID }, query: LocationQuery, payload: RemovePayload, success: HttpApiSchema.NoContent, @@ -45,7 +45,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") ) .add( HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { - params: { projectID: ProjectV2.ID }, + params: { projectID: Project.ID }, query: LocationQuery, success: HttpApiSchema.NoContent, error: ProjectCopyError, @@ -54,4 +54,3 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), ) .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts similarity index 77% rename from packages/server/src/groups/provider.ts rename to packages/protocol/src/groups/provider.ts index 4b861ccc9e83..9089b1a09c7a 100644 --- a/packages/server/src/groups/provider.ts +++ b/packages/protocol/src/groups/provider.ts @@ -1,15 +1,15 @@ -import { ProviderV2 } from "@opencode-ai/core/provider" -import { Location } from "@opencode-ai/core/location" +import { Provider } from "@opencode-ai/schema/provider" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const ProviderGroup = HttpApiGroup.make("server.provider") .add( HttpApiEndpoint.get("provider.list", "/api/provider", { query: LocationQuery, - success: Location.response(Schema.Array(ProviderV2.Info)), + success: Location.response(Schema.Array(Provider.Info)), error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) @@ -23,9 +23,9 @@ export const ProviderGroup = HttpApiGroup.make("server.provider") ) .add( HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", { - params: { providerID: ProviderV2.ID }, + params: { providerID: Provider.ID }, query: LocationQuery, - success: Location.response(ProviderV2.Info), + success: Location.response(Provider.Info), error: [ProviderNotFoundError, ServiceUnavailableError], }) .annotateMerge(locationQueryOpenApi) @@ -43,4 +43,3 @@ export const ProviderGroup = HttpApiGroup.make("server.provider") description: "Experimental provider routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts similarity index 90% rename from packages/server/src/groups/pty.ts rename to packages/protocol/src/groups/pty.ts index b5bbc7bf5a83..a6ec1b66b6f9 100644 --- a/packages/server/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -1,11 +1,10 @@ -import { Pty } from "@opencode-ai/core/pty" -import { PtyID } from "@opencode-ai/core/pty/schema" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { Location } from "@opencode-ai/core/location" +import { Pty } from "@opencode-ai/schema/pty" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ForbiddenError, PtyNotFoundError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const PTY_CONNECT_TICKET_QUERY = "ticket" export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" @@ -51,7 +50,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, success: Location.response(Pty.Info), error: PtyNotFoundError, @@ -67,7 +66,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, payload: Pty.UpdateInput, success: Location.response(Pty.Info), @@ -84,7 +83,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, success: HttpApiSchema.NoContent, error: PtyNotFoundError, @@ -100,7 +99,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, success: Location.response(PtyTicket.ConnectToken), error: [ForbiddenError, PtyNotFoundError], @@ -118,7 +117,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") // Query fields are decoded in the raw handler after the existence check so a missing // session responds with an empty 404 before any upgrade work. HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, success: Schema.Boolean, error: [ForbiddenError, PtyNotFoundError], }).annotateMerge( @@ -141,4 +140,3 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts new file mode 100644 index 000000000000..e6d862334d8a --- /dev/null +++ b/packages/protocol/src/groups/question.ts @@ -0,0 +1,84 @@ +import { Question } from "@opencode-ai/schema/question" +import { Location } from "@opencode-ai/schema/location" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { QuestionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makeQuestionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + 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." })) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Question.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.list", + summary: "List session question requests", + description: "Retrieve pending question requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Question.ID }, + payload: Question.Reply, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reply", + summary: "Reply to pending question request", + description: "Answer a pending question request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { + params: { sessionID: Session.ID, requestID: Question.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reject", + summary: "Reject pending question request", + description: "Reject a pending question request owned by a session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), + ) diff --git a/packages/server/src/groups/reference.ts b/packages/protocol/src/groups/reference.ts similarity index 77% rename from packages/server/src/groups/reference.ts rename to packages/protocol/src/groups/reference.ts index f27a934db2ea..d953cd530a87 100644 --- a/packages/server/src/groups/reference.ts +++ b/packages/protocol/src/groups/reference.ts @@ -1,8 +1,8 @@ -import { Location } from "@opencode-ai/core/location" -import { Reference } from "@opencode-ai/core/reference" +import { Location } from "@opencode-ai/schema/location" +import { Reference } from "@opencode-ai/schema/reference" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const ReferenceGroup = HttpApiGroup.make("server.reference") .add( @@ -25,4 +25,3 @@ export const ReferenceGroup = HttpApiGroup.make("server.reference") description: "Location-scoped project references.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts new file mode 100644 index 000000000000..dba39e765d76 --- /dev/null +++ b/packages/protocol/src/groups/session.ts @@ -0,0 +1,280 @@ +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { Prompt } from "@opencode-ai/schema/prompt" +import { Session } from "@opencode-ai/schema/session" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/schema/schema" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Context, Encoding, Result, Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { + ConflictError, + InvalidCursorError, + InvalidRequestError, + MessageNotFoundError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../errors" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" +import { Revert } from "@opencode-ai/schema/revert" + +const SessionsQueryFields = { + workspace: Workspace.ID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + search: Schema.optional(Schema.String), +} + +const SessionsDirectoryQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath, +}) + +const SessionsProjectQuery = Schema.Struct({ + ...SessionsQueryFields, + project: Project.ID, + subpath: RelativePath.pipe(Schema.optional), +}) + +const SessionsAllQuery = Schema.Struct(SessionsQueryFields) + +const withCursor = (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 SessionsQueryCursor = SessionsCursor.annotate({ + description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", +}) + +export const SessionsQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath.pipe(Schema.optional), + project: Project.ID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsQueryCursor.pipe(Schema.optional), +}).annotate({ identifier: "SessionsQuery" }) + +export const makeSessionGroup = (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.list", + summary: "List sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.create", "/api/session", { + payload: Schema.Struct({ + id: Session.ID.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), + model: Model.Ref.pipe(Schema.optional), + location: Location.Ref.pipe(Schema.optional), + }), + success: Schema.Struct({ data: Session.Info }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.create", + summary: "Create session", + description: "Create a session at the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Session.Info }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.get", + summary: "Get session", + description: "Retrieve a session by ID.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ agent: Agent.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ model: Model.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + prompt: Prompt, + delivery: SessionInput.Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionInput.Admitted }), + error: [ConflictError, SessionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send message", + description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact session", + description: "Compact a session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for session", + description: "Wait for a session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), + success: Schema.Struct({ data: Revert.State }), + error: [MessageNotFoundError, SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.revert.stage", + summary: "Stage session revert", + description: "Stage or move a reversible session boundary and optionally apply its file changes.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })), + ) + .add( + HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }), + ), + ) + .add( + HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get session context", + description: "Retrieve the active context messages for a session (all messages after the last compaction).", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "sessions", + description: "Experimental session routes.", + }), + ) diff --git a/packages/server/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts similarity index 69% rename from packages/server/src/groups/skill.ts rename to packages/protocol/src/groups/skill.ts index a43cb83c525e..ab998a538e0d 100644 --- a/packages/server/src/groups/skill.ts +++ b/packages/protocol/src/groups/skill.ts @@ -1,14 +1,14 @@ -import { SkillV2 } from "@opencode-ai/core/skill" -import { Location } from "@opencode-ai/core/location" +import { Skill } from "@opencode-ai/schema/skill" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const SkillGroup = HttpApiGroup.make("server.skill") .add( HttpApiEndpoint.get("skill.list", "/api/skill", { query: LocationQuery, - success: Location.response(Schema.Array(SkillV2.Info)), + success: Location.response(Schema.Array(Skill.Info)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -25,4 +25,3 @@ export const SkillGroup = HttpApiGroup.make("server.skill") description: "Experimental skill routes.", }), ) - .middleware(LocationMiddleware) 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/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 63ecfad5e0ad..3cde8b8a021f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,7 +9,9 @@ export { LLM } from "./llm" export { Location } from "./location" export { Model } from "./model" export { Permission } from "./permission" +export { PermissionSaved } from "./permission-saved" export { Project } from "./project" +export { ProjectCopy } from "./project-copy" export { Provider } from "./provider" export { Reference } from "./reference" export { Revert } from "./revert" @@ -17,6 +19,9 @@ export { Session } from "./session" export { SessionInput } from "./session-input" export { SessionMessage } from "./session-message" export { Skill } from "./skill" +export { Pty } from "./pty" +export { PtyTicket } from "./pty-ticket" +export { Question } from "./question" export { Workspace } from "./workspace" export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt" export * from "./schema" 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..927a493069e1 --- /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 { ProjectID } from "./project-id" +import { withStatics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("PermissionSaved.ID"), + withStatics((schema) => ({ create: () => schema.make("psv_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + projectID: ProjectID, + action: Schema.String, + resource: Schema.String, +}).annotate({ identifier: "PermissionSaved.Info" }) +export type Info = typeof Info.Type diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts new file mode 100644 index 000000000000..d90c323d5961 --- /dev/null +++ b/packages/schema/src/project-copy.ts @@ -0,0 +1,29 @@ +export * as ProjectCopy from "./project-copy" + +import { Schema } from "effect" +import { ProjectID } from "./project-id" +import { AbsolutePath } from "./schema" + +export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export type StrategyID = typeof StrategyID.Type + +export const CreateInput = Schema.Struct({ + projectID: ProjectID, + strategy: StrategyID, + sourceDirectory: AbsolutePath, + directory: AbsolutePath, + name: Schema.optional(Schema.String), +}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export type CreateInput = typeof CreateInput.Type + +export const RemoveInput = Schema.Struct({ + projectID: ProjectID, + directory: AbsolutePath, + force: Schema.Boolean, +}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export type RemoveInput = typeof RemoveInput.Type + +export const Copy = Schema.Struct({ + directory: AbsolutePath, +}).annotate({ identifier: "ProjectCopy.Copy" }) +export type Copy = typeof Copy.Type 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..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")) @@ -33,3 +33,23 @@ const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegat const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } export const PtyEvent = Event + +export const CreateInput = Schema.Struct({ + command: Schema.optional(Schema.String), + args: Schema.optional(Schema.Array(Schema.String)), + cwd: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type CreateInput = typeof CreateInput.Type + +export const UpdateInput = Schema.Struct({ + title: Schema.optional(Schema.String), + size: Schema.optional( + Schema.Struct({ + rows: PositiveInt, + cols: PositiveInt, + }), + ), +}) +export type UpdateInput = typeof UpdateInput.Type 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..981ad28db93d 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,56 +1,8 @@ -import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { SchemaErrorMiddleware } from "./middleware/schema-error" -import { MessageGroup } from "./groups/message" -import { ModelGroup } from "./groups/model" -import { ProviderGroup } from "./groups/provider" -import { SessionGroup } from "./groups/session" -import { PermissionGroup } from "./groups/permission" -import { FileSystemGroup } from "./groups/fs" -import { CommandGroup } from "./groups/command" -import { SkillGroup } from "./groups/skill" -import { EventGroup, makeEventGroup } from "./groups/event" -import type { Definition } from "@opencode-ai/core/event" -import { AgentGroup } from "./groups/agent" -import { HealthGroup } from "./groups/health" -import { PtyGroup } from "./groups/pty" -import { QuestionGroup } from "./groups/question" -import { ReferenceGroup } from "./groups/reference" -import { Authorization } from "./middleware/authorization" -import { LocationGroup } from "./groups/location" -import { IntegrationGroup } from "./groups/integration" -import { CredentialGroup } from "./groups/credential" -import { ProjectCopyGroup } from "./groups/project-copy" +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "./location" +import { SessionLocationMiddleware } from "./middleware/session-location" -const makeApiFromGroup = (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 const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) diff --git a/packages/server/src/groups/agent.ts b/packages/server/src/groups/agent.ts deleted file mode 100644 index c9dd5398c571..000000000000 --- a/packages/server/src/groups/agent.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AgentV2 } from "@opencode-ai/core/agent" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const AgentGroup = HttpApiGroup.make("server.agent") - .add( - HttpApiEndpoint.get("agent.list", "/api/agent", { - query: LocationQuery, - success: Location.response(Schema.Array(AgentV2.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.agent.list", - summary: "List agents", - description: "Retrieve currently registered agents.", - }), - ), - ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts deleted file mode 100644 index 7e8c02902047..000000000000 --- a/packages/server/src/groups/event.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest" -import { Location } from "@opencode-ai/core/location" -import type { Definition } from "@opencode-ai/core/event" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const fields = { - id: EventV2.ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), - location: Schema.optional(Location.Ref), -} - -const schema = >(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 diff --git a/packages/server/src/groups/permission.ts b/packages/server/src/groups/permission.ts deleted file mode 100644 index a5b9e89a960a..000000000000 --- a/packages/server/src/groups/permission.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { PermissionV2 } from "@opencode-ai/core/permission" -import { Location } from "@opencode-ai/core/location" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { ProjectV2 } from "@opencode-ai/core/project" -import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { PermissionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const PermissionGroup = HttpApiGroup.make("server.permission") - .add( - HttpApiEndpoint.get("permission.request.list", "/api/permission/request", { - query: LocationQuery, - success: Location.response(Schema.Array(PermissionV2.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.request.list", - summary: "List pending permission requests", - description: "Retrieve pending permission requests for a location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", { - query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }), - success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.saved.list", - summary: "List saved permissions", - description: "Retrieve saved permissions, optionally filtered by project.", - }), - ), - ) - .add( - HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { - params: { id: PermissionSaved.ID }, - success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.saved.remove", - summary: "Remove saved permission", - description: "Remove a saved permission by ID.", - }), - ), - ) - .middleware(LocationMiddleware) - .add( - HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.permission.list", - summary: "List session permission requests", - description: "Retrieve pending permission requests owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { - params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID }, - payload: Schema.Struct({ - reply: PermissionV2.Reply, - message: Schema.String.pipe(Schema.optional), - }), - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, PermissionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.permission.reply", - summary: "Reply to pending permission request", - description: "Respond to a pending permission request owned by a session.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/server/src/groups/question.ts b/packages/server/src/groups/question.ts deleted file mode 100644 index cb8932129e7b..000000000000 --- a/packages/server/src/groups/question.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { QuestionV2 } from "@opencode-ai/core/question" -import { Location } from "@opencode-ai/core/location" -import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { QuestionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const QuestionGroup = HttpApiGroup.make("server.question") - .add( - HttpApiEndpoint.get("question.request.list", "/api/question/request", { - query: LocationQuery, - success: Location.response(Schema.Array(QuestionV2.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.question.request.list", - summary: "List pending question requests", - description: "Retrieve pending question requests for a location.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) - .middleware(LocationMiddleware) - .add( - HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.list", - summary: "List session question requests", - description: "Retrieve pending question requests owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { - params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, - payload: QuestionV2.Reply, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, QuestionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.reply", - summary: "Reply to pending question request", - description: "Answer a pending question request owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { - params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, QuestionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.reject", - summary: "Reject pending question request", - description: "Reject a pending question request owned by a session.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), - ) diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts deleted file mode 100644 index 0df59e0c44b9..000000000000 --- a/packages/server/src/groups/session.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionInput } from "@opencode-ai/core/session/input" -import { Prompt } from "@opencode-ai/core/session/prompt" -import { SessionV2 } from "@opencode-ai/core/session" -import { ProjectV2 } from "@opencode-ai/core/project" -import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" -import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Schema, Struct } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { - ConflictError, - InvalidCursorError, - InvalidRequestError, - MessageNotFoundError, - ServiceUnavailableError, - SessionNotFoundError, - UnknownError, -} from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { AgentV2 } from "@opencode-ai/core/agent" -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" - -const SessionsQueryFields = { - workspace: WorkspaceV2.ID.pipe(Schema.optional), - limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - search: Schema.optional(Schema.String), -} - -const SessionsDirectoryQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath, -}) - -const SessionsProjectQuery = Schema.Struct({ - ...SessionsQueryFields, - project: ProjectV2.ID, - subpath: RelativePath.pipe(Schema.optional), -}) - -const SessionsAllQuery = Schema.Struct(SessionsQueryFields) - -const withCursor = (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.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { - params: { sessionID: SessionV2.ID }, - payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), - success: Schema.Struct({ data: SessionV2.RevertState }), - error: [MessageNotFoundError, SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.revert.stage", - summary: "Stage session revert", - description: "Stage or move a reversible session boundary and optionally apply its file changes.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { - params: { sessionID: SessionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })), - ) - .add( - HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", { - params: { sessionID: SessionV2.ID }, - success: HttpApiSchema.NoContent, - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" })), - ) - .add( - HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), - error: [SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.context", - summary: "Get session context", - description: "Retrieve the active context messages for a session (all messages after the last compaction).", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "sessions", - description: "Experimental session routes.", - }), - ) 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 3be2c9d5ea9d..c1511e3c62cd 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 "../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 b09d52bee930..bf41e79f835b 100644 --- a/packages/server/src/handlers/command.ts +++ b/packages/server/src/handlers/command.ts @@ -1,8 +1,7 @@ import { CommandV2 } from "@opencode-ai/core/command" -import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) => handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))), diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index 963bf51d852f..c7d1d43bab7a 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 "../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 d7c651e847a6..6c29d5877607 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -2,8 +2,8 @@ import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { InvalidRequestError } from "../errors" -import { response } from "../groups/location" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { response } from "../location" const authorize = (effect: Effect.Effect) => effect.pipe( diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index ddb6f9877a19..93734c628d8e 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" const DefaultMessagesLimit = 50 diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 542717351b9a..36639ae7b1e6 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 "../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 34fa3d428e5e..b54d6c67d1a3 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -4,8 +4,8 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { PermissionNotFoundError } from "../errors" -import { response } from "../groups/location" +import { PermissionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" function missingRequest(id: PermissionV2.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts index 91b48fb1d578..3733db6771a4 100644 --- a/packages/server/src/handlers/project-copy.ts +++ b/packages/server/src/handlers/project-copy.ts @@ -4,7 +4,7 @@ import { Git } from "@opencode-ai/core/git" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProjectCopyError } from "../groups/project-copy" +import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy" export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => Effect.succeed( diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 4d84179ed36c..c3f25ab0dfc3 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,10 +1,9 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProviderNotFoundError } from "../errors" -import { response } from "../groups/location" +import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => Effect.gen(function* () { diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index a59afb3b3160..cda2cf43e9ea 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -8,9 +8,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" -import { ForbiddenError, PtyNotFoundError } from "../errors" -import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty" -import { response } from "../groups/location" +import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@opencode-ai/protocol/groups/pty" +import { response } from "../location" import { PtyEnvironment } from "../pty-environment" const ticketScope = Effect.gen(function* () { diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 151557c508d0..954afe0df587 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -2,8 +2,8 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { QuestionNotFoundError } from "../errors" -import { response } from "../groups/location" +import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" function missingRequest(id: QuestionV2.ID) { return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` }) diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts index 894f76aa8e66..543c9790dc87 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 "../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/session.ts b/packages/server/src/handlers/session.ts index ecc78b2067a5..2ac6b1774261 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -2,7 +2,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { DateTime, Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionsCursor } from "../groups/session" +import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, @@ -10,7 +10,7 @@ import { ServiceUnavailableError, SessionNotFoundError, UnknownError, -} from "../errors" +} from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index cf3f92c2a855..8ffeaca8ea21 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 "../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 52% rename from packages/server/src/groups/location.ts rename to packages/server/src/location.ts index 6979c6703f34..483a0733fd45 100644 --- a/packages/server/src/groups/location.ts +++ b/packages/server/src/location.ts @@ -1,35 +1,16 @@ import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FileSystem } from "@opencode-ai/core/filesystem" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" import { HttpServerRequest } from "effect/unstable/http" -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { HttpApiMiddleware } from "effect/unstable/httpapi" -export const LocationQuery = Schema.Struct({ - location: Schema.optional( - Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - }), - ), -}).annotate({ identifier: "LocationQuery" }) +export type LocationServices = Layer.Success> -export const locationQueryOpenApi = OpenApi.annotations({ - transform: (operation) => { - const parameters = operation.parameters - if (!Array.isArray(parameters)) return operation - return { - ...operation, - parameters: parameters.map((parameter) => - parameter?.name === "location" && parameter?.in === "query" - ? { ...parameter, style: "deepObject", explode: true } - : parameter, - ), - } - }, -}) +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", +) {} export function response(data: Effect.Effect) { return Effect.gen(function* () { @@ -45,32 +26,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/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..d4e691954b68 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -9,14 +9,12 @@ import { eq } from "drizzle-orm" import { Effect, Layer, Schema } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError, SessionNotFoundError } from "../errors" -import type { LocationServices } from "../groups/location" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import type { LocationServices } from "../location" export class SessionLocationMiddleware extends HttpApiMiddleware.Service< SessionLocationMiddleware, - { - provides: LocationServices - } + { provides: LocationServices } >()("@opencode/HttpApiSessionLocation", { error: [InvalidRequestError, SessionNotFoundError], }) {}