|
|
@@ -0,0 +1,302 @@
|
|
|
+export * as Form from "./form"
|
|
|
+
|
|
|
+import { Form } from "@opencode-ai/schema/form"
|
|
|
+import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
|
|
|
+import { makeLocationNode } from "./effect/app-node"
|
|
|
+import { EventV2 } from "./event"
|
|
|
+
|
|
|
+const RETENTION = Duration.minutes(10)
|
|
|
+
|
|
|
+export const ID = Form.ID
|
|
|
+export type ID = typeof ID.Type
|
|
|
+
|
|
|
+export const Info = Form.Info
|
|
|
+export type Info = typeof Info.Type
|
|
|
+
|
|
|
+export const Field = Form.Field
|
|
|
+export type Field = Form.Field
|
|
|
+
|
|
|
+export const State = Form.State
|
|
|
+export type State = typeof State.Type
|
|
|
+
|
|
|
+export const Answer = Form.Answer
|
|
|
+export type Answer = typeof Answer.Type
|
|
|
+
|
|
|
+export const Reply = Form.Reply
|
|
|
+export type Reply = typeof Reply.Type
|
|
|
+
|
|
|
+export const Event = Form.Event
|
|
|
+
|
|
|
+export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Form.NotFoundError", {
|
|
|
+ id: ID,
|
|
|
+}) {
|
|
|
+ override get message() {
|
|
|
+ return `Form not found: ${this.id}`
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class AlreadySettledError extends Schema.TaggedErrorClass<AlreadySettledError>()("Form.AlreadySettledError", {
|
|
|
+ id: ID,
|
|
|
+}) {
|
|
|
+ override get message() {
|
|
|
+ return `Form already settled: ${this.id}`
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class AlreadyExistsError extends Schema.TaggedErrorClass<AlreadyExistsError>()("Form.AlreadyExistsError", {
|
|
|
+ id: ID,
|
|
|
+}) {
|
|
|
+ override get message() {
|
|
|
+ return `Form already exists: ${this.id}`
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class InvalidAnswerError extends Schema.TaggedErrorClass<InvalidAnswerError>()("Form.InvalidAnswerError", {
|
|
|
+ id: ID,
|
|
|
+ message: Schema.String,
|
|
|
+}) {}
|
|
|
+
|
|
|
+export type CreateInput =
|
|
|
+ | (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
|
|
+ | (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
|
|
+
|
|
|
+export interface ReplyInput {
|
|
|
+ readonly id: ID
|
|
|
+ readonly answer: Answer
|
|
|
+}
|
|
|
+
|
|
|
+export interface ListInput {
|
|
|
+ readonly sessionID?: Form.FormInfo["sessionID"]
|
|
|
+}
|
|
|
+
|
|
|
+export interface Interface {
|
|
|
+ readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError>
|
|
|
+ readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError>
|
|
|
+ readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
|
|
+ readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
|
|
|
+ readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
|
|
|
+ readonly reply: (input: ReplyInput) => Effect.Effect<void, AlreadySettledError | InvalidAnswerError | NotFoundError>
|
|
|
+ readonly cancel: (id: ID) => Effect.Effect<void, AlreadySettledError | NotFoundError>
|
|
|
+}
|
|
|
+
|
|
|
+export class Service extends Context.Service<Service, Interface>()("@opencode/Form") {}
|
|
|
+
|
|
|
+interface Entry {
|
|
|
+ readonly form: Info
|
|
|
+ readonly state: State
|
|
|
+ readonly deferred: Deferred.Deferred<State>
|
|
|
+}
|
|
|
+
|
|
|
+export const layer = Layer.effect(
|
|
|
+ Service,
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const events = yield* EventV2.Service
|
|
|
+ const forms = yield* Cache.makeWith<ID, Entry>(() => Effect.die("Form cache must be used via set/getSuccess, never get"), {
|
|
|
+ capacity: Number.MAX_SAFE_INTEGER,
|
|
|
+ timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION),
|
|
|
+ })
|
|
|
+
|
|
|
+ const find = Effect.fn("Form.find")(function* (id: ID) {
|
|
|
+ return yield* Cache.getSuccess(forms, id).pipe(
|
|
|
+ Effect.flatMap((entry) =>
|
|
|
+ Option.match(entry, {
|
|
|
+ onNone: () => Effect.fail(new NotFoundError({ id })),
|
|
|
+ onSome: Effect.succeed,
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ const create = Effect.fn("Form.create")((input: CreateInput) =>
|
|
|
+ Effect.uninterruptible(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const id = input.id ?? ID.create()
|
|
|
+ const existing = yield* Cache.getSuccess(forms, id)
|
|
|
+ if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
|
|
+ const base = {
|
|
|
+ id,
|
|
|
+ sessionID: input.sessionID,
|
|
|
+ title: input.title,
|
|
|
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
|
|
+ }
|
|
|
+ const form: Info =
|
|
|
+ input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url }
|
|
|
+ const entry: Entry = {
|
|
|
+ form,
|
|
|
+ state: { status: "pending" },
|
|
|
+ deferred: yield* Deferred.make<State>(),
|
|
|
+ }
|
|
|
+ yield* Cache.set(forms, id, entry)
|
|
|
+ yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
|
|
|
+ return form
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ const ask = Effect.fn("Form.ask")((input: CreateInput) =>
|
|
|
+ Effect.uninterruptibleMask((restore) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const form = yield* create(input)
|
|
|
+ const entry = yield* find(form.id).pipe(Effect.orDie)
|
|
|
+ return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id))))
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ const get = Effect.fn("Form.get")(function* (id: ID) {
|
|
|
+ return (yield* find(id)).form
|
|
|
+ })
|
|
|
+
|
|
|
+ const list = Effect.fn("Form.list")(function* (input?: ListInput) {
|
|
|
+ const entries = yield* Cache.values(forms)
|
|
|
+ return Array.from(entries)
|
|
|
+ .filter((entry) => entry.state.status === "pending")
|
|
|
+ .filter((entry) => input?.sessionID === undefined || entry.form.sessionID === input.sessionID)
|
|
|
+ .map((entry) => entry.form)
|
|
|
+ })
|
|
|
+
|
|
|
+ const state = Effect.fn("Form.state")(function* (id: ID) {
|
|
|
+ return (yield* find(id)).state
|
|
|
+ })
|
|
|
+
|
|
|
+ const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
|
|
|
+ Effect.uninterruptible(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const entry = yield* find(input.id)
|
|
|
+ if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
|
|
|
+ const invalid = validateAnswer(entry.form, input.answer)
|
|
|
+ if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
|
|
|
+ const next: State = { status: "answered", answer: input.answer }
|
|
|
+ yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
|
|
|
+ yield* Cache.set(forms, input.id, { ...entry, state: next })
|
|
|
+ yield* Deferred.succeed(entry.deferred, next)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ const cancel = Effect.fn("Form.cancel")((id: ID) =>
|
|
|
+ Effect.uninterruptible(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const entry = yield* find(id)
|
|
|
+ if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
|
|
|
+ const next: State = { status: "cancelled" }
|
|
|
+ yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
|
|
|
+ yield* Cache.set(forms, id, { ...entry, state: next })
|
|
|
+ yield* Deferred.succeed(entry.deferred, next)
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ yield* Effect.addFinalizer(() =>
|
|
|
+ Cache.values(forms).pipe(
|
|
|
+ Effect.flatMap((entries) =>
|
|
|
+ Effect.forEach(
|
|
|
+ Array.from(entries).filter((entry) => entry.state.status === "pending"),
|
|
|
+ (entry) => cancel(entry.form.id).pipe(Effect.ignore),
|
|
|
+ { discard: true },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ return Service.of({ create, ask, get, list, state, reply, cancel })
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+export const locationLayer = layer
|
|
|
+
|
|
|
+export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
|
|
+
|
|
|
+function validateAnswer(form: Info, answer: Answer) {
|
|
|
+ if (form.mode === "url") {
|
|
|
+ if (Object.keys(answer).length === 0) return
|
|
|
+ return "URL forms must be answered with an empty answer"
|
|
|
+ }
|
|
|
+ const fields = new Map(form.fields.map((field) => [field.key, field]))
|
|
|
+ for (const key of Object.keys(answer)) {
|
|
|
+ if (!fields.has(key)) return `Unknown form field: ${key}`
|
|
|
+ }
|
|
|
+ for (const field of form.fields) {
|
|
|
+ const value = answer[field.key]
|
|
|
+ if (value === undefined) {
|
|
|
+ if (field.required && isActive(field, answer)) return `Missing required form field: ${field.key}`
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const invalid = validateField(field, value)
|
|
|
+ if (invalid) return invalid
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isActive(field: Form.Field, answer: Answer) {
|
|
|
+ if (!field.when) return true
|
|
|
+ const value = answer[field.when.key]
|
|
|
+ if (field.when.op === "eq") return value === field.when.value
|
|
|
+ return value !== field.when.value
|
|
|
+}
|
|
|
+
|
|
|
+function validateField(field: Form.Field, value: Form.Value): string | undefined {
|
|
|
+ if (field.type === "string") {
|
|
|
+ if (typeof value !== "string") return `Expected string for form field: ${field.key}`
|
|
|
+ if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
|
|
+ if (field.minLength !== undefined && value.length < field.minLength) return `Form field is too short: ${field.key}`
|
|
|
+ if (field.maxLength !== undefined && value.length > field.maxLength) return `Form field is too long: ${field.key}`
|
|
|
+ if (field.pattern !== undefined) {
|
|
|
+ try {
|
|
|
+ if (!new RegExp(field.pattern).test(value)) return `Form field does not match pattern: ${field.key}`
|
|
|
+ } catch {
|
|
|
+ return `Form field has invalid pattern: ${field.key}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}`
|
|
|
+ if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
|
|
|
+ if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}`
|
|
|
+ if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}`
|
|
|
+ if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
|
|
|
+ return `Invalid option for form field: ${field.key}`
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (field.type === "number" || field.type === "integer") {
|
|
|
+ if (typeof value !== "number" || !Number.isFinite(value)) return `Expected number for form field: ${field.key}`
|
|
|
+ if (field.type === "integer" && !Number.isInteger(value)) return `Expected integer for form field: ${field.key}`
|
|
|
+ if (field.minimum !== undefined && value < field.minimum) return `Form field is too small: ${field.key}`
|
|
|
+ if (field.maximum !== undefined && value > field.maximum) return `Form field is too large: ${field.key}`
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (field.type === "boolean") {
|
|
|
+ if (typeof value !== "boolean") return `Expected boolean for form field: ${field.key}`
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (field.type === "multiselect") {
|
|
|
+ if (!isStringArray(value)) return `Expected string array for form field: ${field.key}`
|
|
|
+ if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
|
|
+ if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}`
|
|
|
+ if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}`
|
|
|
+ if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) {
|
|
|
+ return `Invalid option for form field: ${field.key}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isStringArray(value: Form.Value): value is ReadonlyArray<string> {
|
|
|
+ return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
|
|
|
+}
|
|
|
+
|
|
|
+function isUri(value: string) {
|
|
|
+ try {
|
|
|
+ new URL(value)
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isDate(value: string) {
|
|
|
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
|
+ const date = new Date(`${value}T00:00:00.000Z`)
|
|
|
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
|
|
+}
|
|
|
+
|
|
|
+function isDateTime(value: string) {
|
|
|
+ return !Number.isNaN(new Date(value).getTime())
|
|
|
+}
|