From 3ce5e9800d0dc7ca6fd946a173a141e0d2a1f7c2 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:41:37 -0500 Subject: [PATCH] feat(core): add form service (#35094) --- packages/client/package.json | 1 + .../client/src/promise/generated/types.ts | 115 +++++++ packages/core/src/form.ts | 302 ++++++++++++++++++ packages/core/test/form.test.ts | 57 ++++ packages/opencode/test/event-manifest.test.ts | 3 +- packages/schema/src/event-manifest.ts | 2 + packages/schema/src/form.ts | 146 +++++++++ packages/schema/src/index.ts | 1 + packages/schema/test/event-manifest.test.ts | 9 +- 9 files changed, 631 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/form.ts create mode 100644 packages/core/test/form.test.ts create mode 100644 packages/schema/src/form.ts diff --git a/packages/client/package.json b/packages/client/package.json index 0027e19739..1314239d41 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,6 +27,7 @@ } }, "devDependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/httpapi-codegen": "workspace:*", "@opencode-ai/server": "workspace:*", diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 34fc01fe4c..76b0304efa 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 + } + > + } + | { + 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 } + } + } + | { + 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 } diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts new file mode 100644 index 0000000000..ef28dd2bd2 --- /dev/null +++ b/packages/core/src/form.ts @@ -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()("Form.NotFoundError", { + id: ID, +}) { + override get message() { + return `Form not found: ${this.id}` + } +} + +export class AlreadySettledError extends Schema.TaggedErrorClass()("Form.AlreadySettledError", { + id: ID, +}) { + override get message() { + return `Form already settled: ${this.id}` + } +} + +export class AlreadyExistsError extends Schema.TaggedErrorClass()("Form.AlreadyExistsError", { + id: ID, +}) { + override get message() { + return `Form already exists: ${this.id}` + } +} + +export class InvalidAnswerError extends Schema.TaggedErrorClass()("Form.InvalidAnswerError", { + id: ID, + message: Schema.String, +}) {} + +export type CreateInput = + | (Omit & { readonly id?: ID }) + | (Omit & { 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 + readonly ask: (input: CreateInput) => Effect.Effect + readonly get: (id: ID) => Effect.Effect + readonly list: (input?: ListInput) => Effect.Effect> + readonly state: (id: ID) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly cancel: (id: ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Form") {} + +interface Entry { + readonly form: Info + readonly state: State + readonly deferred: Deferred.Deferred +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const forms = yield* Cache.makeWith(() => 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(), + } + 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 { + 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()) +} diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts new file mode 100644 index 0000000000..d23721d10a --- /dev/null +++ b/packages/core/test/form.test.ts @@ -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" } }) + }), + ) +}) diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index a38d96cb83..64755ad44f 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -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", () => { diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 182cda47ba..19fca5e79d 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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( diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts new file mode 100644 index 0000000000..bad113e09b --- /dev/null +++ b/packages/schema/src/form.ts @@ -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 {} + +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 {} + +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 {} + +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 {} + +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 {} + +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 {} + +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 {} + +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 {} + +export const UrlInfo = Schema.Struct({ + ...InfoBase, + mode: Schema.Literal("url"), + url: Schema.String, +}).annotate({ identifier: "Form.UrlInfo" }) +export interface UrlInfo extends Schema.Schema.Type {} + +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 {} + +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) } diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 5003187462..9714fdc762 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -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" diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5243fb69e8..452b0ac149 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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])