Compare commits

...
1 Commits
Author SHA1 Message Date
Kit Langton 8f903f899a refactor(core): make form ledger global
Pending forms are transient runtime state keyed by Session, so the Form
service now lives in the global node graph instead of each Location
instance. Reading or settling a Session's forms no longer boots that
Session's Location; the TUI's per-descendant form sync had been
reactivating every idle Location on startup.

Events keep routing per Location: the ledger resolves the owning
Session's Location from SessionStore once at creation (or the ambient
Location for the MCP global elicitation owner) and publishes with it
explicitly. form.request.list keeps its per-Location shape by filtering
the global ledger on that route.
2026-09-01 21:18:49 -04:00
12 changed files with 235 additions and 210 deletions
@@ -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,
+40 -9
View File
@@ -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))
-2
View File
@@ -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,
+115 -2
View File
@@ -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>
+3
View File
@@ -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,
),
),
+8 -48
View File
@@ -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"
}
}
}
+9 -12
View File
@@ -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",
+20 -24
View File
@@ -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 }),
+2
View File
@@ -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,
+18 -13
View File
@@ -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))
}
+8 -48
View File
@@ -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"
}
}
}
+8 -48
View File
@@ -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"
}
}
}