feat(core): add form service (#35094)
This commit is contained in:
parent
af5eabcb26
commit
3ce5e9800d
9 changed files with 631 additions and 5 deletions
|
|
@ -27,6 +27,7 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -4019,6 +4019,121 @@ export type EventSubscribeOutput =
|
|||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly requestID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "form.created"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly form:
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "form"
|
||||
readonly fields: ReadonlyArray<
|
||||
| {
|
||||
readonly key: string
|
||||
readonly title?: string
|
||||
readonly description?: string
|
||||
readonly required?: boolean
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
readonly placeholder?: string
|
||||
readonly default?: string
|
||||
readonly options?: ReadonlyArray<{
|
||||
readonly value: string
|
||||
readonly label: string
|
||||
readonly description?: string
|
||||
}>
|
||||
readonly custom?: boolean
|
||||
}
|
||||
| {
|
||||
readonly key: string
|
||||
readonly title?: string
|
||||
readonly description?: string
|
||||
readonly required?: boolean
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
readonly type: "number"
|
||||
readonly minimum?: number
|
||||
readonly maximum?: number
|
||||
readonly default?: number
|
||||
}
|
||||
| {
|
||||
readonly key: string
|
||||
readonly title?: string
|
||||
readonly description?: string
|
||||
readonly required?: boolean
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
readonly type: "integer"
|
||||
readonly minimum?: number
|
||||
readonly maximum?: number
|
||||
readonly default?: number
|
||||
}
|
||||
| {
|
||||
readonly key: string
|
||||
readonly title?: string
|
||||
readonly description?: string
|
||||
readonly required?: boolean
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
readonly type: "boolean"
|
||||
readonly default?: boolean
|
||||
}
|
||||
| {
|
||||
readonly key: string
|
||||
readonly title?: string
|
||||
readonly description?: string
|
||||
readonly required?: boolean
|
||||
readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
|
||||
readonly type: "multiselect"
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: string
|
||||
readonly label: string
|
||||
readonly description?: string
|
||||
}>
|
||||
readonly minItems?: number
|
||||
readonly maxItems?: number
|
||||
readonly custom?: boolean
|
||||
readonly default?: ReadonlyArray<string>
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "form.replied"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly answer: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "form.cancelled"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly id: string; readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
|
|
|
|||
302
packages/core/src/form.ts
Normal file
302
packages/core/src/form.ts
Normal file
|
|
@ -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())
|
||||
}
|
||||
57
packages/core/test/form.test.ts
Normal file
57
packages/core/test/form.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const forms = AppNodeBuilder.build(LayerNode.group([EventV2.node, Form.node]))
|
||||
const it = testEffect(forms)
|
||||
|
||||
const formID = Form.ID.create("frm_test")
|
||||
const input = {
|
||||
id: formID,
|
||||
sessionID: SessionSchema.ID.make("ses_test"),
|
||||
mode: "form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
describe("Form", () => {
|
||||
it.effect("cleans up created forms when event publication fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const events = yield* EventV2.Service
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === Form.Event.Created.type ? Effect.die("create listener failed") : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
expect(Exit.isFailure(yield* Effect.exit(service.create(input)))).toBe(true)
|
||||
expect(yield* service.get(formID).pipe(Effect.flip)).toEqual(new Form.NotFoundError({ id: formID }))
|
||||
|
||||
yield* unsubscribe
|
||||
expect(yield* service.create(input)).toMatchObject({ id: formID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps forms pending when reply event publication fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* service.create(input)
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === Form.Event.Replied.type ? Effect.die("reply listener failed") : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
expect(Exit.isFailure(yield* Effect.exit(service.reply({ id: formID, answer: { name: "Ava" } })))).toBe(true)
|
||||
expect(yield* service.state(formID)).toEqual({ status: "pending" })
|
||||
|
||||
yield* unsubscribe
|
||||
yield* service.reply({ id: formID, answer: { name: "Ava" } })
|
||||
expect(yield* service.state(formID)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -10,13 +10,14 @@ describe("public event manifest", () => {
|
|||
expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
|
||||
expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
|
||||
expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101)
|
||||
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
|
||||
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||
expect(EventManifest.Latest.has("server.connected")).toBe(true)
|
||||
expect(EventManifest.Latest.has("global.disposed")).toBe(true)
|
||||
expect(EventManifest.Latest.has("form.created")).toBe(true)
|
||||
})
|
||||
|
||||
test("contains only the current step settlement versions", () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Durable } from "./durable-event-manifest.js"
|
|||
import { Event } from "./event.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemWatcher } from "./filesystem-watcher.js"
|
||||
import { Form } from "./form.js"
|
||||
import { InstallationEvent } from "./installation-event.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { LegacyEvent } from "./legacy-event.js"
|
||||
|
|
@ -60,6 +61,7 @@ const featureDefinitions = Event.inventory(
|
|||
...Pty.Event.Definitions,
|
||||
...Shell.Event.Definitions,
|
||||
...Question.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
)
|
||||
|
||||
export const ServerDefinitions = Event.inventory(
|
||||
|
|
|
|||
146
packages/schema/src/form.ts
Normal file
146
packages/schema/src/form.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
export * as Form from "./form.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { define, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
||||
|
||||
export const ID = IDSchema.pipe(
|
||||
statics((schema: typeof IDSchema) => ({ create: (id?: string) => schema.make(id ?? "frm_" + ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Metadata = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Form.Metadata" })
|
||||
export type Metadata = typeof Metadata.Type
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
value: Schema.String,
|
||||
label: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Form.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
|
||||
export const When = Schema.Struct({
|
||||
key: Schema.String,
|
||||
op: Schema.Literals(["eq", "neq"]),
|
||||
value: Schema.String,
|
||||
}).annotate({ identifier: "Form.When" })
|
||||
export interface When extends Schema.Schema.Type<typeof When> {}
|
||||
|
||||
const FieldBase = {
|
||||
key: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
required: Schema.Boolean.pipe(optional),
|
||||
when: When.pipe(optional),
|
||||
}
|
||||
|
||||
export const StringField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("string"),
|
||||
format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
|
||||
minLength: NonNegativeInt.pipe(optional),
|
||||
maxLength: NonNegativeInt.pipe(optional),
|
||||
pattern: Schema.String.pipe(optional),
|
||||
placeholder: Schema.String.pipe(optional),
|
||||
default: Schema.String.pipe(optional),
|
||||
options: Schema.Array(Option).pipe(optional),
|
||||
custom: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Form.StringField" })
|
||||
export interface StringField extends Schema.Schema.Type<typeof StringField> {}
|
||||
|
||||
export const NumberField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("number"),
|
||||
minimum: Schema.Number.pipe(optional),
|
||||
maximum: Schema.Number.pipe(optional),
|
||||
default: Schema.Number.pipe(optional),
|
||||
}).annotate({ identifier: "Form.NumberField" })
|
||||
export interface NumberField extends Schema.Schema.Type<typeof NumberField> {}
|
||||
|
||||
export const IntegerField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("integer"),
|
||||
minimum: Schema.Number.pipe(optional),
|
||||
maximum: Schema.Number.pipe(optional),
|
||||
default: Schema.Number.pipe(optional),
|
||||
}).annotate({ identifier: "Form.IntegerField" })
|
||||
export interface IntegerField extends Schema.Schema.Type<typeof IntegerField> {}
|
||||
|
||||
export const BooleanField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("boolean"),
|
||||
default: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Form.BooleanField" })
|
||||
export interface BooleanField extends Schema.Schema.Type<typeof BooleanField> {}
|
||||
|
||||
export const MultiselectField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("multiselect"),
|
||||
options: Schema.Array(Option),
|
||||
minItems: NonNegativeInt.pipe(optional),
|
||||
maxItems: NonNegativeInt.pipe(optional),
|
||||
custom: Schema.Boolean.pipe(optional),
|
||||
default: Schema.Array(Schema.String).pipe(optional),
|
||||
}).annotate({ identifier: "Form.MultiselectField" })
|
||||
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
|
||||
|
||||
export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
|
||||
|
||||
const InfoBase = {
|
||||
id: ID,
|
||||
sessionID: SessionID,
|
||||
title: Schema.String.pipe(optional),
|
||||
metadata: Metadata.pipe(optional),
|
||||
}
|
||||
|
||||
export const FormInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("form"),
|
||||
fields: Schema.Array(Field),
|
||||
}).annotate({ identifier: "Form.FormInfo" })
|
||||
export interface FormInfo extends Schema.Schema.Type<typeof FormInfo> {}
|
||||
|
||||
export const UrlInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
}).annotate({ identifier: "Form.UrlInfo" })
|
||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo
|
||||
|
||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate({
|
||||
identifier: "Form.Value",
|
||||
})
|
||||
export type Value = typeof Value.Type
|
||||
|
||||
export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const State = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("pending") }),
|
||||
Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }),
|
||||
Schema.Struct({ status: Schema.Literal("cancelled") }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("status"))
|
||||
.annotate({ identifier: "Form.State" })
|
||||
export type State = typeof State.Type
|
||||
|
||||
export const Reply = Schema.Struct({
|
||||
answer: Answer,
|
||||
}).annotate({ identifier: "Form.Reply" })
|
||||
export interface Reply extends Schema.Schema.Type<typeof Reply> {}
|
||||
|
||||
const Created = define({ type: "form.created", schema: { form: Info } })
|
||||
const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: SessionID, answer: Answer } })
|
||||
const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: SessionID } })
|
||||
|
||||
export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) }
|
||||
|
|
@ -4,6 +4,7 @@ export { Connection } from "./connection.js"
|
|||
export { Credential } from "./credential.js"
|
||||
export { Event } from "./event.js"
|
||||
export { FileSystem } from "./filesystem.js"
|
||||
export { Form } from "./form.js"
|
||||
export { Integration } from "./integration.js"
|
||||
export { LLM } from "./llm.js"
|
||||
export { Location } from "./location.js"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent, FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
|
||||
import { Agent, FileSystem, Form, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
|
||||
import { EventManifest } from "../src/event-manifest.js"
|
||||
import { IdeEvent } from "../src/ide-event.js"
|
||||
import { SessionEvent } from "../src/session-event.js"
|
||||
|
|
@ -9,11 +9,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js"
|
|||
|
||||
describe("public event manifest", () => {
|
||||
test("owns the complete public event surface", () => {
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63)
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(86)
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||
Agent.Event.Updated,
|
||||
])
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93)
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(101)
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||
Agent.Event.Updated,
|
||||
])
|
||||
|
|
@ -29,7 +29,7 @@ describe("public event manifest", () => {
|
|||
SessionV1.Event.Diff,
|
||||
SessionV1.Event.Error,
|
||||
])
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101)
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||
|
|
@ -49,6 +49,7 @@ describe("public event manifest", () => {
|
|||
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited])
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
|
||||
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
||||
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue