feat(core): add session form service (#34855)
This commit is contained in:
parent
460cdc5aec
commit
7ebd344fa2
67 changed files with 7862 additions and 5223 deletions
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
136
packages/server/src/handlers/form.ts
Normal file
136
packages/server/src/handlers/form.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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))))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
76
packages/server/src/middleware/form-location.ts
Normal file
76
packages/server/src/middleware/form-location.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue