diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts new file mode 100644 index 0000000000..1df3b0a3ef --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -0,0 +1,45 @@ +import { HttpApi } from "effect/unstable/httpapi" +import { ConfigApi } from "./groups/config" +import { ControlApi } from "./groups/control" +import { EventApi } from "./event" +import { ExperimentalApi } from "./groups/experimental" +import { FileApi } from "./groups/file" +import { GlobalApi } from "./groups/global" +import { InstanceApi } from "./groups/instance" +import { McpApi } from "./groups/mcp" +import { PermissionApi } from "./groups/permission" +import { ProjectApi } from "./groups/project" +import { ProviderApi } from "./groups/provider" +import { PtyApi, PtyConnectApi } from "./groups/pty" +import { QuestionApi } from "./groups/question" +import { SessionApi } from "./groups/session" +import { SyncApi } from "./groups/sync" +import { TuiApi } from "./groups/tui" +import { WorkspaceApi } from "./groups/workspace" + +export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) + +export const InstanceHttpApi = HttpApi.make("opencode-instance") + .addHttpApi(ConfigApi) + .addHttpApi(ExperimentalApi) + .addHttpApi(FileApi) + .addHttpApi(InstanceApi) + .addHttpApi(McpApi) + .addHttpApi(ProjectApi) + .addHttpApi(PtyApi) + .addHttpApi(QuestionApi) + .addHttpApi(PermissionApi) + .addHttpApi(ProviderApi) + .addHttpApi(SessionApi) + .addHttpApi(SyncApi) + .addHttpApi(TuiApi) + .addHttpApi(WorkspaceApi) + +export const OpenCodeHttpApi = HttpApi.make("opencode") + .addHttpApi(RootHttpApi) + .addHttpApi(EventApi) + .addHttpApi(InstanceHttpApi) + .addHttpApi(PtyConnectApi) + +export type RootHttpApiType = typeof RootHttpApi +export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts deleted file mode 100644 index ce6d84241e..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/global.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { Config } from "@/config/config" -import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" -import { Installation } from "@/installation" -import { Instance } from "@/project/instance" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import * as Log from "@opencode-ai/core/util/log" -import { Effect, Queue, Schema } from "effect" -import * as Stream from "effect/Stream" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import * as Sse from "effect/unstable/encoding/Sse" - -const log = Log.create({ service: "server" }) - -const GlobalHealth = Schema.Struct({ - healthy: Schema.Literal(true), - version: Schema.String, -}).annotate({ identifier: "GlobalHealth" }) - -const GlobalEventSchema = Schema.Struct({ - directory: Schema.String, - project: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - payload: Schema.Unknown, -}).annotate({ identifier: "GlobalEvent" }) - -const GlobalUpgradeInput = Schema.Struct({ - target: Schema.optional(Schema.String), -}).annotate({ identifier: "GlobalUpgradeInput" }) - -const GlobalUpgradeResult = Schema.Union([ - Schema.Struct({ - success: Schema.Literal(true), - version: Schema.String, - }), - Schema.Struct({ - success: Schema.Literal(false), - error: Schema.String, - }), -]).annotate({ identifier: "GlobalUpgradeResult" }) - -export const GlobalPaths = { - health: "/global/health", - event: "/global/event", - config: "/global/config", - dispose: "/global/dispose", - upgrade: "/global/upgrade", -} as const - -export const GlobalApi = HttpApi.make("global").add( - HttpApiGroup.make("global") - .add( - HttpApiEndpoint.get("health", GlobalPaths.health, { - success: GlobalHealth, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.health", - summary: "Get health", - description: "Get health information about the OpenCode server.", - }), - ), - HttpApiEndpoint.get("event", GlobalPaths.event, { - success: GlobalEventSchema, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.event", - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - }), - ), - HttpApiEndpoint.get("configGet", GlobalPaths.config, { - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.get", - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { - payload: Config.Info, - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.update", - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.dispose", - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - }), - ), - HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { - payload: GlobalUpgradeInput, - success: GlobalUpgradeResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.upgrade", - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), -) - -function eventData(data: unknown): Sse.Event { - return { - _tag: "Event", - event: "message", - id: undefined, - data: JSON.stringify(data), - } -} - -function parseBody(body: string) { - try { - return JSON.parse(body || "{}") as unknown - } catch { - return undefined - } -} - -function eventResponse() { - log.info("global event connected") - const events = Stream.callback((queue) => { - const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event) - return Effect.acquireRelease( - Effect.sync(() => GlobalBus.on("event", handler)), - () => Effect.sync(() => GlobalBus.off("event", handler)), - ) - }) - const heartbeat = Stream.tick("10 seconds").pipe( - Stream.drop(1), - Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), - ) - - return HttpServerResponse.stream( - Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))), - ), - { - contentType: "text/event-stream", - headers: { - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, - ) -} - -export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) => - Effect.gen(function* () { - const config = yield* Config.Service - const installation = yield* Installation.Service - - const health = Effect.fn("GlobalHttpApi.health")(function* () { - return { healthy: true as const, version: InstallationVersion } - }) - - const event = Effect.fn("GlobalHttpApi.event")(function* () { - return eventResponse() - }) - - const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { - return yield* config.getGlobal() - }) - - const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) - }) - - const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) - return true - }) - - const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { - const method = yield* installation.method() - if (method === "unknown") { - return { - status: 400, - body: { success: false as const, error: "Unknown installation method" }, - } - } - const target = ctx.payload.target || (yield* installation.latest(method)) - const result = yield* installation.upgrade(method, target).pipe( - Effect.as({ status: 200, body: { success: true as const, version: target } }), - Effect.catch((err) => - Effect.succeed({ - status: 500, - body: { - success: false as const, - error: err instanceof Error ? err.message : String(err), - }, - }), - ), - ) - if (!result.body.success) return result - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return result - }) - - const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const json = parseBody(body) - if (json === undefined) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( - Effect.map((payload) => ({ valid: true as const, payload })), - Effect.catch(() => Effect.succeed({ valid: false as const })), - ) - if (!payload.valid) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const result = yield* upgrade({ payload: payload.payload }) - return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) - }) - - return handlers - .handle("health", health) - .handleRaw("event", event) - .handle("configGet", configGet) - .handle("configUpdate", configUpdate) - .handle("dispose", dispose) - .handleRaw("upgrade", upgradeRaw) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/config.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index eef825967b..ec88567c57 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,10 +1,8 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" -import * as InstanceState from "@/effect/instance-state" -import { Effect } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForDisposal } from "./lifecycle" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/config" @@ -47,6 +45,7 @@ export const ConfigApi = HttpApi.make("config") description: "Experimental HttpApi config routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -56,30 +55,3 @@ export const ConfigApi = HttpApi.make("config") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) => - Effect.gen(function* () { - const providerSvc = yield* Provider.Service - const configSvc = yield* Config.Service - - const get = Effect.fn("ConfigHttpApi.get")(function* () { - return yield* configSvc.get() - }) - - const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) - yield* markInstanceForDisposal(yield* InstanceState.context) - return ctx.payload - }) - - const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* providerSvc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }) - - return handlers.handle("get", get).handle("update", update).handle("providers", providers) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts similarity index 63% rename from packages/opencode/src/server/routes/instance/httpapi/control.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 718629db71..34edd59341 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -1,8 +1,7 @@ import { Auth } from "@/auth" import { ProviderID } from "@/provider/schema" -import * as Log from "@opencode-ai/core/util/log" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const AuthParams = Schema.Struct({ providerID: ProviderID, @@ -13,7 +12,7 @@ const LogQuery = Schema.Struct({ workspace: Schema.optional(Schema.String), }) -const LogInput = Schema.Struct({ +export const LogInput = Schema.Struct({ service: Schema.String.annotate({ description: "Service name for the log entry" }), level: Schema.Union([ Schema.Literal("debug"), @@ -70,30 +69,3 @@ export const ControlApi = HttpApi.make("control").add( ) .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), ) - -export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) => - Effect.gen(function* () { - const auth = yield* Auth.Service - - const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { - params: { providerID: ProviderID } - payload: Auth.Info - }) { - yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) - return true - }) - - const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { - yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) - return true - }) - - const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { - const logger = Log.create({ service: ctx.payload.service }) - logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) - return true - }) - - return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts similarity index 56% rename from packages/opencode/src/server/routes/instance/httpapi/experimental.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index cc39c7604b..5070e0a2ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -1,24 +1,17 @@ -import { Account } from "@/account/account" import { AccountID, OrgID } from "@/account/schema" -import { Agent } from "@/agent/agent" -import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" -import { Project } from "@/project/project" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" -import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" import { Worktree } from "@/worktree" -import { Effect, Option, Schema, SchemaGetter } from "effect" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optionalKey(Schema.String), - switchableOrgCount: Schema.Number, + switchableOrgCount: Schema.Finite, }).annotate({ identifier: "ConsoleState" }) const ConsoleOrgOption = Schema.Struct({ @@ -34,7 +27,7 @@ const ConsoleOrgList = Schema.Struct({ orgs: Schema.Array(ConsoleOrgOption), }).annotate({ identifier: "ConsoleOrgList" }) -const ConsoleSwitchPayload = Schema.Struct({ +export const ConsoleSwitchPayload = Schema.Struct({ accountID: AccountID, orgID: OrgID, }).annotate({ identifier: "ConsoleSwitchInput" }) @@ -46,7 +39,7 @@ const ToolListItem = Schema.Struct({ parameters: Schema.Record(Schema.String, Schema.Any), }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) -const ToolListQuery = Schema.Struct({ +export const ToolListQuery = Schema.Struct({ provider: ProviderID, model: ModelID, }) @@ -58,7 +51,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( }), ) const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" }) -const SessionListQuery = Schema.Struct({ +export const SessionListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), @@ -200,6 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "Experimental HttpApi read-only routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -209,143 +203,3 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) => - Effect.gen(function* () { - const account = yield* Account.Service - const agents = yield* Agent.Service - const config = yield* Config.Service - const mcp = yield* MCP.Service - const project = yield* Project.Service - const registry = yield* ToolRegistry.Service - const worktreeSvc = yield* Worktree.Service - - const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { - const [state, groups] = yield* Effect.all( - [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - return { - consoleManagedProviders: state.consoleManagedProviders, - ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }) - - const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { - const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - const info = Option.getOrUndefined(active) - return { - orgs: groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ), - } - }) - - const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { - payload: typeof ConsoleSwitchPayload.Type - }) { - yield* account - .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { - const list = yield* registry.tools({ - providerID: ctx.query.provider, - modelID: ctx.query.model, - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - return list.map((item) => ({ - id: item.id, - description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), - })) - }) - - const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { - return yield* registry.ids() - }) - - const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { - const ctx = yield* InstanceState.context - return yield* project.sandboxes(ctx.project.id) - }) - - const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { - payload: Worktree.CreateInput | undefined - }) { - return yield* worktreeSvc.create(ctx.payload) - }) - - const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { - payload: Worktree.RemoveInput - }) { - const ctx = yield* InstanceState.context - yield* worktreeSvc.remove(input.payload) - yield* project.removeSandbox(ctx.project.id, input.payload.directory) - return true - }) - - const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { - payload: Worktree.ResetInput - }) { - yield* worktreeSvc.reset(ctx.payload) - return true - }) - - const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { - const limit = ctx.query.limit ?? 100 - const sessions = Array.from( - Session.listGlobal({ - directory: ctx.query.directory, - roots: ctx.query.roots, - start: ctx.query.start, - cursor: ctx.query.cursor, - search: ctx.query.search, - limit: limit + 1, - archived: ctx.query.archived, - }), - ) - const list = sessions.length > limit ? sessions.slice(0, limit) : sessions - return HttpServerResponse.jsonUnsafe(list, { - headers: - sessions.length > limit && list.length > 0 - ? { "x-next-cursor": String(list[list.length - 1].time.updated) } - : undefined, - }) - }) - - const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { - return yield* mcp.resources() - }) - - return handlers - .handle("console", getConsole) - .handle("consoleOrgs", listConsoleOrgs) - .handle("consoleSwitch", switchConsole) - .handle("tool", tool) - .handle("toolIDs", toolIDs) - .handle("worktree", worktree) - .handle("worktreeCreate", worktreeCreate) - .handle("worktreeRemove", worktreeRemove) - .handle("worktreeReset", worktreeReset) - .handle("session", session) - .handle("resource", resource) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts similarity index 63% rename from packages/opencode/src/server/routes/instance/httpapi/file.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index df525680ae..e80281e50f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -1,20 +1,20 @@ import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" -import * as InstanceState from "@/effect/instance-state" import { LSP } from "@/lsp/lsp" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" -const FileQuery = Schema.Struct({ +export const FileQuery = Schema.Struct({ path: Schema.String, }) -const FindTextQuery = Schema.Struct({ +export const FindTextQuery = Schema.Struct({ pattern: Schema.String, }) -const FindFileQuery = Schema.Struct({ +export const FindFileQuery = Schema.Struct({ query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -23,7 +23,7 @@ const FindFileQuery = Schema.Struct({ ), }) -const FindSymbolQuery = Schema.Struct({ +export const FindSymbolQuery = Schema.Struct({ query: Schema.String, }) @@ -106,6 +106,7 @@ export const FileApi = HttpApi.make("file") description: "Experimental HttpApi file routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -115,51 +116,3 @@ export const FileApi = HttpApi.make("file") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) => - Effect.gen(function* () { - const svc = yield* File.Service - const ripgrep = yield* Ripgrep.Service - - const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { - return (yield* ripgrep - .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) - .pipe(Effect.orDie)).items - }) - - const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { - query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } - }) { - return yield* svc.search({ - query: ctx.query.query, - limit: ctx.query.limit ?? 10, - dirs: ctx.query.dirs !== "false", - type: ctx.query.type, - }) - }) - - const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { - return [] - }) - - const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { - return yield* svc.list(ctx.query.path) - }) - - const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { - return yield* svc.read(ctx.query.path) - }) - - const status = Effect.fn("FileHttpApi.status")(function* () { - return yield* svc.status() - }) - - return handlers - .handle("findText", findText) - .handle("findFile", findFile) - .handle("findSymbol", findSymbol) - .handle("list", list) - .handle("content", content) - .handle("status", status) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts new file mode 100644 index 0000000000..f1fe23cf37 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -0,0 +1,101 @@ +import { Config } from "@/config/config" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const GlobalHealth = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}).annotate({ identifier: "GlobalHealth" }) + +const GlobalEventSchema = Schema.Struct({ + directory: Schema.String, + project: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + payload: Schema.Unknown, +}).annotate({ identifier: "GlobalEvent" }) + +export const GlobalUpgradeInput = Schema.Struct({ + target: Schema.optional(Schema.String), +}).annotate({ identifier: "GlobalUpgradeInput" }) + +const GlobalUpgradeResult = Schema.Union([ + Schema.Struct({ + success: Schema.Literal(true), + version: Schema.String, + }), + Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, + }), +]).annotate({ identifier: "GlobalUpgradeResult" }) + +export const GlobalPaths = { + health: "/global/health", + event: "/global/event", + config: "/global/config", + dispose: "/global/dispose", + upgrade: "/global/upgrade", +} as const + +export const GlobalApi = HttpApi.make("global").add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: GlobalHealth, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.get("event", GlobalPaths.event, { + success: GlobalEventSchema, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.event", + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + }), + ), + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: Config.Info, + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradeInput, + success: GlobalUpgradeResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts similarity index 63% rename from packages/opencode/src/server/routes/instance/httpapi/instance.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 8c471c12a0..595d6376c2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -1,15 +1,13 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { Format } from "@/format" -import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" -import * as InstanceState from "@/effect/instance-state" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForDisposal } from "./lifecycle" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const PathInfo = Schema.Struct({ home: Schema.String, @@ -19,7 +17,7 @@ const PathInfo = Schema.Struct({ directory: Schema.String, }).annotate({ identifier: "Path" }) -const VcsDiffQuery = Schema.Struct({ +export const VcsDiffQuery = Schema.Struct({ mode: Vcs.Mode, }) @@ -130,6 +128,7 @@ export const InstanceApi = HttpApi.make("instance") description: "Experimental HttpApi instance read routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -139,70 +138,3 @@ export const InstanceApi = HttpApi.make("instance") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) => - Effect.gen(function* () { - const agent = yield* Agent.Service - const command = yield* Command.Service - const format = yield* Format.Service - const lsp = yield* LSP.Service - const skill = yield* Skill.Service - const vcs = yield* Vcs.Service - - const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { - yield* markInstanceForDisposal(yield* InstanceState.context) - return true - }) - - const getPath = Effect.fn("InstanceHttpApi.path")(function* () { - const ctx = yield* InstanceState.context - return { - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: ctx.worktree, - directory: ctx.directory, - } - }) - - const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) - return { branch, default_branch } - }) - - const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { - return yield* vcs.diff(ctx.query.mode) - }) - - const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { - return yield* command.list() - }) - - const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { - return yield* agent.list() - }) - - const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { - return yield* skill.all() - }) - - const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { - return yield* lsp.status() - }) - - const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { - return yield* format.status() - }) - - return handlers - .handle("dispose", dispose) - .handle("path", getPath) - .handle("vcs", getVcs) - .handle("vcsDiff", getVcsDiff) - .handle("command", getCommand) - .handle("agent", getAgent) - .handle("skill", getSkill) - .handle("lsp", getLsp) - .handle("formatter", getFormatter) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts similarity index 59% rename from packages/opencode/src/server/routes/instance/httpapi/mcp.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index f5552f6f2f..c327b3bd0a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -1,26 +1,27 @@ import { MCP } from "@/mcp" import { ConfigMCP } from "@/config/mcp" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" -const AddPayload = Schema.Struct({ +export const AddPayload = Schema.Struct({ name: Schema.String, config: ConfigMCP.Info, }).annotate({ identifier: "McpAddInput" }) -const StatusMap = Schema.Record(Schema.String, MCP.Status) -const AuthStartResponse = Schema.Struct({ +export const StatusMap = Schema.Record(Schema.String, MCP.Status) +export const AuthStartResponse = Schema.Struct({ authorizationUrl: Schema.String, oauthState: Schema.String, }).annotate({ identifier: "McpAuthStartResponse" }) -const AuthCallbackPayload = Schema.Struct({ +export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, }).annotate({ identifier: "McpAuthCallbackInput" }) -const AuthRemoveResponse = Schema.Struct({ +export const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), }).annotate({ identifier: "McpAuthRemoveResponse" }) -class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( +export class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( { error: Schema.String }, { httpApiStatus: 400 }, ) {} @@ -127,6 +128,7 @@ export const McpApi = HttpApi.make("mcp") description: "Experimental HttpApi MCP routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -136,66 +138,3 @@ export const McpApi = HttpApi.make("mcp") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) => - Effect.gen(function* () { - const mcp = yield* MCP.Service - - const status = Effect.fn("McpHttpApi.status")(function* () { - return yield* mcp.status() - }) - - const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { - const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status - return yield* Schema.decodeUnknownEffect(StatusMap)( - "status" in result ? { [ctx.payload.name]: result } : result, - ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - }) - - const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.startAuth(ctx.params.name) - }) - - const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { - params: { name: string } - payload: typeof AuthCallbackPayload.Type - }) { - return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) - }) - - const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.authenticate(ctx.params.name) - }) - - const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { - yield* mcp.removeAuth(ctx.params.name) - return { success: true as const } - }) - - const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { - yield* mcp.connect(ctx.params.name) - return true - }) - - const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { - yield* mcp.disconnect(ctx.params.name) - return true - }) - - return handlers - .handle("status", status) - .handle("add", add) - .handle("authStart", authStart) - .handle("authCallback", authCallback) - .handle("authAuthenticate", authAuthenticate) - .handle("authRemove", authRemove) - .handle("connect", connect) - .handle("disconnect", disconnect) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts similarity index 62% rename from packages/opencode/src/server/routes/instance/httpapi/permission.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 357c832990..b93d3f3773 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -1,8 +1,9 @@ import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/permission" @@ -37,6 +38,7 @@ export const PermissionApi = HttpApi.make("permission") description: "Experimental HttpApi permission routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -46,27 +48,3 @@ export const PermissionApi = HttpApi.make("permission") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) => - Effect.gen(function* () { - const svc = yield* Permission.Service - - const list = Effect.fn("PermissionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { - params: { requestID: PermissionID } - payload: Permission.ReplyBody - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - reply: ctx.payload.reply, - message: ctx.payload.message, - }) - return true - }) - - return handlers.handle("list", list).handle("reply", reply) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts similarity index 55% rename from packages/opencode/src/server/routes/instance/httpapi/project.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 276798b0b9..d2e7af6d2a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -1,12 +1,9 @@ -import * as InstanceState from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" import { Project } from "@/project/project" -import { InstanceBootstrap } from "@/project/bootstrap" import { ProjectID } from "@/project/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForReload } from "./lifecycle" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/project" @@ -59,6 +56,7 @@ export const ProjectApi = HttpApi.make("project") description: "Experimental HttpApi project routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -68,40 +66,3 @@ export const ProjectApi = HttpApi.make("project") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) => - Effect.gen(function* () { - const svc = yield* Project.Service - - const list = Effect.fn("ProjectHttpApi.list")(function* () { - return yield* svc.list() - }) - - const current = Effect.fn("ProjectHttpApi.current")(function* () { - return (yield* InstanceState.context).project - }) - - const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { - const ctx = yield* InstanceState.context - const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) - if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) - return next - yield* markInstanceForReload(ctx, { - directory: ctx.directory, - worktree: ctx.directory, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) - return next - }) - - const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { - params: { projectID: ProjectID } - payload: Project.UpdatePayload - }) { - return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) - }) - - return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts new file mode 100644 index 0000000000..be9617e867 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -0,0 +1,73 @@ +import { ProviderAuth } from "@/provider/auth" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" + +const root = "/provider" + +export const ProviderApi = HttpApi.make("provider") + .add( + HttpApiGroup.make("provider") + .add( + HttpApiEndpoint.get("list", root, { + success: Provider.ListResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.list", + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + }), + ), + HttpApiEndpoint.get("auth", `${root}/auth`, { + success: ProviderAuth.Methods, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.auth", + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + }), + ), + HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.AuthorizeInput, + success: Schema.UndefinedOr(ProviderAuth.Authorization), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.authorize", + summary: "Start OAuth authorization", + description: "Start the OAuth authorization flow for a provider.", + }), + ), + HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.CallbackInput, + success: Schema.Boolean, + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.callback", + summary: "Handle OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "provider", + description: "Experimental HttpApi provider routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts new file mode 100644 index 0000000000..eb470b24b2 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -0,0 +1,111 @@ +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" + +const root = "/pty" +export const Params = Schema.Struct({ ptyID: PtyID }) +export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const ShellItem = Schema.Struct({ + path: Schema.String, + name: Schema.String, + acceptable: Schema.Boolean, +}) + +export const PtyPaths = { + shells: `${root}/shells`, + list: root, + create: root, + get: `${root}/:ptyID`, + update: `${root}/:ptyID`, + remove: `${root}/:ptyID`, + connect: `${root}/:ptyID/connect`, +} as const + +export const PtyApi = HttpApi.make("pty") + .add( + HttpApiGroup.make("pty") + .add( + HttpApiEndpoint.get("shells", PtyPaths.shells, { success: Schema.Array(ShellItem) }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.shells", + summary: "List available shells", + description: "Get a list of available shells on the system.", + }), + ), + HttpApiEndpoint.get("list", PtyPaths.list, { success: Schema.Array(Pty.Info) }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.list", + summary: "List PTY sessions", + description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + }), + ), + HttpApiEndpoint.post("create", PtyPaths.create, { payload: Pty.CreateInput, success: Pty.Info }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.create", + summary: "Create PTY session", + description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + }), + ), + HttpApiEndpoint.get("get", PtyPaths.get, { + params: { ptyID: PtyID }, + success: Pty.Info, + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.get", + summary: "Get PTY session", + description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + }), + ), + HttpApiEndpoint.put("update", PtyPaths.update, { + params: { ptyID: PtyID }, + payload: Pty.UpdateInput, + success: Pty.Info, + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.update", + summary: "Update PTY session", + description: "Update properties of an existing pseudo-terminal (PTY) session.", + }), + ), + HttpApiEndpoint.delete("remove", PtyPaths.remove, { + params: { ptyID: PtyID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.remove", + summary: "Remove PTY session", + description: "Remove and terminate a specific pseudo-terminal (PTY) session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const PtyConnectApi = HttpApi.make("pty-connect").add( + HttpApiGroup.make("pty-connect") + .add( + HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connect", + summary: "Connect to PTY session", + description: + "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts similarity index 63% rename from packages/opencode/src/server/routes/instance/httpapi/question.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index 2169e17c5c..fa9a838108 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -1,8 +1,9 @@ import { Question } from "@/question" import { QuestionID } from "@/question/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/question" @@ -47,6 +48,7 @@ export const QuestionApi = HttpApi.make("question") description: "Question routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -56,31 +58,3 @@ export const QuestionApi = HttpApi.make("question") description: "Effect HttpApi surface for instance routes.", }), ) - -export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) => - Effect.gen(function* () { - const svc = yield* Question.Service - - const list = Effect.fn("QuestionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: QuestionID } - payload: Question.Reply - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true - }) - - const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { - yield* svc.reject(ctx.params.requestID) - return true - }) - - return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts new file mode 100644 index 0000000000..c189e2078d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -0,0 +1,415 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { Session } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { Snapshot } from "@/snapshot" +import { Schema, SchemaGetter, Struct } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" + +const root = "/session" +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) +export const ListQuery = Schema.Struct({ + directory: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + limit: Schema.optional(Schema.NumberFromString), +}) +export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const MessagesQuery = Schema.Struct({ + limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), + before: Schema.optional(Schema.String), +}) +export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) +export const UpdatePayload = Schema.Struct({ + title: Schema.optional(Schema.String), + permission: Schema.optional(Permission.Ruleset), + time: Schema.optional( + Schema.Struct({ + archived: Schema.optional(Schema.Finite), + }), + ), +}).annotate({ identifier: "SessionUpdateInput" }) +export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({ + identifier: "SessionForkInput", +}) +export const InitPayload = Schema.Struct({ + modelID: ModelID, + providerID: ProviderID, + messageID: MessageID, +}).annotate({ identifier: "SessionInitInput" }) +export const SummarizePayload = Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + auto: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "SessionSummarizeInput" }) +export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({ + identifier: "SessionPromptInput", +}) +export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({ + identifier: "SessionCommandInput", +}) +export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({ + identifier: "SessionShellInput", +}) +export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({ + identifier: "SessionRevertInput", +}) +export const PermissionResponsePayload = Schema.Struct({ + response: Permission.Reply, +}).annotate({ identifier: "SessionPermissionResponseInput" }) + +export const SessionPaths = { + list: root, + status: `${root}/status`, + get: `${root}/:sessionID`, + children: `${root}/:sessionID/children`, + todo: `${root}/:sessionID/todo`, + diff: `${root}/:sessionID/diff`, + messages: `${root}/:sessionID/message`, + message: `${root}/:sessionID/message/:messageID`, + create: root, + remove: `${root}/:sessionID`, + update: `${root}/:sessionID`, + fork: `${root}/:sessionID/fork`, + abort: `${root}/:sessionID/abort`, + share: `${root}/:sessionID/share`, + init: `${root}/:sessionID/init`, + summarize: `${root}/:sessionID/summarize`, + prompt: `${root}/:sessionID/message`, + promptAsync: `${root}/:sessionID/prompt_async`, + command: `${root}/:sessionID/command`, + shell: `${root}/:sessionID/shell`, + revert: `${root}/:sessionID/revert`, + unrevert: `${root}/:sessionID/unrevert`, + permissions: `${root}/:sessionID/permissions/:permissionID`, + deleteMessage: `${root}/:sessionID/message/:messageID`, + deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, + updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, +} as const + +export const SessionApi = HttpApi.make("session") + .add( + HttpApiGroup.make("session") + .add( + HttpApiEndpoint.get("list", SessionPaths.list, { + query: ListQuery, + success: Schema.Array(Session.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.list", + summary: "List sessions", + description: "Get a list of all OpenCode sessions, sorted by most recently updated.", + }), + ), + HttpApiEndpoint.get("status", SessionPaths.status, { + success: StatusMap, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.status", + summary: "Get session status", + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", + }), + ), + HttpApiEndpoint.get("get", SessionPaths.get, { + params: { sessionID: SessionID }, + success: Session.Info, + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.get", + summary: "Get session", + description: "Retrieve detailed information about a specific OpenCode session.", + }), + ), + HttpApiEndpoint.get("children", SessionPaths.children, { + params: { sessionID: SessionID }, + success: Schema.Array(Session.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.children", + summary: "Get session children", + description: "Retrieve all child sessions that were forked from the specified parent session.", + }), + ), + HttpApiEndpoint.get("todo", SessionPaths.todo, { + params: { sessionID: SessionID }, + success: Schema.Array(Todo.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.todo", + summary: "Get session todos", + description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", + }), + ), + HttpApiEndpoint.get("diff", SessionPaths.diff, { + params: { sessionID: SessionID }, + query: DiffQuery, + success: Schema.Array(Snapshot.FileDiff), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.diff", + summary: "Get message diff", + description: "Get the file changes (diff) that resulted from a specific user message in the session.", + }), + ), + HttpApiEndpoint.get("messages", SessionPaths.messages, { + params: { sessionID: SessionID }, + query: MessagesQuery, + success: Schema.Array(MessageV2.WithParts), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.messages", + summary: "Get session messages", + description: "Retrieve all messages in a session, including user prompts and AI responses.", + }), + ), + HttpApiEndpoint.get("message", SessionPaths.message, { + params: { sessionID: SessionID, messageID: MessageID }, + success: MessageV2.WithParts, + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.message", + summary: "Get message", + description: "Retrieve a specific message from a session by its message ID.", + }), + ), + HttpApiEndpoint.post("create", SessionPaths.create, { + payload: [HttpApiSchema.NoContent, Session.CreateInput], + success: Session.Info, + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.create", + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + }), + ), + HttpApiEndpoint.delete("remove", SessionPaths.remove, { + params: { sessionID: SessionID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.delete", + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + }), + ), + HttpApiEndpoint.patch("update", SessionPaths.update, { + params: { sessionID: SessionID }, + payload: UpdatePayload, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.update", + summary: "Update session", + description: "Update properties of an existing session, such as title or other metadata.", + }), + ), + HttpApiEndpoint.post("fork", SessionPaths.fork, { + params: { sessionID: SessionID }, + payload: ForkPayload, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.fork", + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + }), + ), + HttpApiEndpoint.post("abort", SessionPaths.abort, { + params: { sessionID: SessionID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.abort", + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + }), + ), + HttpApiEndpoint.post("init", SessionPaths.init, { + params: { sessionID: SessionID }, + payload: InitPayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.init", + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + }), + ), + HttpApiEndpoint.post("share", SessionPaths.share, { + params: { sessionID: SessionID }, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.share", + summary: "Share session", + description: "Create a shareable link for a session, allowing others to view the conversation.", + }), + ), + HttpApiEndpoint.delete("unshare", SessionPaths.share, { + params: { sessionID: SessionID }, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unshare", + summary: "Unshare session", + description: "Remove the shareable link for a session, making it private again.", + }), + ), + HttpApiEndpoint.post("summarize", SessionPaths.summarize, { + params: { sessionID: SessionID }, + payload: SummarizePayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.summarize", + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + }), + ), + HttpApiEndpoint.post("prompt", SessionPaths.prompt, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: MessageV2.WithParts, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt", + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + }), + ), + HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt_async", + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + }), + ), + HttpApiEndpoint.post("command", SessionPaths.command, { + params: { sessionID: SessionID }, + payload: CommandPayload, + success: MessageV2.WithParts, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.command", + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + }), + ), + HttpApiEndpoint.post("shell", SessionPaths.shell, { + params: { sessionID: SessionID }, + payload: ShellPayload, + success: MessageV2.WithParts, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.shell", + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + }), + ), + HttpApiEndpoint.post("revert", SessionPaths.revert, { + params: { sessionID: SessionID }, + payload: RevertPayload, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.revert", + summary: "Revert message", + description: + "Revert a specific message in a session, undoing its effects and restoring the previous state.", + }), + ), + HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { + params: { sessionID: SessionID }, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unrevert", + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + }), + ), + HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { + params: { sessionID: SessionID, permissionID: PermissionID }, + payload: PermissionResponsePayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.respond", + summary: "Respond to permission", + description: "Approve or deny a permission request from the AI assistant.", + deprecated: true, + }), + ), + HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { + params: { sessionID: SessionID, messageID: MessageID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.deleteMessage", + summary: "Delete message", + description: + "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + }), + ), + HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { + params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "part.delete", + description: "Delete a part from a message.", + }), + ), + HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { + params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + payload: MessageV2.Part, + success: MessageV2.Part, + }).annotateMerge( + OpenApi.annotations({ + identifier: "part.update", + description: "Update a part in a message.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "session", + description: "Experimental HttpApi session routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts similarity index 54% rename from packages/opencode/src/server/routes/instance/httpapi/sync.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 67fcede2f8..27ed732968 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,39 +1,29 @@ -import { startWorkspaceSyncing } from "@/control-plane/workspace" -import * as InstanceState from "@/effect/instance-state" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { and } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { lte } from "drizzle-orm" -import { not } from "drizzle-orm" -import { or } from "drizzle-orm" -import { SyncEvent } from "@/sync" -import { EventTable } from "@/sync/event.sql" import { NonNegativeInt } from "@/util/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/sync" -const ReplayEvent = Schema.Struct({ +export const ReplayEvent = Schema.Struct({ id: Schema.String, aggregateID: Schema.String, seq: NonNegativeInt, type: Schema.String, data: Schema.Record(Schema.String, Schema.Unknown), }).annotate({ identifier: "SyncReplayEvent" }) -const ReplayPayload = Schema.Struct({ +export const ReplayPayload = Schema.Struct({ directory: Schema.String, events: Schema.NonEmptyArray(ReplayEvent), }).annotate({ identifier: "SyncReplayInput" }) -const ReplayResponse = Schema.Struct({ +export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }).annotate({ identifier: "SyncReplayResponse" }) -const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) -const HistoryEvent = Schema.Struct({ +export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) +export const HistoryEvent = Schema.Struct({ id: Schema.String, aggregate_id: Schema.String, - seq: Schema.Number, + seq: Schema.Finite, type: Schema.String, data: Schema.Record(Schema.String, Schema.Unknown), }).annotate({ identifier: "SyncHistoryEvent" }) @@ -87,6 +77,7 @@ export const SyncApi = HttpApi.make("sync") description: "Experimental HttpApi sync routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -97,41 +88,3 @@ export const SyncApi = HttpApi.make("sync") }), ) -export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) => - Effect.gen(function* () { - const start = Effect.fn("SyncHttpApi.start")(function* () { - startWorkspaceSyncing((yield* InstanceState.context).project.id) - return true - }) - - const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { - const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ - id: event.id, - aggregateID: event.aggregateID, - seq: event.seq, - type: event.type, - data: { ...event.data }, - })) - SyncEvent.replayAll(events) - return { sessionID: events[0].aggregateID } - }) - - const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { - const exclude = Object.entries(ctx.payload) - return Database.use((db) => - db - .select() - .from(EventTable) - .where( - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined, - ) - .orderBy(asc(EventTable.seq)) - .all(), - ) - }) - - return handlers.handle("start", start).handle("replay", replay).handle("history", history) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts similarity index 52% rename from packages/opencode/src/server/routes/instance/httpapi/tui.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 38f7651a8a..f43d15a7b2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -1,43 +1,22 @@ -import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" -import { SessionID } from "@/session/schema" -import { SessionTable } from "@/session/session.sql" -import * as Database from "@/storage/db" -import { eq } from "drizzle-orm" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../tui" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/tui" -const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" }) -const TuiRequestPayload = Schema.Struct({ +export const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" }) +export const TuiRequestPayload = Schema.Struct({ path: Schema.String, body: Schema.Unknown, }).annotate({ identifier: "TuiRequest" }) -const TuiPublishPayload = Schema.Union([ - Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }), +export const TuiPublishPayload = Schema.Union([ + Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }), + Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }), + Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }), + Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }), ]).annotate({ identifier: "TuiEventInput" }) -const commandAliases = { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_line_up: "session.line.up", - messages_line_down: "session.line.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", -} as const - export const TuiPaths = { appendPrompt: `${root}/append-prompt`, openHelp: `${root}/open-help`, @@ -173,6 +152,7 @@ export const TuiApi = HttpApi.make("tui") ), ) .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -183,110 +163,3 @@ export const TuiApi = HttpApi.make("tui") }), ) -export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) => - Effect.gen(function* () { - const bus = yield* Bus.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => - bus.publish(TuiEvent.CommandExecute, { command }) - - const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { - payload: typeof TuiEvent.PromptAppend.properties.Type - }) { - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) - return true - }) - - const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { - yield* publishCommand("help.show") - return true - }) - - const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { - yield* publishCommand("session.list") - return true - }) - - const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { - yield* publishCommand("session.list") - return true - }) - - const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { - yield* publishCommand("model.list") - return true - }) - - const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { - yield* publishCommand("prompt.submit") - return true - }) - - const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { - yield* publishCommand("prompt.clear") - return true - }) - - const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { - payload: typeof CommandPayload.Type - }) { - yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) - return true - }) - - const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { - payload: typeof TuiEvent.ToastShow.properties.Type - }) { - yield* bus.publish(TuiEvent.ToastShow, ctx.payload) - return true - }) - - const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { - if (ctx.payload.type === TuiEvent.PromptAppend.type) - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.CommandExecute.type) - yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.SessionSelect.type) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) - return true - }) - - const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { - payload: typeof TuiEvent.SessionSelect.properties.Type - }) { - if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) - const row = yield* Effect.sync(() => - Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), - ), - ) - if (!row) return yield* new HttpApiError.NotFound({}) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) - return true - }) - - const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { - return yield* Effect.promise(() => nextTuiRequest()) - }) - - const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { - submitTuiResponse(ctx.payload) - return true - }) - - return handlers - .handle("appendPrompt", appendPrompt) - .handle("openHelp", openHelp) - .handle("openSessions", openSessions) - .handle("openThemes", openThemes) - .handle("openModels", openModels) - .handle("submitPrompt", submitPrompt) - .handle("clearPrompt", clearPrompt) - .handle("executeCommand", executeCommand) - .handle("showToast", showToast) - .handle("publish", publish) - .handle("selectSession", selectSession) - .handle("controlNext", controlNext) - .handle("controlResponse", controlResponse) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts similarity index 52% rename from packages/opencode/src/server/routes/instance/httpapi/workspace.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1c5b4f87d8..e0f09d6a30 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,23 +1,21 @@ -import { listAdaptors } from "@/control-plane/adaptors" import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdaptorEntry } from "@/control-plane/types" -import * as InstanceState from "@/effect/instance-state" -import { Instance } from "@/project/instance" -import { Effect, Schema, Struct } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema, Struct } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" const root = "/experimental/workspace" -const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({ +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({ identifier: "WorkspaceCreateInput", }) -const SessionRestorePayload = Schema.Struct( +export const SessionRestorePayload = Schema.Struct( Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]), ).annotate({ identifier: "WorkspaceSessionRestoreInput", }) -const SessionRestoreResponse = Schema.Struct({ - total: Schema.Number, +export const SessionRestoreResponse = Schema.Struct({ + total: Schema.Finite, }).annotate({ identifier: "WorkspaceSessionRestoreResponse" }) export const WorkspacePaths = { @@ -41,9 +39,7 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "List all available workspace adaptors for the current project.", }), ), - HttpApiEndpoint.get("list", WorkspacePaths.list, { - success: Schema.Array(Workspace.Info), - }).annotateMerge( + HttpApiEndpoint.get("list", WorkspacePaths.list, { success: Schema.Array(Workspace.Info) }).annotateMerge( OpenApi.annotations({ identifier: "experimental.workspace.list", summary: "List workspaces", @@ -91,12 +87,8 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), ) - .annotateMerge( - OpenApi.annotations({ - title: "workspace", - description: "Experimental HttpApi workspace routes.", - }), - ) + .annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." })) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -106,61 +98,3 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => - Effect.gen(function* () { - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { - const ctx = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(ctx.project.id)) - }) - - const list = Effect.fn("WorkspaceHttpApi.list")(function* () { - return Workspace.list((yield* InstanceState.context).project) - }) - - const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.create({ - ...ctx.payload, - projectID: instance.project.id, - }), - ), - ) - }) - - const status = Effect.fn("WorkspaceHttpApi.status")(function* () { - const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) - return Workspace.status().filter((item) => ids.has(item.workspaceID)) - }) - - const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) - }) - - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.sessionRestore({ - workspaceID: ctx.params.id, - sessionID: ctx.payload.sessionID, - }), - ), - ) - }) - - return handlers - .handle("adaptors", adaptors) - .handle("list", list) - .handle("create", create) - .handle("status", status) - .handle("remove", remove) - .handle("sessionRestore", sessionRestore) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts new file mode 100644 index 0000000000..2fc225d171 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -0,0 +1,34 @@ +import { Config } from "@/config/config" +import { Provider } from "@/provider/provider" +import * as InstanceState from "@/effect/instance-state" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForDisposal } from "../lifecycle" + +export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) => + Effect.gen(function* () { + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) + + const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { + yield* configSvc.update(ctx.payload, { dispose: false }) + yield* markInstanceForDisposal(yield* InstanceState.context) + return ctx.payload + }) + + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { + const providers = yield* providerSvc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }) + + return handlers.handle("get", get).handle("update", update).handle("providers", providers) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts new file mode 100644 index 0000000000..abddd8c402 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -0,0 +1,34 @@ +import { Auth } from "@/auth" +import { ProviderID } from "@/provider/schema" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { RootHttpApi } from "../api" +import { LogInput } from "../groups/control" + +export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => + Effect.gen(function* () { + const auth = yield* Auth.Service + + const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { + params: { providerID: ProviderID } + payload: Auth.Info + }) { + yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + return true + }) + + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { + yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + return true + }) + + const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { + const logger = Log.create({ service: ctx.payload.service }) + logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) + return true + }) + + return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts new file mode 100644 index 0000000000..42eab762e8 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -0,0 +1,155 @@ +import { Account } from "@/account/account" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { MCP } from "@/mcp" +import { Project } from "@/project/project" +import { Session } from "@/session/session" +import { ToolRegistry } from "@/tool/registry" +import * as EffectZod from "@/util/effect-zod" +import { Worktree } from "@/worktree" +import { Effect, Option } from "effect" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" + +export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => + Effect.gen(function* () { + const account = yield* Account.Service + const agents = yield* Agent.Service + const config = yield* Config.Service + const mcp = yield* MCP.Service + const project = yield* Project.Service + const registry = yield* ToolRegistry.Service + const worktreeSvc = yield* Worktree.Service + + const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { + const [state, groups] = yield* Effect.all( + [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + return { + consoleManagedProviders: state.consoleManagedProviders, + ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }) + + const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { + const [groups, active] = yield* Effect.all( + [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + const info = Option.getOrUndefined(active) + return { + orgs: groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ), + } + }) + + const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { + payload: typeof ConsoleSwitchPayload.Type + }) { + yield* account + .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { + const list = yield* registry.tools({ + providerID: ctx.query.provider, + modelID: ctx.query.model, + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + return list.map((item) => ({ + id: item.id, + description: item.description, + parameters: EffectZod.toJsonSchema(item.parameters), + })) + }) + + const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { + return yield* registry.ids() + }) + + const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { + const ctx = yield* InstanceState.context + return yield* project.sandboxes(ctx.project.id) + }) + + const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { + payload: Worktree.CreateInput | undefined + }) { + return yield* worktreeSvc.create(ctx.payload) + }) + + const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { + payload: Worktree.RemoveInput + }) { + const ctx = yield* InstanceState.context + yield* worktreeSvc.remove(input.payload) + yield* project.removeSandbox(ctx.project.id, input.payload.directory) + return true + }) + + const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { + payload: Worktree.ResetInput + }) { + yield* worktreeSvc.reset(ctx.payload) + return true + }) + + const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { + const limit = ctx.query.limit ?? 100 + const sessions = Array.from( + Session.listGlobal({ + directory: ctx.query.directory, + roots: ctx.query.roots, + start: ctx.query.start, + cursor: ctx.query.cursor, + search: ctx.query.search, + limit: limit + 1, + archived: ctx.query.archived, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + return HttpServerResponse.jsonUnsafe(list, { + headers: + sessions.length > limit && list.length > 0 + ? { "x-next-cursor": String(list[list.length - 1].time.updated) } + : undefined, + }) + }) + + const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { + return yield* mcp.resources() + }) + + return handlers + .handle("console", getConsole) + .handle("consoleOrgs", listConsoleOrgs) + .handle("consoleSwitch", switchConsole) + .handle("tool", tool) + .handle("toolIDs", toolIDs) + .handle("worktree", worktree) + .handle("worktreeCreate", worktreeCreate) + .handle("worktreeRemove", worktreeRemove) + .handle("worktreeReset", worktreeReset) + .handle("session", session) + .handle("resource", resource) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts new file mode 100644 index 0000000000..72133e8dea --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -0,0 +1,54 @@ +import * as InstanceState from "@/effect/instance-state" +import { File } from "@/file" +import { Ripgrep } from "@/file/ripgrep" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => + Effect.gen(function* () { + const svc = yield* File.Service + const ripgrep = yield* Ripgrep.Service + + const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { + return (yield* ripgrep + .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) + .pipe(Effect.orDie)).items + }) + + const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { + query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } + }) { + return yield* svc.search({ + query: ctx.query.query, + limit: ctx.query.limit ?? 10, + dirs: ctx.query.dirs !== "false", + type: ctx.query.type, + }) + }) + + const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { + return [] + }) + + const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { + return yield* svc.list(ctx.query.path) + }) + + const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + return yield* svc.read(ctx.query.path) + }) + + const status = Effect.fn("FileHttpApi.status")(function* () { + return yield* svc.status() + }) + + return handlers + .handle("findText", findText) + .handle("findFile", findFile) + .handle("findSymbol", findSymbol) + .handle("list", list) + .handle("content", content) + .handle("status", status) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts new file mode 100644 index 0000000000..5972395512 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -0,0 +1,156 @@ +import { Config } from "@/config/config" +import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { Installation } from "@/installation" +import { Instance } from "@/project/instance" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Log from "@opencode-ai/core/util/log" +import { Effect, Queue, Schema } from "effect" +import * as Stream from "effect/Stream" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" +import { RootHttpApi } from "../api" +import { GlobalUpgradeInput } from "../groups/global" + +const log = Log.create({ service: "server" }) + +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(data), + } +} + +function parseBody(body: string) { + try { + return JSON.parse(body || "{}") as unknown + } catch { + return undefined + } +} + +function eventResponse() { + log.info("global event connected") + const events = Stream.callback((queue) => { + const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event) + return Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", handler)), + () => Effect.sync(() => GlobalBus.off("event", handler)), + ) + }) + const heartbeat = Stream.tick("10 seconds").pipe( + Stream.drop(1), + Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + ) + + return HttpServerResponse.stream( + Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) +} + +export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) => + Effect.gen(function* () { + const config = yield* Config.Service + const installation = yield* Installation.Service + + const health = Effect.fn("GlobalHttpApi.health")(function* () { + return { healthy: true as const, version: InstallationVersion } + }) + + const event = Effect.fn("GlobalHttpApi.event")(function* () { + return eventResponse() + }) + + const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { + return yield* config.getGlobal() + }) + + const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { + return yield* config.updateGlobal(ctx.payload) + }) + + const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { + yield* Effect.promise(() => Instance.disposeAll()) + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.disposed", properties: {} }, + }) + return true + }) + + const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { + const method = yield* installation.method() + if (method === "unknown") { + return { + status: 400, + body: { success: false as const, error: "Unknown installation method" }, + } + } + const target = ctx.payload.target || (yield* installation.latest(method)) + const result = yield* installation.upgrade(method, target).pipe( + Effect.as({ status: 200, body: { success: true as const, version: target } }), + Effect.catch((err) => + Effect.succeed({ + status: 500, + body: { + success: false as const, + error: err instanceof Error ? err.message : String(err), + }, + }), + ), + ) + if (!result.body.success) return result + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, + }) + return result + }) + + const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const json = parseBody(body) + if (json === undefined) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( + Effect.map((payload) => ({ valid: true as const, payload })), + Effect.catch(() => Effect.succeed({ valid: false as const })), + ) + if (!payload.valid) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const result = yield* upgrade({ payload: payload.payload }) + return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) + }) + + return handlers + .handle("health", health) + .handleRaw("event", event) + .handle("configGet", configGet) + .handle("configUpdate", configUpdate) + .handle("dispose", dispose) + .handleRaw("upgrade", upgradeRaw) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts new file mode 100644 index 0000000000..b6f3860652 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -0,0 +1,79 @@ +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import * as InstanceState from "@/effect/instance-state" +import { Format } from "@/format" +import { Global } from "@opencode-ai/core/global" +import { LSP } from "@/lsp/lsp" +import { Vcs } from "@/project/vcs" +import { Skill } from "@/skill" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForDisposal } from "../lifecycle" + +export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => + Effect.gen(function* () { + const agent = yield* Agent.Service + const command = yield* Command.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const skill = yield* Skill.Service + const vcs = yield* Vcs.Service + + const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { + yield* markInstanceForDisposal(yield* InstanceState.context) + return true + }) + + const getPath = Effect.fn("InstanceHttpApi.path")(function* () { + const ctx = yield* InstanceState.context + return { + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: ctx.worktree, + directory: ctx.directory, + } + }) + + const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + return { branch, default_branch } + }) + + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { + return yield* vcs.diff(ctx.query.mode) + }) + + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { + return yield* command.list() + }) + + const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { + return yield* agent.list() + }) + + const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { + return yield* skill.all() + }) + + const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { + return yield* lsp.status() + }) + + const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { + return yield* format.status() + }) + + return handlers + .handle("dispose", dispose) + .handle("path", getPath) + .handle("vcs", getVcs) + .handle("vcsDiff", getVcsDiff) + .handle("command", getCommand) + .handle("agent", getAgent) + .handle("skill", getSkill) + .handle("lsp", getLsp) + .handle("formatter", getFormatter) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts new file mode 100644 index 0000000000..b4d27d91de --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -0,0 +1,68 @@ +import { MCP } from "@/mcp" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" + +export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => + Effect.gen(function* () { + const mcp = yield* MCP.Service + + const status = Effect.fn("McpHttpApi.status")(function* () { + return yield* mcp.status() + }) + + const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { + const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status + return yield* Schema.decodeUnknownEffect(StatusMap)( + "status" in result ? { [ctx.payload.name]: result } : result, + ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + }) + + const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.startAuth(ctx.params.name) + }) + + const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { + params: { name: string } + payload: typeof AuthCallbackPayload.Type + }) { + return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) + }) + + const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.authenticate(ctx.params.name) + }) + + const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { + yield* mcp.removeAuth(ctx.params.name) + return { success: true as const } + }) + + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { + yield* mcp.connect(ctx.params.name) + return true + }) + + const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { + yield* mcp.disconnect(ctx.params.name) + return true + }) + + return handlers + .handle("status", status) + .handle("add", add) + .handle("authStart", authStart) + .handle("authCallback", authCallback) + .handle("authAuthenticate", authAuthenticate) + .handle("authRemove", authRemove) + .handle("connect", connect) + .handle("disconnect", disconnect) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts new file mode 100644 index 0000000000..a5d6dab895 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -0,0 +1,29 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => + Effect.gen(function* () { + const svc = yield* Permission.Service + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, + }) + return true + }) + + return handlers.handle("list", list).handle("reply", reply) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts new file mode 100644 index 0000000000..20a5ddfb09 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -0,0 +1,46 @@ +import { AppRuntime } from "@/effect/app-runtime" +import * as InstanceState from "@/effect/instance-state" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Project } from "@/project/project" +import { ProjectID } from "@/project/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForReload } from "../lifecycle" + +export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => + Effect.gen(function* () { + const svc = yield* Project.Service + + const list = Effect.fn("ProjectHttpApi.list")(function* () { + return yield* svc.list() + }) + + const current = Effect.fn("ProjectHttpApi.current")(function* () { + return (yield* InstanceState.context).project + }) + + const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { + const ctx = yield* InstanceState.context + const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) + if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) + return next + yield* markInstanceForReload(ctx, { + directory: ctx.directory, + worktree: ctx.directory, + project: next, + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) + return next + }) + + const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { + params: { projectID: ProjectID } + payload: Project.UpdatePayload + }) { + return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) + }) + + return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts new file mode 100644 index 0000000000..f343829d6a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -0,0 +1,89 @@ +import { ProviderAuth } from "@/provider/auth" +import { Config } from "@/config/config" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { mapValues } from "remeda" +import { Effect, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) => + Effect.gen(function* () { + const cfg = yield* Config.Service + const provider = yield* Provider.Service + const svc = yield* ProviderAuth.Service + + const list = Effect.fn("ProviderHttpApi.list")(function* () { + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value + } + const connected = yield* provider.list() + const providers = Object.assign( + mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }) + + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { + return yield* svc.methods() + }) + + const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.AuthorizeInput + }) { + return yield* svc + .authorize({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + inputs: ctx.payload.inputs, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + }) + + const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { + params: { providerID: ProviderID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + const result = yield* authorize({ params: ctx.params, payload }) + if (result === undefined) return HttpServerResponse.empty({ status: 200 }) + return HttpServerResponse.jsonUnsafe(result) + }) + + const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.CallbackInput + }) { + yield* svc + .callback({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + code: ctx.payload.code, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + return handlers + .handle("list", list) + .handle("auth", auth) + .handleRaw("authorize", authorizeRaw) + .handle("callback", callback) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts new file mode 100644 index 0000000000..f2f17d4714 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -0,0 +1,118 @@ +import { EffectBridge } from "@/effect/bridge" +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { Shell } from "@/shell/shell" +import { Effect } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" +import { InstanceHttpApi } from "../api" +import { CursorQuery, Params, PtyPaths } from "../groups/pty" + +export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => + Effect.gen(function* () { + const pty = yield* Pty.Service + + const shells = Effect.fn("PtyHttpApi.shells")(function* () { + return yield* Effect.promise(() => Shell.list()) + }) + + const list = Effect.fn("PtyHttpApi.list")(function* () { + return yield* pty.list() + }) + + const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { + const bridge = yield* EffectBridge.make() + return yield* Effect.promise(() => + bridge.promise( + pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + env: ctx.payload.env ? { ...ctx.payload.env } : undefined, + }), + ), + ) + }) + + const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { + const info = yield* pty.get(ctx.params.ptyID) + if (!info) return yield* new HttpApiError.NotFound({}) + return info + }) + + const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { + params: { ptyID: PtyID } + payload: typeof Pty.UpdateInput.Type + }) { + const info = yield* pty.update(ctx.params.ptyID, { + ...ctx.payload, + size: ctx.payload.size ? { ...ctx.payload.size } : undefined, + }) + if (!info) return yield* new HttpApiError.NotFound({}) + return info + }) + + const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { + yield* pty.remove(ctx.params.ptyID) + return true + }) + + return handlers + .handle("shells", shells) + .handle("list", list) + .handle("create", create) + .handle("get", get) + .handle("update", update) + .handle("remove", remove) + }), +) + +export const ptyConnectRoute = HttpRouter.add( + "GET", + PtyPaths.connect, + Effect.gen(function* () { + const pty = yield* Pty.Service + const params = yield* HttpRouter.schemaPathParams(Params) + if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) + + const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) + const cursor = + parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined + const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const write = yield* socket.writer + let closed = false + const adapter = { + get readyState() { + return closed ? 3 : 1 + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + if (closed) return + Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void))) + }, + close: (code?: number, reason?: string) => { + if (closed) return + closed = true + Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) + }, + } + const handler = yield* pty.connect(params.ptyID, adapter, cursor) + if (!handler) return HttpServerResponse.empty() + + yield* socket + .runRaw((message) => { + handler.onMessage(typeof message === "string" ? message : message.slice().buffer) + }) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring( + Effect.sync(() => { + closed = true + handler.onClose() + }), + ), + Effect.orDie, + ) + return HttpServerResponse.empty() + }).pipe(Effect.provide(Pty.defaultLayer)), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts new file mode 100644 index 0000000000..53ca568cf5 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts @@ -0,0 +1,33 @@ +import { Question } from "@/question" +import { QuestionID } from "@/question/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) => + Effect.gen(function* () { + const svc = yield* Question.Service + + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, + }) + return true + }) + + const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { + yield* svc.reject(ctx.params.requestID) + return true + }) + + return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts similarity index 55% rename from packages/opencode/src/server/routes/instance/httpapi/session.ts rename to packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index ef63bf9a53..d6264b6050 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -6,7 +6,6 @@ import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Instance } from "@/project/instance" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -18,84 +17,17 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" -import { Snapshot } from "@/snapshot" import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Schema, SchemaGetter, Struct } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiError, - HttpApiGroup, - HttpApiSchema, - OpenApi, -} from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session" const log = Log.create({ service: "server" }) -const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) -const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), - scope: Schema.optional(Schema.Literals(["project"])), - path: Schema.optional(Schema.String), - roots: Schema.optional(QueryBoolean), - start: Schema.optional(Schema.NumberFromString), - search: Schema.optional(Schema.String), - limit: Schema.optional(Schema.NumberFromString), -}) -const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) -const MessagesQuery = Schema.Struct({ - limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), - before: Schema.optional(Schema.String), -}) -const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) -const UpdatePayload = Schema.Struct({ - title: Schema.optional(Schema.String), - permission: Schema.optional(Permission.Ruleset), - time: Schema.optional( - Schema.Struct({ - archived: Schema.optional(Schema.Number), - }), - ), -}).annotate({ identifier: "SessionUpdateInput" }) -const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({ - identifier: "SessionForkInput", -}) -const InitPayload = Schema.Struct({ - modelID: ModelID, - providerID: ProviderID, - messageID: MessageID, -}).annotate({ identifier: "SessionInitInput" }) -const SummarizePayload = Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - auto: Schema.optional(Schema.Boolean), -}).annotate({ identifier: "SessionSummarizeInput" }) -const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({ - identifier: "SessionPromptInput", -}) -const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({ - identifier: "SessionCommandInput", -}) -const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({ - identifier: "SessionShellInput", -}) -const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({ - identifier: "SessionRevertInput", -}) -const PermissionResponsePayload = Schema.Struct({ - response: Permission.Reply, -}).annotate({ identifier: "SessionPermissionResponseInput" }) const mapNotFound = (self: Effect.Effect) => self.pipe( @@ -105,346 +37,7 @@ const mapNotFound = (self: Effect.Effect) => ), ) -export const SessionPaths = { - list: root, - status: `${root}/status`, - get: `${root}/:sessionID`, - children: `${root}/:sessionID/children`, - todo: `${root}/:sessionID/todo`, - diff: `${root}/:sessionID/diff`, - messages: `${root}/:sessionID/message`, - message: `${root}/:sessionID/message/:messageID`, - create: root, - remove: `${root}/:sessionID`, - update: `${root}/:sessionID`, - fork: `${root}/:sessionID/fork`, - abort: `${root}/:sessionID/abort`, - share: `${root}/:sessionID/share`, - init: `${root}/:sessionID/init`, - summarize: `${root}/:sessionID/summarize`, - prompt: `${root}/:sessionID/message`, - promptAsync: `${root}/:sessionID/prompt_async`, - command: `${root}/:sessionID/command`, - shell: `${root}/:sessionID/shell`, - revert: `${root}/:sessionID/revert`, - unrevert: `${root}/:sessionID/unrevert`, - permissions: `${root}/:sessionID/permissions/:permissionID`, - deleteMessage: `${root}/:sessionID/message/:messageID`, - deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, - updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, -} as const - -export const SessionApi = HttpApi.make("session") - .add( - HttpApiGroup.make("session") - .add( - HttpApiEndpoint.get("list", SessionPaths.list, { - query: ListQuery, - success: Schema.Array(Session.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.list", - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - }), - ), - HttpApiEndpoint.get("status", SessionPaths.status, { - success: StatusMap, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.status", - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - }), - ), - HttpApiEndpoint.get("get", SessionPaths.get, { - params: { sessionID: SessionID }, - success: Session.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.get", - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - }), - ), - HttpApiEndpoint.get("children", SessionPaths.children, { - params: { sessionID: SessionID }, - success: Schema.Array(Session.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.children", - summary: "Get session children", - description: "Retrieve all child sessions that were forked from the specified parent session.", - }), - ), - HttpApiEndpoint.get("todo", SessionPaths.todo, { - params: { sessionID: SessionID }, - success: Schema.Array(Todo.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.todo", - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - }), - ), - HttpApiEndpoint.get("diff", SessionPaths.diff, { - params: { sessionID: SessionID }, - query: DiffQuery, - success: Schema.Array(Snapshot.FileDiff), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.diff", - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - }), - ), - HttpApiEndpoint.get("messages", SessionPaths.messages, { - params: { sessionID: SessionID }, - query: MessagesQuery, - success: Schema.Array(MessageV2.WithParts), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.messages", - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - }), - ), - HttpApiEndpoint.get("message", SessionPaths.message, { - params: { sessionID: SessionID, messageID: MessageID }, - success: MessageV2.WithParts, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.message", - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - }), - ), - HttpApiEndpoint.post("create", SessionPaths.create, { - payload: [HttpApiSchema.NoContent, Session.CreateInput], - success: Session.Info, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.create", - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - }), - ), - HttpApiEndpoint.delete("remove", SessionPaths.remove, { - params: { sessionID: SessionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.delete", - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - }), - ), - HttpApiEndpoint.patch("update", SessionPaths.update, { - params: { sessionID: SessionID }, - payload: UpdatePayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.update", - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - }), - ), - HttpApiEndpoint.post("fork", SessionPaths.fork, { - params: { sessionID: SessionID }, - payload: ForkPayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.fork", - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - }), - ), - HttpApiEndpoint.post("abort", SessionPaths.abort, { - params: { sessionID: SessionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.abort", - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - }), - ), - HttpApiEndpoint.post("init", SessionPaths.init, { - params: { sessionID: SessionID }, - payload: InitPayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.init", - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - }), - ), - HttpApiEndpoint.post("share", SessionPaths.share, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.share", - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - }), - ), - HttpApiEndpoint.delete("unshare", SessionPaths.share, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.unshare", - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - }), - ), - HttpApiEndpoint.post("summarize", SessionPaths.summarize, { - params: { sessionID: SessionID }, - payload: SummarizePayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.summarize", - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - }), - ), - HttpApiEndpoint.post("prompt", SessionPaths.prompt, { - params: { sessionID: SessionID }, - payload: PromptPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.prompt", - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - }), - ), - HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { - params: { sessionID: SessionID }, - payload: PromptPayload, - success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.prompt_async", - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - }), - ), - HttpApiEndpoint.post("command", SessionPaths.command, { - params: { sessionID: SessionID }, - payload: CommandPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.command", - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - }), - ), - HttpApiEndpoint.post("shell", SessionPaths.shell, { - params: { sessionID: SessionID }, - payload: ShellPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.shell", - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - }), - ), - HttpApiEndpoint.post("revert", SessionPaths.revert, { - params: { sessionID: SessionID }, - payload: RevertPayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.revert", - summary: "Revert message", - description: - "Revert a specific message in a session, undoing its effects and restoring the previous state.", - }), - ), - HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.unrevert", - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - }), - ), - HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { - params: { sessionID: SessionID, permissionID: PermissionID }, - payload: PermissionResponsePayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "permission.respond", - summary: "Respond to permission", - description: "Approve or deny a permission request from the AI assistant.", - deprecated: true, - }), - ), - HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { - params: { sessionID: SessionID, messageID: MessageID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.deleteMessage", - summary: "Delete message", - description: - "Permanently delete a specific message and all of its parts from a session without reverting file changes.", - }), - ), - HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { - params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "part.delete", - description: "Delete a part from a message.", - }), - ), - HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { - params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, - payload: MessageV2.Part, - success: MessageV2.Part, - }).annotateMerge( - OpenApi.annotations({ - identifier: "part.update", - description: "Update a part in a message.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "session", - description: "Experimental HttpApi session routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) => +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service const statusSvc = yield* SessionStatus.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts new file mode 100644 index 0000000000..3ae091484f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -0,0 +1,54 @@ +import { startWorkspaceSyncing } from "@/control-plane/workspace" +import * as InstanceState from "@/effect/instance-state" +import { Database } from "@/storage/db" +import { SyncEvent } from "@/sync" +import { EventTable } from "@/sync/event.sql" +import { asc } from "drizzle-orm" +import { and } from "drizzle-orm" +import { eq } from "drizzle-orm" +import { lte } from "drizzle-orm" +import { not } from "drizzle-orm" +import { or } from "drizzle-orm" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { HistoryPayload, ReplayPayload } from "../groups/sync" + +export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => + Effect.gen(function* () { + const start = Effect.fn("SyncHttpApi.start")(function* () { + startWorkspaceSyncing((yield* InstanceState.context).project.id) + return true + }) + + const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { + const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ + id: event.id, + aggregateID: event.aggregateID, + seq: event.seq, + type: event.type, + data: { ...event.data }, + })) + SyncEvent.replayAll(events) + return { sessionID: events[0].aggregateID } + }) + + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { + const exclude = Object.entries(ctx.payload) + return Database.use((db) => + db + .select() + .from(EventTable) + .where( + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined, + ) + .orderBy(asc(EventTable.seq)) + .all(), + ) + }) + + return handlers.handle("start", start).handle("replay", replay).handle("history", history) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts new file mode 100644 index 0000000000..cb12ccb7a7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -0,0 +1,134 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { SessionTable } from "@/session/session.sql" +import * as Database from "@/storage/db" +import { eq } from "drizzle-orm" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { nextTuiRequest, submitTuiResponse } from "../../tui" +import { InstanceHttpApi } from "../api" +import { CommandPayload, TuiPublishPayload } from "../groups/tui" + +const commandAliases = { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", +} as const + +export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => + Effect.gen(function* () { + const bus = yield* Bus.Service + const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => + bus.publish(TuiEvent.CommandExecute, { command }) + + const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { + payload: typeof TuiEvent.PromptAppend.properties.Type + }) { + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) + return true + }) + + const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { + yield* publishCommand("help.show") + return true + }) + + const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { + yield* publishCommand("model.list") + return true + }) + + const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { + yield* publishCommand("prompt.submit") + return true + }) + + const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { + yield* publishCommand("prompt.clear") + return true + }) + + const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { + payload: typeof CommandPayload.Type + }) { + yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) + return true + }) + + const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { + payload: typeof TuiEvent.ToastShow.properties.Type + }) { + yield* bus.publish(TuiEvent.ToastShow, ctx.payload) + return true + }) + + const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { + if (ctx.payload.type === TuiEvent.PromptAppend.type) + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.CommandExecute.type) + yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.SessionSelect.type) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) + return true + }) + + const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { + payload: typeof TuiEvent.SessionSelect.properties.Type + }) { + if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) + const row = yield* Effect.sync(() => + Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), + ), + ) + if (!row) return yield* new HttpApiError.NotFound({}) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) + return true + }) + + const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { + return yield* Effect.promise(() => nextTuiRequest()) + }) + + const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { + submitTuiResponse(ctx.payload) + return true + }) + + return handlers + .handle("appendPrompt", appendPrompt) + .handle("openHelp", openHelp) + .handle("openSessions", openSessions) + .handle("openThemes", openThemes) + .handle("openModels", openModels) + .handle("submitPrompt", submitPrompt) + .handle("clearPrompt", clearPrompt) + .handle("executeCommand", executeCommand) + .handle("showToast", showToast) + .handle("publish", publish) + .handle("selectSession", selectSession) + .handle("controlNext", controlNext) + .handle("controlResponse", controlResponse) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts new file mode 100644 index 0000000000..9413c865d1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -0,0 +1,66 @@ +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import * as InstanceState from "@/effect/instance-state" +import { Instance } from "@/project/instance" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { CreatePayload, SessionRestorePayload } from "../groups/workspace" + +export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => + Effect.gen(function* () { + const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => listAdaptors(instance.project.id)) + }) + + const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Workspace.list((yield* InstanceState.context).project) + }) + + const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + Workspace.create({ + ...ctx.payload, + projectID: instance.project.id, + }), + ), + ) + }) + + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) + return Workspace.status().filter((item) => ids.has(item.workspaceID)) + }) + + const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) + }) + + const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { + params: { id: Workspace.Info["id"] } + payload: typeof SessionRestorePayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + Workspace.sessionRestore({ + workspaceID: ctx.params.id, + sessionID: ctx.payload.sessionID, + }), + ), + ) + }) + + return handlers + .handle("adaptors", adaptors) + .handle("list", list) + .handle("create", create) + .handle("status", status) + .handle("remove", remove) + .handle("sessionRestore", sessionRestore) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts new file mode 100644 index 0000000000..92184367d9 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts @@ -0,0 +1,66 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import { Filesystem } from "@/util/filesystem" +import { Effect, Layer, Schema } from "effect" +import { HttpRouter, HttpServerRequest } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +const Query = Schema.Struct({ + directory: Schema.optionalKey(Schema.String), + workspace: Schema.optionalKey(Schema.String), + auth_token: Schema.optionalKey(Schema.String), +}) + +const Headers = Schema.Struct({ + authorization: Schema.optionalKey(Schema.String), + "x-opencode-directory": Schema.optionalKey(Schema.String), +}) + +export class InstanceContextMiddleware extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiInstanceContext", +) {} + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function currentDirectory() { + try { + return Instance.directory + } catch { + return process.cwd() + } +} + +function provideInstanceContext(effect: Effect.Effect) { + return Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query).pipe(Effect.orDie) + const headers = yield* HttpServerRequest.schemaHeaders(Headers).pipe(Effect.orDie) + const raw = query.directory || headers["x-opencode-directory"] || currentDirectory() + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(raw)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, query.workspace), + ) + }) +} + +export const instanceContextLayer = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), +) + +export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => provideInstanceContext(effect))).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts deleted file mode 100644 index 7dbc491e13..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { ProviderAuth } from "@/provider/auth" -import { Config } from "@/config/config" -import { ModelsDev } from "@/provider/models" -import { Provider } from "@/provider/provider" -import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" -import { Effect, Schema } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/provider" - -export const ProviderApi = HttpApi.make("provider") - .add( - HttpApiGroup.make("provider") - .add( - HttpApiEndpoint.get("list", root, { - success: Provider.ListResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.list", - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - }), - ), - HttpApiEndpoint.get("auth", `${root}/auth`, { - success: ProviderAuth.Methods, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.auth", - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - }), - ), - HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.AuthorizeInput, - success: Schema.UndefinedOr(ProviderAuth.Authorization), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.authorize", - summary: "Start OAuth authorization", - description: "Start the OAuth authorization flow for a provider.", - }), - ), - HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.CallbackInput, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.callback", - summary: "Handle OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "provider", - description: "Experimental HttpApi provider routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) => - Effect.gen(function* () { - const cfg = yield* Config.Service - const provider = yield* Provider.Service - const svc = yield* ProviderAuth.Service - - const list = Effect.fn("ProviderHttpApi.list")(function* () { - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* provider.list() - const providers = Object.assign( - mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }) - - const auth = Effect.fn("ProviderHttpApi.auth")(function* () { - return yield* svc.methods() - }) - - const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.AuthorizeInput - }) { - const result = yield* svc - .authorize({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - inputs: ctx.payload.inputs, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return result - }) - - const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { - params: { providerID: ProviderID } - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( - Effect.mapError(() => new HttpApiError.BadRequest({})), - ) - const result = yield* authorize({ params: ctx.params, payload }) - if (result === undefined) return HttpServerResponse.empty({ status: 200 }) - return HttpServerResponse.jsonUnsafe(result) - }) - - const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.CallbackInput - }) { - yield* svc - .callback({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - code: ctx.payload.code, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - return handlers - .handle("list", list) - .handle("auth", auth) - .handleRaw("authorize", authorizeRaw) - .handle("callback", callback) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts deleted file mode 100644 index d4e77c9d03..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { EffectBridge } from "@/effect/bridge" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { Shell } from "@/shell/shell" -import { Effect, Schema } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" -import { Authorization } from "./auth" - -const root = "/pty" -const Params = Schema.Struct({ - ptyID: PtyID, -}) -const CursorQuery = Schema.Struct({ - cursor: Schema.optional(Schema.String), -}) -const ShellItem = Schema.Struct({ - path: Schema.String, - name: Schema.String, - acceptable: Schema.Boolean, -}) - -export const PtyPaths = { - shells: `${root}/shells`, - list: root, - create: root, - get: `${root}/:ptyID`, - update: `${root}/:ptyID`, - remove: `${root}/:ptyID`, - connect: `${root}/:ptyID/connect`, -} as const - -export const PtyApi = HttpApi.make("pty") - .add( - HttpApiGroup.make("pty") - .add( - HttpApiEndpoint.get("shells", PtyPaths.shells, { - success: Schema.Array(ShellItem), - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.shells", - summary: "List available shells", - description: "Get a list of available shells on the system.", - }), - ), - HttpApiEndpoint.get("list", PtyPaths.list, { - success: Schema.Array(Pty.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.list", - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - }), - ), - HttpApiEndpoint.post("create", PtyPaths.create, { - payload: Pty.CreateInput, - success: Pty.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.create", - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - }), - ), - HttpApiEndpoint.get("get", PtyPaths.get, { - params: { ptyID: PtyID }, - success: Pty.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.get", - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - }), - ), - HttpApiEndpoint.put("update", PtyPaths.update, { - params: { ptyID: PtyID }, - payload: Pty.UpdateInput, - success: Pty.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.update", - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - }), - ), - HttpApiEndpoint.delete("remove", PtyPaths.remove, { - params: { ptyID: PtyID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.remove", - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "pty", - description: "Experimental HttpApi PTY routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const PtyConnectApi = HttpApi.make("pty-connect").add( - HttpApiGroup.make("pty-connect") - .add( - HttpApiEndpoint.get("connect", PtyPaths.connect, { - params: Params, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.connect", - summary: "Connect to PTY session", - description: - "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), -) - -export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) => - Effect.gen(function* () { - const pty = yield* Pty.Service - - const shells = Effect.fn("PtyHttpApi.shells")(function* () { - return yield* Effect.promise(() => Shell.list()) - }) - - const list = Effect.fn("PtyHttpApi.list")(function* () { - return yield* pty.list() - }) - - const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { - const bridge = yield* EffectBridge.make() - return yield* Effect.promise(() => - bridge.promise( - pty.create({ - ...ctx.payload, - args: ctx.payload.args ? [...ctx.payload.args] : undefined, - env: ctx.payload.env ? { ...ctx.payload.env } : undefined, - }), - ), - ) - }) - - const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { - const info = yield* pty.get(ctx.params.ptyID) - if (!info) return yield* new HttpApiError.NotFound({}) - return info - }) - - const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { - params: { ptyID: PtyID } - payload: typeof Pty.UpdateInput.Type - }) { - const info = yield* pty.update(ctx.params.ptyID, { - ...ctx.payload, - size: ctx.payload.size ? { ...ctx.payload.size } : undefined, - }) - if (!info) return yield* new HttpApiError.NotFound({}) - return info - }) - - const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { - yield* pty.remove(ctx.params.ptyID) - return true - }) - - return handlers - .handle("shells", shells) - .handle("list", list) - .handle("create", create) - .handle("get", get) - .handle("update", update) - .handle("remove", remove) - }), -) - -export const ptyConnectRoute = HttpRouter.add( - "GET", - PtyPaths.connect, - Effect.gen(function* () { - const pty = yield* Pty.Service - const params = yield* HttpRouter.schemaPathParams(Params) - if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) - - const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) - const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) - const cursor = - parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) - const write = yield* socket.writer - let closed = false - const adapter = { - get readyState() { - return closed ? 3 : 1 - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - if (closed) return - Effect.runFork( - write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)), - ) - }, - close: (code?: number, reason?: string) => { - if (closed) return - closed = true - Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) - }, - } - const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() - - yield* socket - .runRaw((message) => { - handler.onMessage(typeof message === "string" ? message : message.slice().buffer) - }) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { - closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) - return HttpServerResponse.empty() - }).pipe(Effect.provide(Pty.defaultLayer)), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index a4e86e9a5f..4f3ee031a3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,21 +1,5 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { ConfigApi } from "./config" -import { ControlApi } from "./control" -import { EventApi } from "./event" -import { ExperimentalApi } from "./experimental" -import { FileApi } from "./file" -import { GlobalApi } from "./global" -import { InstanceApi } from "./instance" -import { McpApi } from "./mcp" -import { PermissionApi } from "./permission" -import { ProjectApi } from "./project" -import { ProviderApi } from "./provider" -import { PtyApi, PtyConnectApi } from "./pty" -import { QuestionApi } from "./question" -import { SessionApi } from "./session" -import { SyncApi } from "./sync" -import { TuiApi } from "./tui" -import { WorkspaceApi } from "./workspace" +import { OpenApi } from "effect/unstable/httpapi" +import { OpenCodeHttpApi } from "./api" type OpenApiParameter = { name: string @@ -208,25 +192,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { param.schema = normalizeRequestSchema(param.schema) } -export const PublicApi = HttpApi.make("opencode") - .addHttpApi(ControlApi) - .addHttpApi(GlobalApi) - .addHttpApi(EventApi) - .addHttpApi(ConfigApi) - .addHttpApi(ExperimentalApi) - .addHttpApi(FileApi) - .addHttpApi(InstanceApi) - .addHttpApi(McpApi) - .addHttpApi(PermissionApi) - .addHttpApi(ProjectApi) - .addHttpApi(ProviderApi) - .addHttpApi(PtyApi) - .addHttpApi(PtyConnectApi) - .addHttpApi(QuestionApi) - .addHttpApi(SessionApi) - .addHttpApi(SyncApi) - .addHttpApi(TuiApi) - .addHttpApi(WorkspaceApi) +export const PublicApi = OpenCodeHttpApi .annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 768fb25723..2f4bde9183 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,14 +1,12 @@ -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" import { Bus } from "@/bus" import { Config } from "@/config/config" import { Command } from "@/command" -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" @@ -16,8 +14,6 @@ import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" import { Installation } from "@/installation" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -32,82 +28,34 @@ import { Todo } from "@/session/todo" import { Skill } from "@/skill" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" +import { InstanceHttpApi, RootHttpApi } from "./api" import { authorizationLayer } from "./auth" -import { ConfigApi, configHandlers } from "./config" -import { ControlApi, controlHandlers } from "./control" import { eventRoute } from "./event" -import { FileApi, fileHandlers } from "./file" -import { ExperimentalApi, experimentalHandlers } from "./experimental" -import { GlobalApi, globalHandlers } from "./global" -import { InstanceApi, instanceHandlers } from "./instance" -import { McpApi, mcpHandlers } from "./mcp" -import { PermissionApi, permissionHandlers } from "./permission" -import { ProjectApi, projectHandlers } from "./project" -import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty" -import { ProviderApi, providerHandlers } from "./provider" -import { QuestionApi, questionHandlers } from "./question" -import { SessionApi, sessionHandlers } from "./session" -import { SyncApi, syncHandlers } from "./sync" -import { TuiApi, tuiHandlers } from "./tui" -import { WorkspaceApi, workspaceHandlers } from "./workspace" +import { configHandlers } from "./handlers/config" +import { controlHandlers } from "./handlers/control" +import { experimentalHandlers } from "./handlers/experimental" +import { fileHandlers } from "./handlers/file" +import { globalHandlers } from "./handlers/global" +import { instanceHandlers } from "./handlers/instance" +import { mcpHandlers } from "./handlers/mcp" +import { permissionHandlers } from "./handlers/permission" +import { projectHandlers } from "./handlers/project" +import { providerHandlers } from "./handlers/provider" +import { ptyConnectRoute, ptyHandlers } from "./handlers/pty" +import { questionHandlers } from "./handlers/question" +import { sessionHandlers } from "./handlers/session" +import { syncHandlers } from "./handlers/sync" +import { tuiHandlers } from "./handlers/tui" +import { workspaceHandlers } from "./handlers/workspace" +import { instanceContextLayer, instanceRouterLayer } from "./instance-context" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" -const Query = Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - auth_token: Schema.optional(Schema.String), -}) - -const Headers = Schema.Struct({ - authorization: Schema.optional(Schema.String), - "x-opencode-directory": Schema.optional(Schema.String), -}) - export const context = Context.empty() as Context.Context -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -function currentDirectory() { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - -const instance = HttpRouter.middleware()( - Effect.gen(function* () { - return (effect) => - Effect.gen(function* () { - const query = yield* HttpServerRequest.schemaSearchParams(Query) - const headers = yield* HttpServerRequest.schemaHeaders(Headers) - const raw = query.directory || headers["x-opencode-directory"] || currentDirectory() - const workspace = query.workspace || undefined - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(raw)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - - const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect - return yield* next.pipe(Effect.provideService(InstanceRef, ctx)) - }) - }), -).layer - const runtime = HttpRouter.middleware()( Effect.succeed((effect) => Effect.gen(function* () { @@ -118,25 +66,8 @@ const runtime = HttpRouter.middleware()( ), ).layer -const rootApiRoutes = Layer.mergeAll(HttpApiBuilder.layer(ControlApi), HttpApiBuilder.layer(GlobalApi)).pipe( - Layer.provide([controlHandlers, globalHandlers]), -) -const instanceApiRoutes = Layer.mergeAll( - HttpApiBuilder.layer(ConfigApi), - HttpApiBuilder.layer(ExperimentalApi), - HttpApiBuilder.layer(FileApi), - HttpApiBuilder.layer(InstanceApi), - HttpApiBuilder.layer(McpApi), - HttpApiBuilder.layer(ProjectApi), - HttpApiBuilder.layer(PtyApi), - HttpApiBuilder.layer(QuestionApi), - HttpApiBuilder.layer(PermissionApi), - HttpApiBuilder.layer(ProviderApi), - HttpApiBuilder.layer(SessionApi), - HttpApiBuilder.layer(SyncApi), - HttpApiBuilder.layer(TuiApi), - HttpApiBuilder.layer(WorkspaceApi), -).pipe( +const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ configHandlers, experimentalHandlers, @@ -155,8 +86,9 @@ const instanceApiRoutes = Layer.mergeAll( ]), ) -const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe( - Layer.provide([authorizationLayer, instance]), +const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) +const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( + Layer.provide([authorizationLayer, instanceContextLayer]), ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 6997e75c2d..abc136374c 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { ControlPaths } from "../../src/server/routes/instance/httpapi/control" -import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 3978631b87..a4b0b66199 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index f9e94eeaa5..b7425007e1 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context } from "effect" import path from "path" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 4ab1da11e6..8e48284dea 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index be0e49aa8d..b88a032f5d 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -4,8 +4,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index f698c5ea7a..e348866528 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 87e2a94120..37d2a4f64d 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 3e3fb35731..593f9765c7 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 2a81305c62..5fa6784a13 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 81a2105095..9f7c8e9e89 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -3,7 +3,7 @@ import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" -import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui" +import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" import { callTui } from "../../src/server/routes/instance/tui" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index cb549c6497..74c3fb0e15 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -6,7 +6,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdaptor } from "../../src/control-plane/adaptors" import type { WorkspaceAdaptor } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server"