feat(core): add session form service (#34855)

This commit is contained in:
Aiden Cline 2026-07-02 17:15:18 -05:00 committed by GitHub
commit 7ebd344fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 7862 additions and 5223 deletions

View file

@ -1,8 +1,12 @@
import { makeDefaultApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "./location"
import { FormLocationMiddleware } from "./middleware/form-location"
import { SessionLocationMiddleware } from "./middleware/session-location"
export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware,
// FormLocationMiddleware contains the temporary `sessionID === "global"` MCP elicitation hack.
// Do not use that sentinel with general session APIs.
formLocationMiddleware: FormLocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})

View file

@ -6,6 +6,7 @@ import { ProviderHandler } from "./handlers/provider"
import { SessionHandler } from "./handlers/session"
import { PermissionHandler } from "./handlers/permission"
import { FileSystemHandler } from "./handlers/fs"
import { FormHandler } from "./handlers/form"
import { CommandHandler } from "./handlers/command"
import { SkillHandler } from "./handlers/skill"
import { EventHandler } from "./handlers/event"
@ -14,7 +15,6 @@ import { PluginHandler } from "./handlers/plugin"
import { HealthHandler } from "./handlers/health"
import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import { LocationHandler } from "./handlers/location"
import { IntegrationHandler } from "./handlers/integration"
@ -37,6 +37,7 @@ export const handlers = Layer.mergeAll(
McpHandler,
CredentialHandler,
ProjectHandler,
FormHandler,
PermissionHandler,
FileSystemHandler,
CommandHandler,
@ -44,7 +45,6 @@ export const handlers = Layer.mergeAll(
EventHandler,
PtyHandler,
ShellHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,
)

View file

@ -0,0 +1,136 @@
import { Form } from "@opencode-ai/core/form"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import {
ConflictError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidRequestError,
} from "@opencode-ai/protocol/errors"
import { Api } from "../api"
import { response } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
}
function alreadySettled(error: Form.AlreadySettledError) {
return new FormAlreadySettledError({ id: error.id, message: error.message })
}
function alreadyExists(error: Form.AlreadyExistsError) {
return new ConflictError({ resource: error.id, message: error.message })
}
function invalidAnswer(error: Form.InvalidAnswerError) {
return new FormInvalidAnswerError({ id: error.id, message: error.message })
}
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const withOwnedForm = Effect.fnUntraced(function* <A, E>(
sessionID: string,
formID: Form.ID,
use: (service: Form.Interface, info: Form.Info) => Effect.Effect<A, E>,
) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
if (info.sessionID !== sessionID) return yield* missingForm(formID)
return yield* use(form, info)
})
return handlers
.handle(
"form.request.list",
Effect.fn(function* () {
const form = yield* Form.Service
return yield* response(form.list())
}),
)
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
return yield* response(form.list({ sessionID: ctx.params.sessionID }))
}),
)
.handle(
"session.form.create",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
if (ctx.payload.mode === "form") {
if (!ctx.payload.fields) {
return yield* new InvalidRequestError({ message: "Form fields are required", field: "fields" })
}
return yield* response(
form.create({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
title: ctx.payload.title,
metadata: ctx.payload.metadata,
mode: "form",
fields: ctx.payload.fields,
}).pipe(Effect.catchTag("Form.AlreadyExistsError", alreadyExists)),
)
}
if (!ctx.payload.url) return yield* new InvalidRequestError({ message: "Form URL is required", field: "url" })
return yield* response(
form.create({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
title: ctx.payload.title,
metadata: ctx.payload.metadata,
mode: "url",
url: ctx.payload.url,
}).pipe(Effect.catchTag("Form.AlreadyExistsError", alreadyExists)),
)
}),
)
.handle(
"session.form.get",
Effect.fn(function* (ctx) {
return yield* response(withOwnedForm(ctx.params.sessionID, ctx.params.formID, (_, info) => Effect.succeed(info)))
}),
)
.handle(
"session.form.state",
Effect.fn(function* (ctx) {
return yield* response(
withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
form.state(ctx.params.formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID))),
),
)
}),
)
.handle(
"session.form.reply",
Effect.fn(function* (ctx) {
yield* withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
Effect.catchTags({
"Form.AlreadySettledError": alreadySettled,
"Form.InvalidAnswerError": invalidAnswer,
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.form.cancel",
Effect.fn(function* (ctx) {
yield* withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
form.cancel(ctx.params.formID).pipe(
Effect.catchTags({
"Form.AlreadySettledError": alreadySettled,
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View file

@ -1,62 +0,0 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
function missingRequest(id: QuestionV2.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
}
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
Effect.gen(function* () {
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
requestID: QuestionV2.ID,
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
const question = yield* QuestionV2.Service
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(
"question.request.list",
Effect.fn(function* () {
return yield* response((yield* QuestionV2.Service).list())
}),
)
.handle(
"session.question.list",
Effect.fn(function* (ctx) {
const requests = yield* (yield* QuestionV2.Service).list()
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
}),
)
.handle(
"session.question.reply",
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(
"session.question.reject",
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()
}),
)
}),
)

View file

@ -26,7 +26,7 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
})
}
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
export function requestRef(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"]
const directory =
@ -53,7 +53,7 @@ export const layer = Layer.effect(
return LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}),
)
}),

View file

@ -0,0 +1,76 @@
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { requestRef, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiFormLocation", {
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
if (route.params.sessionID === "global") {
// Temporary MCP elicitation escape hatch. This is still Location-scoped; it only bypasses
// the session row lookup because some MCP elicitations cannot currently be attributed to
// a real session. Keep this undocumented and remove once elicitations carry session ownership.
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
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.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),
)
}),
)

View file

@ -24,6 +24,7 @@ import { authorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
import { PtyEnvironment } from "./pty-environment"
import { layer as locationLayer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
const applicationServices = LayerNode.group([
@ -79,6 +80,7 @@ function makeRoutes<AuthError, AuthServices>(
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers),
Layer.provide(formLocationLayer),
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(authorizationLayer),