feat(core): add command registry (#30624)
This commit is contained in:
@@ -20,7 +20,7 @@ import { SessionApi } from "./groups/session"
|
||||
import { SyncApi } from "./groups/sync"
|
||||
import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
import { V2Api } from "@opencode-ai/server/api"
|
||||
// GlobalEventSchema snapshots the registry after event-producing groups register their variants.
|
||||
import { GlobalApi } from "./groups/global"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
@@ -60,7 +60,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(ProviderApi)
|
||||
.addHttpApi(SessionApi)
|
||||
.addHttpApi(SyncApi)
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
@@ -69,6 +68,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||
.addHttpApi(RootHttpApi)
|
||||
.addHttpApi(EventApi)
|
||||
.addHttpApi(InstanceHttpApi)
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(PtyConnectApi)
|
||||
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
@@ -51,7 +52,7 @@ const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||
export const ToolListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
provider: ProviderV2.ID,
|
||||
model: ProviderV2.ModelID,
|
||||
model: ModelV2.ID,
|
||||
})
|
||||
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
|
||||
@@ -20,11 +20,14 @@ const SyncEventSchemas = EventV2.registry
|
||||
return [
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("sync"),
|
||||
name: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
|
||||
id: Schema.String,
|
||||
seq: Schema.Finite,
|
||||
aggregateID: Schema.Literal(definition.sync.aggregate),
|
||||
data: definition.data,
|
||||
syncEvent: Schema.Struct({
|
||||
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
|
||||
id: Schema.String,
|
||||
seq: Schema.Finite,
|
||||
aggregateID: Schema.String,
|
||||
data: definition.data,
|
||||
}),
|
||||
}).annotate({ identifier: `SyncEvent.${definition.type}` }),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const root = "/session"
|
||||
export const ListQuery = Schema.Struct({
|
||||
@@ -57,13 +58,13 @@ export const UpdatePayload = Schema.Struct({
|
||||
})
|
||||
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
|
||||
export const InitPayload = Schema.Struct({
|
||||
modelID: ProviderV2.ModelID,
|
||||
modelID: ModelV2.ID,
|
||||
providerID: ProviderV2.ID,
|
||||
messageID: MessageID,
|
||||
})
|
||||
export const SummarizePayload = Schema.Struct({
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ProviderV2.ModelID,
|
||||
modelID: ModelV2.ID,
|
||||
auto: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
|
||||
import { MessageGroup } from "./v2/message"
|
||||
import { ModelGroup } from "./v2/model"
|
||||
import { ProviderGroup } from "./v2/provider"
|
||||
import { SessionGroup } from "./v2/session"
|
||||
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
|
||||
import { FileSystemGroup } from "./v2/fs"
|
||||
import { QuestionGroup, SessionQuestionGroup } from "./v2/question"
|
||||
|
||||
export const V2Api = HttpApi.make("v2")
|
||||
.add(SessionGroup)
|
||||
.add(MessageGroup)
|
||||
.add(ModelGroup)
|
||||
.add(ProviderGroup)
|
||||
.add(PermissionGroup)
|
||||
.add(SessionPermissionGroup)
|
||||
.add(PermissionSavedGroup)
|
||||
.add(FileSystemGroup)
|
||||
.add(QuestionGroup)
|
||||
.add(SessionQuestionGroup)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
|
||||
const ReadQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: RelativePath,
|
||||
reference: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
reference: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
||||
.add(
|
||||
HttpApiEndpoint.get("read", "/api/fs/read", {
|
||||
query: ReadQuery,
|
||||
success: FileSystem.Content,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.fs.read",
|
||||
summary: "Read file",
|
||||
description: "Read one file relative to the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", "/api/fs/list", {
|
||||
query: ListQuery,
|
||||
success: Schema.Array(FileSystem.Entry),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.fs.list",
|
||||
summary: "List directory",
|
||||
description: "List direct children of one directory relative to the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2 filesystem",
|
||||
description: "Experimental v2 location-scoped filesystem routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const LocationQuery = Schema.Struct({
|
||||
location: Schema.optional(
|
||||
Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2LocationQuery" })
|
||||
|
||||
export const locationQueryOpenApi = OpenApi.annotations({
|
||||
transform: (operation) => {
|
||||
const parameters = operation.parameters
|
||||
if (!Array.isArray(parameters)) return operation
|
||||
return {
|
||||
...operation,
|
||||
parameters: parameters.map((parameter) =>
|
||||
parameter?.name === "location" && parameter?.in === "query"
|
||||
? { ...parameter, style: "deepObject", explode: true }
|
||||
: parameter,
|
||||
),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
||||
V2LocationMiddleware,
|
||||
{
|
||||
provides:
|
||||
| Catalog.Service
|
||||
| PluginBoot.Service
|
||||
| PermissionV2.Service
|
||||
| ProjectReference.Service
|
||||
| FileSystem.Service
|
||||
| QuestionV2.Service
|
||||
}
|
||||
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
||||
|
||||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
||||
return {
|
||||
directory: AbsolutePath.make(
|
||||
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
||||
),
|
||||
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
V2LocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap
|
||||
return V2LocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
|
||||
export const MessagesQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
||||
).annotate({
|
||||
description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
cursor: Schema.optional(
|
||||
Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2SessionMessagesQuery" })
|
||||
|
||||
export const MessageGroup = HttpApiGroup.make("v2.message")
|
||||
.add(
|
||||
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
|
||||
params: { sessionID: SessionID },
|
||||
query: MessagesQuery,
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionMessage.Message),
|
||||
cursor: Schema.Struct({
|
||||
previous: Schema.String.pipe(Schema.optional),
|
||||
next: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionMessagesResponse" }),
|
||||
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.messages",
|
||||
summary: "Get v2 session messages",
|
||||
description:
|
||||
"Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2 messages",
|
||||
description: "Experimental v2 message routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ServiceUnavailableError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
|
||||
export const ModelGroup = HttpApiGroup.make("v2.model")
|
||||
.add(
|
||||
HttpApiEndpoint.get("models", "/api/model", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(ModelV2.PublicInfo),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.model.list",
|
||||
summary: "List v2 models",
|
||||
description: "Retrieve available v2 models ordered by release date.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2 models",
|
||||
description: "Experimental v2 model routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,94 +0,0 @@
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
|
||||
export const PermissionGroup = HttpApiGroup.make("v2.permission")
|
||||
.add(
|
||||
HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(PermissionV2.Request),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.permission.request.list",
|
||||
summary: "List pending permission requests",
|
||||
description: "Retrieve pending permission requests for a location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." }))
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2Authorization)
|
||||
|
||||
export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
|
||||
params: { sessionID: SessionV2.ID },
|
||||
success: Schema.Array(PermissionV2.Request),
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.list",
|
||||
summary: "List session permission requests",
|
||||
description: "Retrieve pending permission requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", {
|
||||
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
|
||||
payload: Schema.Struct({
|
||||
reply: PermissionV2.Reply,
|
||||
message: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, PermissionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.reply",
|
||||
summary: "Reply to pending permission request",
|
||||
description: "Respond to a pending permission request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." }),
|
||||
)
|
||||
.middleware(V2Authorization)
|
||||
|
||||
export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
|
||||
.add(
|
||||
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
|
||||
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
|
||||
success: Schema.Array(PermissionSaved.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.permission.saved.list",
|
||||
summary: "List saved permissions",
|
||||
description: "Retrieve saved permissions, optionally filtered by project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", {
|
||||
params: { id: PermissionSaved.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.permission.saved.remove",
|
||||
summary: "Remove saved permission",
|
||||
description: "Remove a saved permission by ID.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." }),
|
||||
)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
|
||||
export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("providers", "/api/provider", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(ProviderV2.PublicInfo),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.provider.list",
|
||||
summary: "List v2 providers",
|
||||
description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
|
||||
params: { providerID: ProviderV2.ID },
|
||||
query: LocationQuery,
|
||||
success: ProviderV2.PublicInfo,
|
||||
error: [ProviderNotFoundError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.provider.get",
|
||||
summary: "Get v2 provider",
|
||||
description:
|
||||
"Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2 providers",
|
||||
description: "Experimental v2 provider routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,59 +0,0 @@
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
|
||||
export const QuestionGroup = HttpApiGroup.make("v2.question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("questionRequests", "/api/question/request", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(QuestionV2.Request),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.question.request.list",
|
||||
summary: "List pending question requests",
|
||||
description: "Retrieve pending question requests for a location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "v2 questions", description: "Experimental v2 question routes." }))
|
||||
.middleware(V2LocationMiddleware)
|
||||
.middleware(V2Authorization)
|
||||
|
||||
export const SessionQuestionGroup = HttpApiGroup.make("v2.session.question")
|
||||
.add(
|
||||
HttpApiEndpoint.post("questionRequestReply", "/api/session/:sessionID/question/request/:requestID/reply", {
|
||||
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
|
||||
payload: QuestionV2.Reply,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reply",
|
||||
summary: "Reply to pending question request",
|
||||
description: "Answer a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("questionRequestReject", "/api/session/:sessionID/question/request/:requestID/reject", {
|
||||
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reject",
|
||||
summary: "Reject pending question request",
|
||||
description: "Reject a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ title: "v2 session questions", description: "Experimental v2 session question routes." }),
|
||||
)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,178 +0,0 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const SessionsProjectQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
project: ProjectV2.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
|
||||
|
||||
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({
|
||||
...Struct.omit(fields, ["limit"]),
|
||||
anchor: SessionV2.ListAnchor,
|
||||
}))
|
||||
|
||||
const SessionsCursorInput = Schema.Union([
|
||||
withCursor(SessionsDirectoryQuery),
|
||||
withCursor(SessionsProjectQuery),
|
||||
withCursor(SessionsAllQuery),
|
||||
])
|
||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||
|
||||
export const SessionsCursor = Schema.String.pipe(
|
||||
Schema.brand("V2SessionsCursor"),
|
||||
withStatics((schema) => {
|
||||
const make = schema.make
|
||||
return {
|
||||
make: (input: typeof SessionsCursorInput.Type) =>
|
||||
make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
|
||||
parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type SessionsCursor = typeof SessionsCursor.Type
|
||||
|
||||
const SessionsCursorQuery = Schema.Struct({
|
||||
cursor: SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
}),
|
||||
limit: SessionsQueryFields.limit,
|
||||
})
|
||||
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: ProjectV2.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "V2SessionsQuery" })
|
||||
|
||||
export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessions", "/api/session", {
|
||||
query: SessionsQuery,
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionV2.Info),
|
||||
cursor: Schema.Struct({
|
||||
previous: SessionsCursor.pipe(Schema.optional),
|
||||
next: SessionsCursor.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionsResponse" }),
|
||||
error: [InvalidCursorError, InvalidRequestError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.list",
|
||||
summary: "List v2 sessions",
|
||||
description:
|
||||
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Struct({
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
prompt: Prompt,
|
||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: SessionMessage.User,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.prompt",
|
||||
summary: "Send v2 message",
|
||||
description: "Durably admit one v2 session input and schedule agent-loop execution unless resume is false.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.compact",
|
||||
summary: "Compact v2 session",
|
||||
description: "Compact a v2 session conversation.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.wait",
|
||||
summary: "Wait for v2 session",
|
||||
description: "Wait for a v2 session agent loop to become idle.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Array(SessionMessage.Message),
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.context",
|
||||
summary: "Get v2 session context",
|
||||
description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2",
|
||||
description: "Experimental v2 routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(V2Authorization)
|
||||
@@ -1,47 +0,0 @@
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Layer } from "effect"
|
||||
import { layer as v2LocationLayer } from "../groups/v2/location"
|
||||
import { messageHandlers } from "./v2/message"
|
||||
import { modelHandlers } from "./v2/model"
|
||||
import { providerHandlers } from "./v2/provider"
|
||||
import { sessionHandlers } from "./v2/session"
|
||||
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
|
||||
import { fileSystemHandlers } from "./v2/fs"
|
||||
import { questionHandlers, sessionQuestionHandlers } from "./v2/question"
|
||||
|
||||
const routedSessions = SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
export const v2Handlers = Layer.mergeAll(
|
||||
sessionHandlers,
|
||||
messageHandlers,
|
||||
modelHandlers,
|
||||
providerHandlers,
|
||||
permissionHandlers,
|
||||
sessionPermissionHandlers,
|
||||
savedPermissionHandlers,
|
||||
fileSystemHandlers,
|
||||
questionHandlers,
|
||||
sessionQuestionHandlers,
|
||||
).pipe(
|
||||
Layer.provide(v2LocationLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(PermissionSaved.layer),
|
||||
Layer.provide(routedSessions),
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
||||
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
|
||||
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
|
||||
}),
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(Cursor)
|
||||
|
||||
const cursor = {
|
||||
encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") {
|
||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
||||
},
|
||||
decode(input: string) {
|
||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
||||
},
|
||||
}
|
||||
|
||||
export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
return handlers.handle(
|
||||
"messages",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && ctx.query.order !== undefined)
|
||||
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
||||
})
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const messages = yield* session
|
||||
.messages({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode v2 session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
items: messages,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { ServiceUnavailableError } from "../../errors"
|
||||
|
||||
const catalogUnavailable = new ServiceUnavailableError({
|
||||
message: "Model catalog is unavailable",
|
||||
service: "catalog",
|
||||
})
|
||||
|
||||
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers.handle(
|
||||
"models",
|
||||
Effect.fn(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const pluginBoot = yield* PluginBoot.Service
|
||||
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||
return (yield* catalog.model.available()).map(ModelV2.toPublic)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
|
||||
function missingRequest(id: PermissionV2.ID) {
|
||||
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
|
||||
}
|
||||
|
||||
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers.handle(
|
||||
"permissionRequests",
|
||||
Effect.fn(function* () {
|
||||
return yield* (yield* PermissionV2.Service).list()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
|
||||
const withSessionPermission = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: Parameters<PermissionV2.Interface["forSession"]>[0],
|
||||
use: (permission: PermissionV2.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
return yield* use(yield* PermissionV2.Service)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"sessionPermissionRequests",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
|
||||
permission.forSession(ctx.params.sessionID),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"permissionRequestReply",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withSessionPermission(ctx.params.sessionID, (permission) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* permission.get(ctx.params.requestID)
|
||||
if (!request || request.sessionID !== ctx.params.sessionID)
|
||||
return yield* missingRequest(ctx.params.requestID)
|
||||
yield* permission
|
||||
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
|
||||
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
|
||||
}),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
return handlers
|
||||
.handle(
|
||||
"savedPermissions",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* saved.list({ projectID: ctx.query.projectID })
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"removeSavedPermission",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* saved.remove(ctx.params.id)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
|
||||
|
||||
const catalogUnavailable = new ServiceUnavailableError({
|
||||
message: "Provider catalog is unavailable",
|
||||
service: "catalog",
|
||||
})
|
||||
|
||||
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle(
|
||||
"providers",
|
||||
Effect.fn(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const pluginBoot = yield* PluginBoot.Service
|
||||
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||
return (yield* catalog.provider.available()).map(ProviderV2.toPublic)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"provider",
|
||||
Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const pluginBoot = yield* PluginBoot.Service
|
||||
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||
return yield* catalog.provider.get(ctx.params.providerID).pipe(
|
||||
Effect.map(ProviderV2.toPublic),
|
||||
Effect.catchTag("CatalogV2.ProviderNotFound", (error) =>
|
||||
Effect.fail(
|
||||
new ProviderNotFoundError({
|
||||
providerID: error.providerID,
|
||||
message: `Provider not found: ${error.providerID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
|
||||
function missingRequest(id: QuestionV2.ID) {
|
||||
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
|
||||
}
|
||||
|
||||
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers.handle(
|
||||
"questionRequests",
|
||||
Effect.fn(function* () {
|
||||
return yield* (yield* QuestionV2.Service).list()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const sessionQuestionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
|
||||
const withSessionQuestion = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: QuestionV2.Request["sessionID"],
|
||||
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
return yield* use(yield* QuestionV2.Service)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: QuestionV2.Request["sessionID"],
|
||||
requestID: QuestionV2.ID,
|
||||
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
return yield* withSessionQuestion(sessionID, (question) =>
|
||||
Effect.gen(function* () {
|
||||
const request = (yield* question.list()).find((request) => request.id === requestID)
|
||||
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
|
||||
return yield* use(question)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"questionRequestReply",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
|
||||
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"questionRequestReject",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reject(ctx.params.requestID)
|
||||
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,173 +0,0 @@
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { SessionsCursor } from "../../groups/v2/session"
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "../../errors"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"sessions",
|
||||
Effect.fn(function* (ctx) {
|
||||
const query =
|
||||
ctx.query.cursor !== undefined
|
||||
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: ctx.query
|
||||
const sessions = yield* session.list({
|
||||
...query,
|
||||
workspaceID: query.workspace,
|
||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||
})
|
||||
const first = sessions[0]
|
||||
const last = sessions.at(-1)
|
||||
return {
|
||||
items: sessions,
|
||||
cursor: {
|
||||
previous: first
|
||||
? SessionsCursor.make({
|
||||
...query,
|
||||
anchor: {
|
||||
id: first.id,
|
||||
time: DateTime.toEpochMillis(first.time.created),
|
||||
direction: "previous",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
next: last
|
||||
? SessionsCursor.make({
|
||||
...query,
|
||||
anchor: {
|
||||
id: last.id,
|
||||
time: DateTime.toEpochMillis(last.time.created),
|
||||
direction: "next",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session
|
||||
.prompt({
|
||||
sessionID: ctx.params.sessionID,
|
||||
id: ctx.payload.id,
|
||||
prompt: ctx.payload.prompt,
|
||||
delivery: ctx.payload.delivery,
|
||||
resume: ctx.payload.resume,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.PromptConflictError", (error) =>
|
||||
Effect.fail(
|
||||
new ConflictError({
|
||||
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
|
||||
resource: error.messageID,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"compact",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `V2 session ${error.operation} is not available yet`,
|
||||
service: `v2.session.${error.operation}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"wait",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.wait(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `V2 session ${error.operation} is not available yet`,
|
||||
service: `v2.session.${error.operation}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"context",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session.context(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode v2 session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
|
||||
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
|
||||
import { isPublicUIPath } from "@/server/shared/public-ui"
|
||||
import { UnauthorizedError } from "../errors"
|
||||
export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
|
||||
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
@@ -20,13 +20,6 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
},
|
||||
) {}
|
||||
|
||||
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiV2Authorization",
|
||||
{
|
||||
error: UnauthorizedError,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
|
||||
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
|
||||
{
|
||||
@@ -152,27 +145,3 @@ export const ptyConnectAuthorizationLayer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const v2AuthorizationLayer = Layer.effect(
|
||||
V2Authorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuth.Config
|
||||
if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
|
||||
return V2Authorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) =>
|
||||
Effect.gen(function* () {
|
||||
if (ServerAuth.authorized(credential, config)) return yield* effect
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
||||
)
|
||||
return yield* new UnauthorizedError({ message: "Authentication required" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ import { Todo } from "@/session/todo"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Skill } from "@/skill"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
@@ -56,6 +57,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { V2Api } from "@opencode-ai/server/api"
|
||||
import { PublicApi } from "./public"
|
||||
import {
|
||||
authorizationLayer,
|
||||
@@ -82,7 +84,8 @@ import { questionHandlers } from "./handlers/question"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
import { v2Handlers } from "./handlers/v2"
|
||||
import { v2Handlers } from "@opencode-ai/server/handlers"
|
||||
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
|
||||
import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer } from "./middleware/instance-context"
|
||||
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
@@ -144,14 +147,17 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
providerHandlers,
|
||||
sessionHandlers,
|
||||
syncHandlers,
|
||||
v2Handlers,
|
||||
tuiHandlers,
|
||||
workspaceHandlers,
|
||||
]),
|
||||
)
|
||||
|
||||
const instanceRoutes = instanceApiRoutes.pipe(
|
||||
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
)
|
||||
const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
|
||||
Layer.provide(v2Handlers),
|
||||
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
@@ -186,7 +192,7 @@ type RouteRequirements =
|
||||
export function createRoutes(
|
||||
corsOptions?: CorsOptions,
|
||||
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, v2Routes, docRoute, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
@@ -226,6 +232,7 @@ export function createRoutes(
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
|
||||
Reference in New Issue
Block a user