mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 07:26:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f903f899a |
@@ -1424,7 +1424,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1435,7 +1435,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1447,7 +1447,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
|
||||
body: { answer: input["answer"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 409, 400, 401],
|
||||
declaredStatuses: [409, 400, 404, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1458,7 +1458,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 409, 400, 401],
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -2,8 +2,11 @@ export * as Form from "./form.js"
|
||||
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
|
||||
const RETENTION = Duration.minutes(10)
|
||||
|
||||
@@ -76,6 +79,8 @@ export interface ReplyInput {
|
||||
|
||||
export interface ListInput {
|
||||
readonly sessionID?: Form.Info["sessionID"]
|
||||
/** Restrict to forms routed to this Location; `form.request.list` keeps its per-Location shape. */
|
||||
readonly location?: Location.Ref
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -94,12 +99,18 @@ interface Entry {
|
||||
readonly form: Info
|
||||
readonly state: State
|
||||
readonly deferred: Deferred.Deferred<TerminalState>
|
||||
// Event route, fixed at creation. Undefined leaves Bus to its ambient fallback.
|
||||
readonly location: Location.Ref | undefined
|
||||
}
|
||||
|
||||
// The ledger is process-global: pending forms are transient state keyed by Session, so reading
|
||||
// them must not boot the Session's Location. Events still route per Location, resolved once at
|
||||
// creation from the owning Session row, or from the ambient Location for the MCP `global` owner.
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const forms = yield* Cache.makeWith<ID, Entry>(
|
||||
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
|
||||
{
|
||||
@@ -115,6 +126,13 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const resolveLocation = Effect.fnUntraced(function* (sessionID: Info["sessionID"]) {
|
||||
const session = Schema.is(SessionSchema.ID)(sessionID) ? yield* store.get(sessionID) : undefined
|
||||
if (session) return session.location
|
||||
const ambient = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
return ambient ? { directory: ambient.directory, workspaceID: ambient.workspaceID } : undefined
|
||||
})
|
||||
|
||||
const create = Effect.fn("Form.create")((input: CreateInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
@@ -134,9 +152,12 @@ export const layer = Layer.effect(
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
deferred: yield* Deferred.make<TerminalState>(),
|
||||
location: yield* resolveLocation(input.sessionID),
|
||||
}
|
||||
yield* Cache.set(forms, id, entry)
|
||||
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
|
||||
yield* bus
|
||||
.publish(Form.Event.Created, { form }, { location: entry.location })
|
||||
.pipe(Effect.onError(() => Cache.invalidate(forms, id)))
|
||||
return form
|
||||
}),
|
||||
),
|
||||
@@ -163,6 +184,12 @@ export const layer = Layer.effect(
|
||||
return Array.from(entries)
|
||||
.filter((entry) => entry.state.status === "pending")
|
||||
.filter((entry) => input?.sessionID === undefined || entry.form.sessionID === input.sessionID)
|
||||
.filter(
|
||||
(entry) =>
|
||||
input?.location === undefined ||
|
||||
(entry.location?.directory === input.location.directory &&
|
||||
entry.location.workspaceID === input.location.workspaceID),
|
||||
)
|
||||
.map((entry) => entry.form)
|
||||
})
|
||||
|
||||
@@ -178,11 +205,11 @@ export const layer = Layer.effect(
|
||||
const invalid = validateAnswer(entry.form.fields, input.answer)
|
||||
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
|
||||
const next: TerminalState = { status: "answered", answer: input.answer }
|
||||
yield* bus.publish(Form.Event.Replied, {
|
||||
id: input.id,
|
||||
sessionID: entry.form.sessionID,
|
||||
answer: input.answer,
|
||||
})
|
||||
yield* bus.publish(
|
||||
Form.Event.Replied,
|
||||
{ id: input.id, sessionID: entry.form.sessionID, answer: input.answer },
|
||||
{ location: entry.location },
|
||||
)
|
||||
yield* Cache.set(forms, input.id, { ...entry, state: next })
|
||||
yield* Deferred.succeed(entry.deferred, next)
|
||||
}),
|
||||
@@ -195,7 +222,11 @@ export const layer = Layer.effect(
|
||||
const entry = yield* requireEntry(id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
|
||||
const next: TerminalState = { status: "cancelled" }
|
||||
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
|
||||
yield* bus.publish(
|
||||
Form.Event.Cancelled,
|
||||
{ id, sessionID: entry.form.sessionID },
|
||||
{ location: entry.location },
|
||||
)
|
||||
yield* Cache.set(forms, id, { ...entry, state: next })
|
||||
yield* Deferred.succeed(entry.deferred, next)
|
||||
}),
|
||||
@@ -218,7 +249,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, SessionStore.node] })
|
||||
|
||||
export function validateAnswer(form: ReadonlyArray<Form.Field>, answer: Answer) {
|
||||
const fields = new Map(form.map((field) => [field.key, field] as const))
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
@@ -91,7 +90,6 @@ const nodes = [
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const forms = AppNodeBuilder.build(LayerNode.group([Bus.node, Form.node]))
|
||||
// No LocationServiceMap or Instance in this graph: the ledger must serve Session-keyed reads
|
||||
// and route events without booting the Session's Location.
|
||||
const forms = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Form.node]))
|
||||
const it = testEffect(forms)
|
||||
|
||||
const formID = Form.ID.create("frm_test")
|
||||
@@ -18,7 +28,110 @@ const input = {
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
|
||||
const b = Location.Ref.make({ directory: AbsolutePath.make("/b") })
|
||||
const Done = Bus.ephemeral({ type: "test.form.done", schema: {} })
|
||||
|
||||
const seed = Effect.fn(function* (sessions: ReadonlyArray<{ id: SessionSchema.ID; ref: Location.Ref }>) {
|
||||
const database = yield* Database.Service
|
||||
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: a.directory, sandboxes: [] }).run()
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values(
|
||||
sessions.map((session) => ({
|
||||
id: session.id,
|
||||
project_id: Project.ID.global,
|
||||
directory: session.ref.directory,
|
||||
workspace_id: session.ref.workspaceID,
|
||||
slug: session.id,
|
||||
version: "test",
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
})
|
||||
|
||||
// Collects what a client subscribed at `ref` sees until the global Done marker.
|
||||
const watch = (bus: Bus.Interface, ref: Location.Ref) =>
|
||||
bus
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.filter((event) => event.type !== Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.provideService(Location.Service, location(ref)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
describe("Form", () => {
|
||||
it.effect("serves Session-keyed reads from the global ledger without the Session's Location", () =>
|
||||
Effect.gen(function* () {
|
||||
const other = SessionSchema.ID.make("ses_other")
|
||||
yield* seed([
|
||||
{ id: input.sessionID, ref: a },
|
||||
{ id: other, ref: b },
|
||||
])
|
||||
const service = yield* Form.Service
|
||||
|
||||
const created = yield* service.create(input)
|
||||
|
||||
expect(yield* service.list({ sessionID: input.sessionID })).toEqual([created])
|
||||
expect(yield* service.list({ sessionID: other })).toEqual([])
|
||||
expect(yield* service.list({ location: a })).toEqual([created])
|
||||
expect(yield* service.list({ location: b })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes events to the owning Session's Location without an ambient Location", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seed([{ id: input.sessionID, ref: a }])
|
||||
const service = yield* Form.Service
|
||||
const bus = yield* Bus.Service
|
||||
const atA = yield* watch(bus, a)
|
||||
const atB = yield* watch(bus, b)
|
||||
|
||||
const created = yield* service.create(input)
|
||||
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
||||
yield* bus.publish(Done, {}, { global: true })
|
||||
|
||||
const seen = Array.from(yield* Fiber.join(atA))
|
||||
expect(seen.map((event) => [event.type, event.location])).toEqual([
|
||||
["form.created", a],
|
||||
["form.replied", a],
|
||||
])
|
||||
expect(Array.from(yield* Fiber.join(atB))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scopes the global mcp elicitation owner to its ambient Location", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const bus = yield* Bus.Service
|
||||
const atA = yield* watch(bus, a)
|
||||
const atB = yield* watch(bus, b)
|
||||
|
||||
const created = yield* service
|
||||
.create({
|
||||
sessionID: "global",
|
||||
title: "MCP input",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
})
|
||||
.pipe(Effect.provideService(Location.Service, location(a)))
|
||||
expect(yield* service.list({ sessionID: "global", location: a })).toEqual([created])
|
||||
expect(yield* service.list({ sessionID: "global", location: b })).toEqual([])
|
||||
|
||||
// Settling from outside the Location, as the HTTP cancel route does, keeps the creation route.
|
||||
yield* service.cancel(created.id)
|
||||
yield* bus.publish(Done, {}, { global: true })
|
||||
|
||||
const seen = Array.from(yield* Fiber.join(atA))
|
||||
expect(seen.map((event) => [event.type, event.location])).toEqual([
|
||||
["form.created", a],
|
||||
["form.cancelled", a],
|
||||
])
|
||||
expect(Array.from(yield* Fiber.join(atB))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates absolute URI formats without restricting schemes", () =>
|
||||
Effect.sync(() => {
|
||||
const fields = [{ key: "uri", type: "string", format: "uri" }] satisfies ReadonlyArray<Form.Field>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
@@ -279,6 +280,8 @@ function resourceMcpLayer(
|
||||
},
|
||||
}),
|
||||
Layer.mock(Credential.Service, {}),
|
||||
// MCP elicitation forms use the `global` owner, so the ledger never consults the Session row.
|
||||
Layer.mock(SessionStore.Service, {}),
|
||||
overrides?.environment ?? hostEnvironmentLayer,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7767,21 +7767,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7854,21 +7844,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7934,21 +7914,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8027,21 +7997,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ const CreatePayload = Schema.Struct({
|
||||
|
||||
export type CreatePayload = typeof CreatePayload.Type
|
||||
|
||||
// Form routes intentionally look session-scoped, but use a form-specific middleware instead of
|
||||
// SessionLocationMiddleware. The middleware treats real session IDs normally and has an
|
||||
// undocumented `global` sentinel branch for MCP elicitation forms that are still Location-scoped
|
||||
// but not session-owned. This is temporary and should disappear once elicitations are attributable.
|
||||
// The form ledger is process-global and keyed by Session, so reading or settling a form never
|
||||
// needs the Session's Location. Only creation still runs inside the Location: it stamps the event
|
||||
// route and serves the undocumented `global` sentinel owner for MCP elicitation forms that are
|
||||
// Location-scoped but not session-owned. That sentinel is temporary and should disappear once
|
||||
// elicitations are attributable.
|
||||
export const makeFormGroup = <
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
@@ -83,9 +84,8 @@ export const makeFormGroup = <
|
||||
HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
success: Schema.Struct({ data: Form.Info }),
|
||||
error: [SessionNotFoundError, FormNotFoundError],
|
||||
error: FormNotFoundError,
|
||||
})
|
||||
.middleware(formLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.get",
|
||||
@@ -98,9 +98,8 @@ export const makeFormGroup = <
|
||||
HttpApiEndpoint.get("session.form.state", "/api/session/:sessionID/form/:formID/state", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
success: Schema.Struct({ data: Form.State }),
|
||||
error: [SessionNotFoundError, FormNotFoundError],
|
||||
error: FormNotFoundError,
|
||||
})
|
||||
.middleware(formLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.state",
|
||||
@@ -114,9 +113,8 @@ export const makeFormGroup = <
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
payload: Form.Reply,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
|
||||
error: [FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
|
||||
})
|
||||
.middleware(formLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.reply",
|
||||
@@ -129,9 +127,8 @@ export const makeFormGroup = <
|
||||
HttpApiEndpoint.post("session.form.cancel", "/api/session/:sessionID/form/:formID/cancel", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError],
|
||||
error: [FormAlreadySettledError, FormNotFoundError],
|
||||
})
|
||||
.middleware(formLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.cancel",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Instance } from "@opencode-ai/core/instance/service"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import {
|
||||
ConflictError,
|
||||
@@ -20,40 +19,38 @@ function missingForm(id: Form.ID) {
|
||||
|
||||
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const instances = yield* Instance.Service
|
||||
const form = yield* Form.Service
|
||||
const sessions = yield* Session.Service
|
||||
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
|
||||
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 { form, info }
|
||||
return info
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"form.request.list",
|
||||
Effect.fn(function* () {
|
||||
const form = yield* Form.Service
|
||||
return yield* response(form.list())
|
||||
const location = yield* Location.Service
|
||||
return yield* response(
|
||||
form.list({ location: { directory: location.directory, workspaceID: location.workspaceID } }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const session =
|
||||
ctx.params.sessionID === "global" ? undefined : yield* sessionInfo(sessions, ctx.params.sessionID)
|
||||
const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID }))
|
||||
const forms = yield* session
|
||||
? read.pipe(instances.provide(session))
|
||||
: read.pipe(Effect.provide(locations.get(requestRef(ctx.request))))
|
||||
return { data: forms }
|
||||
// The `global` MCP elicitation owner is Location-scoped rather than session-owned.
|
||||
if (ctx.params.sessionID === "global") {
|
||||
return { data: yield* form.list({ sessionID: "global", location: requestRef(ctx.request) }) }
|
||||
}
|
||||
yield* sessionInfo(sessions, ctx.params.sessionID)
|
||||
return { data: yield* form.list({ sessionID: ctx.params.sessionID }) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.create",
|
||||
Effect.fn(function* (ctx) {
|
||||
const form = yield* Form.Service
|
||||
const created = yield* form
|
||||
.create({
|
||||
id: ctx.payload.id,
|
||||
@@ -75,15 +72,14 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
||||
.handle(
|
||||
"session.form.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
return { data: owned.info }
|
||||
return { data: yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.state",
|
||||
Effect.fn(function* (ctx) {
|
||||
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
const data = yield* owned.form
|
||||
yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
const data = yield* form
|
||||
.state(ctx.params.formID)
|
||||
.pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID)))
|
||||
return { data }
|
||||
@@ -92,8 +88,8 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
||||
.handle(
|
||||
"session.form.reply",
|
||||
Effect.fn(function* (ctx) {
|
||||
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
yield* owned.form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
|
||||
yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
yield* form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadySettledError": (error) =>
|
||||
new FormAlreadySettledError({ id: error.id, message: error.message }),
|
||||
@@ -108,8 +104,8 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
||||
.handle(
|
||||
"session.form.cancel",
|
||||
Effect.fn(function* (ctx) {
|
||||
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
yield* owned.form.cancel(ctx.params.formID).pipe(
|
||||
yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
|
||||
yield* form.cancel(ctx.params.formID).pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadySettledError": (error) =>
|
||||
new FormAlreadySettledError({ id: error.id, message: error.message }),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventLogger } from "@opencode-ai/core/event-logger"
|
||||
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
@@ -57,6 +58,7 @@ const applicationServiceNodes = [
|
||||
Project.node,
|
||||
Worktree.node,
|
||||
Session.node,
|
||||
Form.node,
|
||||
Instance.node,
|
||||
SessionTransfer.node,
|
||||
SdkPlugins.node,
|
||||
|
||||
@@ -159,6 +159,7 @@ it.live(
|
||||
createEmbeddedRoutes({}, replacements).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sessions = Context.get(context, Session.Service)
|
||||
const forms = Context.get(context, Form.Service)
|
||||
const instances = Context.get(context, Instance.Service)
|
||||
const locations = Context.get(context, LocationServiceMap.Service)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter)
|
||||
@@ -232,12 +233,12 @@ it.live(
|
||||
})),
|
||||
)
|
||||
|
||||
// Seed through Core, then use HTTP to reach those exact private instances.
|
||||
// Seed through Core, then use HTTP to reach those exact private instances. Forms live in the
|
||||
// global ledger keyed by Session; permissions stay in the selected instance.
|
||||
const pending = yield* Effect.forEach(configs, (config) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* sessions.get(config.id)
|
||||
return yield* Effect.gen(function* () {
|
||||
const forms = yield* Form.Service
|
||||
const permissions = yield* Permission.Service
|
||||
const form = yield* forms.create({
|
||||
sessionID: config.id,
|
||||
@@ -273,14 +274,20 @@ it.live(
|
||||
}),
|
||||
)
|
||||
for (const entry of pending) {
|
||||
const forms = yield* request(`/api/session/${entry.session.id}/form`)
|
||||
expect(forms.status).toBe(200)
|
||||
expect(yield* Effect.promise<unknown>(() => forms.json())).toEqual({ data: [entry.form] })
|
||||
const owned = pending
|
||||
.flatMap((other) => [other.form, other.foreignForm])
|
||||
.filter((form) => form.sessionID === entry.session.id)
|
||||
const response = yield* request(`/api/session/${entry.session.id}/form`)
|
||||
expect(response.status).toBe(200)
|
||||
const listed = yield* Effect.promise(() => response.json() as Promise<{ data: Form.Info[] }>)
|
||||
expect(listed.data.toSorted((x, y) => x.id.localeCompare(y.id))).toEqual(
|
||||
owned.toSorted((x, y) => x.id.localeCompare(y.id)),
|
||||
)
|
||||
const permissions = yield* request(`/api/session/${entry.session.id}/permission`)
|
||||
expect(permissions.status).toBe(200)
|
||||
expect(yield* Effect.promise<unknown>(() => permissions.json())).toEqual({ data: [entry.permission] })
|
||||
|
||||
// These IDs exist in the selected instance, but belong to the other Session.
|
||||
// These IDs exist, but belong to the other Session.
|
||||
expect((yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}`)).status).toBe(404)
|
||||
expect(
|
||||
(yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}`)).status,
|
||||
@@ -295,10 +302,9 @@ it.live(
|
||||
reply: "once",
|
||||
})).status,
|
||||
).toBe(404)
|
||||
expect(yield* forms.state(entry.foreignForm.id)).toEqual({ status: "pending" })
|
||||
yield* Effect.gen(function* () {
|
||||
const forms = yield* Form.Service
|
||||
const permissions = yield* Permission.Service
|
||||
expect(yield* forms.state(entry.foreignForm.id)).toEqual({ status: "pending" })
|
||||
expect(yield* permissions.get(entry.foreignPermission.id)).toMatchObject({
|
||||
sessionID: entry.foreignForm.sessionID,
|
||||
})
|
||||
@@ -315,13 +321,12 @@ it.live(
|
||||
reply: "once",
|
||||
})).status,
|
||||
).toBe(204)
|
||||
expect(yield* forms.state(entry.form.id)).toEqual({
|
||||
status: "answered",
|
||||
answer: { answer: entry.session.id },
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const forms = yield* Form.Service
|
||||
const permissions = yield* Permission.Service
|
||||
expect(yield* forms.state(entry.form.id)).toEqual({
|
||||
status: "answered",
|
||||
answer: { answer: entry.session.id },
|
||||
})
|
||||
expect(yield* permissions.get(entry.permission.id)).toBeUndefined()
|
||||
}).pipe(instances.provide(entry.session))
|
||||
}
|
||||
|
||||
@@ -7767,21 +7767,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7854,21 +7844,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7934,21 +7914,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8027,21 +7997,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7767,21 +7767,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7854,21 +7844,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7934,21 +7914,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8027,21 +7997,11 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | FormNotFoundError",
|
||||
"description": "FormNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/FormNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user