diff --git a/bun.lock b/bun.lock index 411c09c8ffc4..0e40a5546c38 100644 --- a/bun.lock +++ b/bun.lock @@ -539,6 +539,7 @@ "@openauthjs/openauth": "catalog:", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 431aa004b311..5f90b09f308c 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,6 +1,7 @@ export * as Catalog from "./catalog" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" +import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { EventV2 } from "./event" @@ -17,9 +18,7 @@ export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } export const PolicyActions = Schema.Literals(["provider.use"]) -export const Event = { - Updated: EventV2.define({ type: "catalog.updated", schema: {} }), -} +export const Event = Catalog.Event type Data = { providers: Map diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 93d5afce53c8..132a88b11125 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,46 +1,19 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Event } from "@opencode-ai/schema/event" +import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import { and, asc, eq, gt } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" import { LayerNode } from "./effect/layer-node" import { isDeepStrictEqual } from "node:util" +import { Durable } from "@opencode-ai/schema/durable-event-manifest" -export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( - Schema.brand("Event.ID"), - withStatics((schema) => ({ - create: () => schema.make("evt_" + Identifier.ascending()), - })), -) -export type ID = typeof ID.Type - -export type Definition = { - readonly type: Type - readonly durable?: { - readonly version: number - readonly aggregate: string - } - readonly data: DataSchema -} - -export type Data = Schema.Schema.Type - -export type Payload = { - readonly id: ID - readonly type: D["type"] - readonly data: Data - readonly durable?: { - readonly aggregateID: string - readonly seq: number - readonly version: number - } - readonly location?: Location.Ref - readonly metadata?: Record -} +export const ID = Event.ID +export type ID = import("@opencode-ai/schema/event").ID +export type { Data, Definition, Payload } from "@opencode-ai/schema/event" export type Subscriber = (event: Payload) => Effect.Effect export type Unsubscribe = Effect.Effect @@ -74,52 +47,8 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass() -const durableRegistry = new Map() - -export function define(input: { - readonly type: Type - readonly durable?: { - readonly version: number - readonly aggregate: string - } - readonly schema: Fields -}): Schema.Schema>>> & Definition> { - const Data = Schema.Struct(input.schema) - const Payload = Schema.Struct({ - id: ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - type: Schema.Literal(input.type), - durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), - location: Schema.optional(Location.Ref), - data: Data, - }).annotate({ identifier: input.type }) - - const definition = Object.assign(Payload, { - type: input.type, - ...(input.durable === undefined ? {} : { durable: input.durable }), - data: Data, - }) - const existing = registry.get(input.type) - if ( - input.durable === undefined || - existing?.durable === undefined || - input.durable.version >= existing.durable.version - ) { - registry.set(input.type, definition) - } - if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition) - return definition as Schema.Schema>>> & - Definition> -} - -export function definitions() { - return registry.values().toArray() -} +export const define = Event.define +export const versionedType = Event.versionedType export interface PublishOptions { readonly id?: ID @@ -169,6 +98,7 @@ export const layerWith = (options?: LayerOptions) => typed: new Map>(), } const projectors = new Map() + // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads. const listeners = new Array() const { db } = yield* Database.Service @@ -194,6 +124,7 @@ export const layerWith = (options?: LayerOptions) => ) function commitDurableEvent( + definition: Definition, event: Payload, input?: { readonly seq: number @@ -204,7 +135,6 @@ export const layerWith = (options?: LayerOptions) => commit?: (seq: number) => Effect.Effect, ) { return Effect.gen(function* () { - const definition = registry.get(event.type) const durable = definition?.durable if (durable) { const aggregateID = (event.data as Record)[durable.aggregate] @@ -238,9 +168,10 @@ export const layerWith = (options?: LayerOptions) => .get() .pipe(Effect.orDie) const latest = row?.seq ?? -1 - const encoded = Schema.encodeUnknownSync( - definition.data as Schema.Codec, - )(event.data) as Record + const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record< + string, + unknown + > if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { yield* Effect.die( new InvalidDurableEventError({ @@ -356,9 +287,8 @@ export const layerWith = (options?: LayerOptions) => }) } - function publishEvent(event: Payload, commit?: PublishOptions["commit"]) { + function publishEvent(definition: D, event: Payload, commit?: PublishOptions["commit"]) { return Effect.gen(function* () { - const definition = registry.get(event.type) if (!definition?.durable && commit) return yield* Effect.die( new InvalidDurableEventError({ @@ -367,7 +297,7 @@ export const layerWith = (options?: LayerOptions) => }), ) if (definition?.durable) { - const committed = yield* commitDurableEvent(event as Payload, undefined, commit) + const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit) if (committed) { event = { ...event, @@ -416,6 +346,7 @@ export const layerWith = (options?: LayerOptions) => ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } : undefined) return yield* publishEvent( + definition, { id: options?.id ?? ID.create(), ...(options?.metadata ? { metadata: options.metadata } : {}), @@ -433,7 +364,7 @@ export const layerWith = (options?: LayerOptions) => options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, ) { return Effect.gen(function* () { - const definition = durableRegistry.get(event.type) + const definition = Durable.get(event.type) if (!definition?.durable) { yield* Effect.die( new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), @@ -442,11 +373,9 @@ export const layerWith = (options?: LayerOptions) => const payload = { id: event.id, type: definition.type, - data: Schema.decodeUnknownSync(definition.data as Schema.Codec)( - event.data, - ), + data: Schema.decodeUnknownSync(definition.data)(event.data), } as Payload - const committed = yield* commitDurableEvent(payload, { + const committed = yield* commitDurableEvent(definition, payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, @@ -530,8 +459,8 @@ export const layerWith = (options?: LayerOptions) => const streamAll = (): Stream.Stream => Stream.fromPubSub(pubsub.all) - const decodeSerializedEvent = (event: SerializedEvent): Payload => { - const definition = durableRegistry.get(event.type) + const decodeSerializedEvent = (event: SerializedEvent) => { + const definition = Durable.get(event.type) if (!definition?.durable) { throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) } @@ -539,7 +468,7 @@ export const layerWith = (options?: LayerOptions) => id: event.id, type: definition.type, durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, - data: Schema.decodeUnknownSync(definition.data as Schema.Codec)(event.data), + data: Schema.decodeUnknownSync(definition.data)(event.data), } } diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index d7536ab62e98..e55ebbb66d37 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -2,12 +2,11 @@ export * as FileSystem from "./filesystem" import path from "path" import { Context, Effect, Layer, Schema } from "effect" -import { EventV2 } from "./event" import { FSUtil } from "./fs-util" import { Location } from "./location" import { PositiveInt, RelativePath } from "./schema" import { FileSystemSearch } from "./filesystem/search" -import { Entry, Match } from "@opencode-ai/schema/filesystem" +import { Entry, FileSystem, Match } from "@opencode-ai/schema/filesystem" export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem" export const ReadInput = Schema.Struct({ @@ -48,14 +47,7 @@ export class GrepInput extends Schema.Class("FileSystem.GrepInput")({ limit: PositiveInt.pipe(Schema.optional), }) {} -export const Event = { - Edited: EventV2.define({ - type: "file.edited", - schema: { - file: Schema.String, - }, - }), -} +export const Event = FileSystem.Event export interface Interface { readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 65d85e048233..c87edb39dc78 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,7 +3,8 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { Cause, Context, Effect, Layer, Schema } from "effect" +import { Cause, Context, Effect, Layer } from "effect" +import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import path from "path" import { Config } from "../config" import { EventV2 } from "../event" @@ -19,15 +20,7 @@ declare const OPENCODE_LIBC: string | undefined const SUBSCRIBE_TIMEOUT_MS = 10_000 -export const Event = { - Updated: EventV2.define({ - type: "file.watcher.updated", - schema: { - file: Schema.String, - event: Schema.Literals(["add", "change", "unlink"]), - }, - }), -} +export const Event = FileSystemWatcher.Event const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index bd56cc025d1e..19daef9ae19a 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -136,16 +136,7 @@ export class AuthorizationError extends Schema.TaggedErrorClass -export const Event = { - Refreshed: EventV2.define({ - type: "models-dev.refreshed", - schema: {}, - }), -} +export const Event = ModelsDev.Event declare const OPENCODE_MODELS_DEV: Record | undefined diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index f509cd1bee94..0ee64a07d7f0 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -7,45 +7,31 @@ import { Location } from "./location" import { AgentV2 } from "./agent" import { SessionV2 } from "./session" import { SessionStore } from "./session/store" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" import { Wildcard } from "./util/wildcard" import { PermissionSaved } from "./permission/saved" export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionV2.ID"), - withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) +export const ID = Permission.ID export type ID = typeof ID.Type -export const Source = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("tool"), - messageID: Schema.String, - callID: Schema.String, - }), -]).annotate({ identifier: "PermissionV2.Source" }) +export const Source = Permission.Source export type Source = typeof Source.Type const RequestFields = { - sessionID: SessionV2.ID, - action: Schema.String, - resources: Schema.Array(Schema.String), - save: Schema.Array(Schema.String).pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - source: Source.pipe(Schema.optional), + sessionID: Permission.Request.fields.sessionID, + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, } -export const Request = Schema.Struct({ - id: ID, - ...RequestFields, -}).annotate({ identifier: "PermissionV2.Request" }) +export const Request = Permission.Request export type Request = typeof Request.Type -export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export const Reply = Permission.Reply export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ @@ -68,17 +54,7 @@ export const AskResult = Schema.Struct({ }).annotate({ identifier: "PermissionV2.AskResult" }) export type AskResult = typeof AskResult.Type -export const Event = { - Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "permission.v2.replied", - schema: { - sessionID: SessionV2.ID, - requestID: ID, - reply: Reply, - }, - }), -} +export const Event = Permission.Event export class RejectedError extends Schema.TaggedErrorClass()("PermissionV2.RejectedError", {}) {} diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 4bb40934a21a..af3559189c0f 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,7 +1,8 @@ export * as PluginV2 from "./plugin" -import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect" +import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { PluginEvent, PluginID } from "@opencode-ai/schema/plugin" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" @@ -14,17 +15,10 @@ import { Reference } from "./reference" import { SkillV2 } from "./skill" import { State } from "./state" -export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export const ID = PluginID export type ID = typeof ID.Type -export const Event = { - Added: EventV2.define({ - type: "plugin.added", - schema: { - id: ID, - }, - }), -} +export const Event = PluginEvent export interface Interface { readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 441c380d7c7a..8030a1dfea9e 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -13,6 +13,7 @@ import { Slug } from "../util/slug" import { EventV2 } from "../event" import { Database } from "../database/database" import { Location } from "../location" +import { ProjectDirectoriesEvent } from "@opencode-ai/schema/project-directories" export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) export type StrategyID = typeof StrategyID.Type @@ -106,12 +107,7 @@ export interface Strategy { readonly list: (directory: AbsolutePath) => Effect.Effect } -export const Event = { - Updated: EventV2.define({ - type: "project.directories.updated", - schema: { projectID: Project.ID }, - }), -} +export const Event = ProjectDirectoriesEvent export interface Interface { readonly register: (strategy: Strategy) => Effect.Effect diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 49269e952116..16e09ab57977 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -2,10 +2,11 @@ export * as Pty from "./pty" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" +import { PtyEvent, PtyInfo } from "@opencode-ai/schema/pty" import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" -import { NonNegativeInt, PositiveInt } from "./schema" +import { PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { Shell } from "./shell" import { lazy } from "./util/lazy" @@ -35,18 +36,7 @@ type Active = { listeners: Disp[] } -export const Info = Schema.Struct({ - id: PtyID, - title: Schema.String, - command: Schema.String, - args: Schema.Array(Schema.String), - cwd: Schema.String, - status: Schema.Literals(["running", "exited"]), - // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time. - pid: NonNegativeInt, - // Present once status is "exited". - exitCode: Schema.optional(NonNegativeInt), -}).annotate({ identifier: "Pty" }) +export const Info = PtyInfo export type Info = Types.DeepMutable @@ -100,12 +90,7 @@ export class ExitedError extends Schema.TaggedErrorClass()("Pty.Exi ptyID: PtyID, }) {} -export const Event = { - Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), - Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), - Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }), - Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }), -} +export const Event = PtyEvent export interface Interface { readonly list: () => Effect.Effect diff --git a/packages/core/src/pty/schema.ts b/packages/core/src/pty/schema.ts index b8c973862f98..ab0c40521b1f 100644 --- a/packages/core/src/pty/schema.ts +++ b/packages/core/src/pty/schema.ts @@ -1,13 +1 @@ -import { Schema } from "effect" -import { Identifier } from "../id/id" -import { withStatics } from "../schema" - -const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) - -export type PtyID = typeof ptyIdSchema.Type - -export const PtyID = ptyIdSchema.pipe( - withStatics((schema: typeof ptyIdSchema) => ({ - ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - })), -) +export { ID as PtyID } from "@opencode-ai/schema/pty" diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts new file mode 100644 index 000000000000..99c208d9067b --- /dev/null +++ b/packages/core/src/public-event-manifest.ts @@ -0,0 +1,3 @@ +export * as PublicEventManifest from "./public-event-manifest" + +export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest" diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index a489fb9aac2f..2f4942af1b03 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,83 +1,35 @@ export * as QuestionV2 from "./question" import { Context, Deferred, Effect, Layer, Schema } from "effect" +import { Question } from "@opencode-ai/schema/question" import { EventV2 } from "./event" -import { Identifier } from "./id/id" -import { withStatics } from "./schema" import { SessionSchema } from "./session/schema" -export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( - Schema.brand("QuestionV2.ID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })), -) +export const ID = Question.ID export type ID = typeof ID.Type -export const Option = Schema.Struct({ - label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), - description: Schema.String.annotate({ description: "Explanation of choice" }), -}).annotate({ identifier: "QuestionV2.Option" }) +export const Option = Question.Option export type Option = typeof Option.Type -const base = { - question: Schema.String.annotate({ description: "Complete question" }), - header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), - options: Schema.Array(Option).annotate({ description: "Available choices" }), - multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.Boolean.pipe(Schema.optional).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionV2.Info" }) +export const Info = Question.Info export type Info = typeof Info.Type -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export const Prompt = Question.Prompt export type Prompt = typeof Prompt.Type -export const Tool = Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, -}).annotate({ identifier: "QuestionV2.Tool" }) +export const Tool = Question.Tool export type Tool = typeof Tool.Type -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), - tool: Tool.pipe(Schema.optional), -}).annotate({ identifier: "QuestionV2.Request" }) +export const Request = Question.Request export type Request = typeof Request.Type -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export const Answer = Question.Answer export type Answer = typeof Answer.Type -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionV2.Reply" }) +export const Reply = Question.Reply export type Reply = typeof Reply.Type -export const Event = { - Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "question.v2.replied", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - answers: Schema.Array(Answer), - }, - }), - Rejected: EventV2.define({ - type: "question.v2.rejected", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - }, - }), -} +export const Event = Question.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionV2.RejectedError", {}) { override get message() { diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 3edc84e98a5e..f03a69d49c2f 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -18,9 +18,7 @@ export type GitSource = Reference.GitSource export const Source = Reference.Source export type Source = Reference.Source -export const Event = { - Updated: EventV2.define({ type: "reference.updated", schema: {} }), -} +export const Event = Reference.Event export class Info extends Schema.Class("Reference.Info")({ name: Schema.String, diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 25cf147f80bd..78e40a699416 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -1,468 +1,2 @@ -import { Schema } from "effect" -import { ProviderMetadata, ToolContent } from "@opencode-ai/schema/llm" -import { Delivery } from "@opencode-ai/schema/session-delivery" -import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema" -import { FileAttachment, Prompt } from "./prompt" -import { SessionSchema } from "./schema" -import { Location } from "../location" -import { SessionMessageID } from "./message-id" -import { SessionMessage } from "./message" - -export { FileAttachment } - -export const Source = Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, -}).annotate({ - identifier: "session.next.event.source", -}) -export type Source = typeof Source.Type - -const Base = { - timestamp: DateTimeUtcFromMillis, - sessionID: SessionSchema.ID, -} -const PromptFields = { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Delivery, -} - -const options = { - durable: { - aggregate: "sessionID", - version: 1, - }, -} as const -const stepSettlementOptions = { - durable: { - aggregate: "sessionID", - version: 2, - }, -} as const - -export const UnknownError = SessionMessage.UnknownError -export type UnknownError = SessionMessage.UnknownError - -export const AgentSwitched = EventV2.define({ - type: "session.next.agent.switched", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - agent: Schema.String, - }, -}) -export type AgentSwitched = typeof AgentSwitched.Type - -export const ModelSwitched = EventV2.define({ - type: "session.next.model.switched", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - model: ModelV2.Ref, - }, -}) -export type ModelSwitched = typeof ModelSwitched.Type - -export const Moved = EventV2.define({ - type: "session.next.moved", - ...options, - schema: { - ...Base, - location: Location.Ref, - subdirectory: RelativePath.pipe(Schema.optional), - }, -}) -export type Moved = typeof Moved.Type - -export const Prompted = EventV2.define({ - type: "session.next.prompted", - ...options, - schema: PromptFields, -}) -export type Prompted = typeof Prompted.Type - -export const PromptAdmitted = EventV2.define({ - type: "session.next.prompt.admitted", - ...options, - schema: PromptFields, -}) -export type PromptAdmitted = typeof PromptAdmitted.Type - -export const ContextUpdated = EventV2.define({ - type: "session.next.context.updated", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, -}) -export type ContextUpdated = typeof ContextUpdated.Type - -export const Synthetic = EventV2.define({ - type: "session.next.synthetic", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, -}) -export type Synthetic = typeof Synthetic.Type - -export namespace Shell { - export const Started = EventV2.define({ - type: "session.next.shell.started", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - callID: Schema.String, - command: Schema.String, - }, - }) - export type Started = typeof Started.Type - - export const Ended = EventV2.define({ - type: "session.next.shell.ended", - ...options, - schema: { - ...Base, - callID: Schema.String, - output: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Step { - export const Started = EventV2.define({ - type: "session.next.step.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - agent: Schema.String, - model: ModelV2.Ref, - snapshot: Schema.String.pipe(Schema.optional), - }, - }) - export type Started = typeof Started.Type - - export const Ended = EventV2.define({ - type: "session.next.step.ended", - ...stepSettlementOptions, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - finish: Schema.String, - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - snapshot: Schema.String.pipe(Schema.optional), - }, - }) - export type Ended = typeof Ended.Type - - export const Failed = EventV2.define({ - type: "session.next.step.failed", - ...stepSettlementOptions, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - error: UnknownError, - }, - }) - export type Failed = typeof Failed.Type -} - -export namespace Text { - export const Started = EventV2.define({ - type: "session.next.text.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. - export const Delta = EventV2.define({ - type: "session.next.text.delta", - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.text.ended", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - text: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Reasoning { - export const Started = EventV2.define({ - type: "session.next.reasoning.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. - export const Delta = EventV2.define({ - type: "session.next.reasoning.delta", - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.reasoning.ended", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Tool { - const ToolBase = { - ...Base, - assistantMessageID: SessionMessageID.ID, - callID: Schema.String, - } - - export namespace Input { - export const Started = EventV2.define({ - type: "session.next.tool.input.started", - ...options, - schema: { - ...ToolBase, - name: Schema.String, - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. - export const Delta = EventV2.define({ - type: "session.next.tool.input.delta", - schema: { - ...ToolBase, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.tool.input.ended", - ...options, - schema: { - ...ToolBase, - text: Schema.String, - }, - }) - export type Ended = typeof Ended.Type - } - - export const Called = EventV2.define({ - type: "session.next.tool.called", - ...options, - schema: { - ...ToolBase, - tool: Schema.String, - input: Schema.Record(Schema.String, Schema.Unknown), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Called = typeof Called.Type - - /** - * Replayable bounded running-tool state. Tools should checkpoint semantic - * transitions or at a bounded cadence, not persist every stdout/stderr chunk. - */ - export const Progress = EventV2.define({ - type: "session.next.tool.progress", - ...options, - schema: { - ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), - content: Schema.Array(ToolContent), - }, - }) - export type Progress = typeof Progress.Type - - export const Success = EventV2.define({ - type: "session.next.tool.success", - ...options, - schema: { - ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), - content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), - result: Schema.Unknown.pipe(Schema.optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Success = typeof Success.Type - - export const Failed = EventV2.define({ - type: "session.next.tool.failed", - ...options, - schema: { - ...ToolBase, - error: UnknownError, - result: Schema.Unknown.pipe(Schema.optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Failed = typeof Failed.Type -} - -export const RetryError = Schema.Struct({ - message: Schema.String, - statusCode: Schema.Finite.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), -}).annotate({ - identifier: "session.next.retry_error", -}) -export type RetryError = typeof RetryError.Type - -export const Retried = EventV2.define({ - type: "session.next.retried", - ...options, - schema: { - ...Base, - attempt: Schema.Finite, - error: RetryError, - }, -}) -export type Retried = typeof Retried.Type - -export namespace Compaction { - export const Started = EventV2.define({ - type: "session.next.compaction.started", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), - }, - }) - export type Started = typeof Started.Type - - export const Delta = EventV2.define({ - type: "session.next.compaction.delta", - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.compaction.ended", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - reason: Started.data.fields.reason, - text: Schema.String, - recent: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -const DurableDefinitions = [ - AgentSwitched, - ModelSwitched, - Moved, - Prompted, - PromptAdmitted, - ContextUpdated, - Synthetic, - Shell.Started, - Shell.Ended, - Step.Started, - Step.Ended, - Step.Failed, - Text.Started, - Text.Ended, - Tool.Input.Started, - Tool.Input.Ended, - Tool.Called, - Tool.Progress, - Tool.Success, - Tool.Failed, - Reasoning.Started, - Reasoning.Ended, - Retried, - Compaction.Started, - Compaction.Ended, -] as const -const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const - -export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) -export type DurableEvent = typeof Durable.Type - -export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe( - Schema.toTaggedUnion("type"), -) -export type Event = typeof All.Type -export type Type = Event["type"] - -export * as SessionEvent from "./event" +export * from "@opencode-ai/schema/session-event" +export * as SessionEvent from "@opencode-ai/schema/session-event" diff --git a/packages/core/src/session/todo.ts b/packages/core/src/session/todo.ts index 7b3c3be3f69b..95420f78f728 100644 --- a/packages/core/src/session/todo.ts +++ b/packages/core/src/session/todo.ts @@ -1,30 +1,17 @@ export * as SessionTodo from "./todo" import { asc, eq } from "drizzle-orm" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" +import { SessionTodo, SessionTodoInfo } from "@opencode-ai/schema/session-todo" import { Database } from "../database/database" import { EventV2 } from "../event" import { SessionSchema } from "./schema" import { TodoTable } from "./sql" -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}).annotate({ identifier: "SessionTodo.Info" }) +export const Info = SessionTodoInfo export type Info = typeof Info.Type -export const Event = { - Updated: EventV2.define({ - type: "todo.updated", - schema: { - sessionID: SessionSchema.ID, - todos: Schema.Array(Info), - }, - }), -} +export const Event = SessionTodo.Event export interface Interface { readonly update: (input: { diff --git a/packages/core/src/v1/permission.ts b/packages/core/src/v1/permission.ts index b241ccd9077b..c289196c182b 100644 --- a/packages/core/src/v1/permission.ts +++ b/packages/core/src/v1/permission.ts @@ -1,71 +1,8 @@ export * as PermissionV1 from "./permission" import { Schema } from "effect" -import { ProjectV2 } from "../project" -import { withStatics } from "../schema" -import { SessionSchema } from "../session/schema" -import { Identifier } from "../util/identifier" - -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) -export type Action = typeof Action.Type - -export const Rule = Schema.Struct({ - permission: Schema.String, - pattern: Schema.String, - action: Action, -}).annotate({ identifier: "PermissionRule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) -export type Ruleset = typeof Ruleset.Type - -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, - }).pipe(Schema.optional), -}).annotate({ identifier: "PermissionRequest" }) -export type Request = typeof Request.Type - -export const Reply = Schema.Literals(["once", "always", "reject"]) -export type Reply = typeof Reply.Type - -export const ReplyBody = Schema.Struct({ - reply: Reply, - message: Schema.String.pipe(Schema.optional), -}).annotate({ identifier: "PermissionReplyBody" }) -export type ReplyBody = typeof ReplyBody.Type - -export const Approval = Schema.Struct({ - projectID: ProjectV2.ID, - patterns: Schema.Array(Schema.String), -}).annotate({ identifier: "PermissionApproval" }) -export type Approval = typeof Approval.Type - -export const AskInput = Schema.Struct({ - ...Request.fields, - id: ID.pipe(Schema.optional), - ruleset: Ruleset, -}).annotate({ identifier: "PermissionAskInput" }) -export type AskInput = typeof AskInput.Type - -export const ReplyInput = Schema.Struct({ - requestID: ID, - ...ReplyBody.fields, -}).annotate({ identifier: "PermissionReplyInput" }) -export type ReplyInput = typeof ReplyInput.Type +export * from "@opencode-ai/schema/permission-v1" +import { ID } from "@opencode-ai/schema/permission-v1" export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { override get message() { diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index 181ba9807d05..b4ce31e5f09a 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -1,39 +1,52 @@ export * as SessionV1 from "./session" -import { Effect, Schema, Types } from "effect" -import { EventV2 } from "../event" -import { PermissionV1 } from "./permission" -import { ProjectV2 } from "../project" -import { ProviderV2 } from "../provider" -import { ModelV2 } from "../model" -import { optionalOmitUndefined, withStatics } from "../schema" -import { Identifier } from "../util/identifier" +import { Schema } from "effect" import { NonNegativeInt } from "../schema" import { NamedError } from "../util/error" -import { SessionSchema } from "../session/schema" -import { WorkspaceV2 } from "../workspace" -const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) - -export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( - Schema.brand("MessageID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })), -) -export type MessageID = typeof MessageID.Type - -export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( - Schema.brand("PartID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })), -) -export type PartID = typeof PartID.Type +export { + AgentPart, + AgentPartInput, + Assistant, + CompactionPart, + Event, + FilePart, + FilePartInput, + FilePartSource, + FileSource, + Format, + Info, + MessageID, + OutputFormatJsonSchema, + OutputFormatText, + Part, + PartID, + PatchPart, + Range, + ReasoningPart, + ResourceSource, + RetryPart, + SessionInfo, + SnapshotPart, + StepFinishPart, + StepStartPart, + SubtaskPart, + SubtaskPartInput, + SymbolSource, + TextPart, + TextPartInput, + ToolPart, + ToolState, + ToolStateCompleted, + ToolStateError, + ToolStatePending, + ToolStateRunning, + User, + WithParts, +} from "@opencode-ai/schema/session-v1" export const OutputLengthError = NamedError.create("MessageOutputLengthError", {}) - -export const AuthError = NamedError.create("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) - +export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String }) export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = NamedError.create("StructuredOutputError", { message: Schema.String, @@ -52,581 +65,4 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) -export const ContentFilterError = NamedError.create("ContentFilterError", { - message: Schema.String, -}) - -export class OutputFormatText extends Schema.Class("OutputFormatText")({ - type: Schema.Literal("text"), -}) {} - -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ - type: Schema.Literal("json_schema"), - schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} - -export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ - discriminator: "type", - identifier: "OutputFormat", -}) -export type OutputFormat = Schema.Schema.Type - -const partBase = { - id: PartID, - sessionID: SessionSchema.ID, - messageID: MessageID, -} - -export const SnapshotPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("snapshot"), - snapshot: Schema.String, -}).annotate({ identifier: "SnapshotPart" }) -export type SnapshotPart = Types.DeepMutable> - -export const PatchPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("patch"), - hash: Schema.String, - files: Schema.Array(Schema.String), -}).annotate({ identifier: "PatchPart" }) -export type PatchPart = Types.DeepMutable> - -export const TextPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPart" }) -export type TextPart = Types.DeepMutable> - -export const ReasoningPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("reasoning"), - text: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Types.DeepMutable> - -const filePartSourceBase = { - text: Schema.Struct({ - value: Schema.String, - start: Schema.Finite, - end: Schema.Finite, - }).annotate({ identifier: "FilePartSourceText" }), -} - -export const Range = Schema.Struct({ - start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), - end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), -}).annotate({ identifier: "Range" }) -export type Range = typeof Range.Type - -export const FileSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "FileSource" }) - -export const SymbolSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("symbol"), - path: Schema.String, - range: Range, - name: Schema.String, - kind: NonNegativeInt, -}).annotate({ identifier: "SymbolSource" }) - -export const ResourceSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("resource"), - clientName: Schema.String, - uri: Schema.String, -}).annotate({ identifier: "ResourceSource" }) - -export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ - discriminator: "type", - identifier: "FilePartSource", -}) - -export const FilePart = Schema.Struct({ - ...partBase, - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePart" }) -export type FilePart = Types.DeepMutable> - -export const AgentPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPart" }) -export type AgentPart = Types.DeepMutable> - -export const CompactionPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("compaction"), - auto: Schema.Boolean, - overflow: Schema.optional(Schema.Boolean), - tail_start_id: Schema.optional(MessageID), -}).annotate({ identifier: "CompactionPart" }) -export type CompactionPart = Types.DeepMutable> - -export const SubtaskPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPart" }) -export type SubtaskPart = Types.DeepMutable> - -export const RetryPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - error: APIError.EffectSchema, - time: Schema.Struct({ - created: NonNegativeInt, - }), -}).annotate({ identifier: "RetryPart" }) -export type RetryPart = Omit>, "error"> & { - error: APIError -} - -export const StepStartPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-start"), - snapshot: Schema.optional(Schema.String), -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Types.DeepMutable> - -export const StepFinishPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-finish"), - reason: Schema.String, - snapshot: Schema.optional(Schema.String), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), -}).annotate({ identifier: "StepFinishPart" }) -export type StepFinishPart = Types.DeepMutable> - -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), - input: Schema.Record(Schema.String, Schema.Any), - raw: Schema.String, -}).annotate({ identifier: "ToolStatePending" }) -export type ToolStatePending = Types.DeepMutable> - -export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Any), - title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateRunning" }) -export type ToolStateRunning = Types.DeepMutable> - -export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Any), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Any), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - compacted: Schema.optional(NonNegativeInt), - }), - attachments: Schema.optional(Schema.Array(FilePart)), -}).annotate({ identifier: "ToolStateCompleted" }) -export type ToolStateCompleted = Types.DeepMutable> - -export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Any), - error: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateError" }) -export type ToolStateError = Types.DeepMutable> - -export const ToolState = Schema.Union([ - ToolStatePending, - ToolStateRunning, - ToolStateCompleted, - ToolStateError, -]).annotate({ - discriminator: "status", - identifier: "ToolState", -}) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export const ToolPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("tool"), - callID: Schema.String, - tool: Schema.String, - state: ToolState, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "ToolPart" }) -export type ToolPart = Omit>, "state"> & { - state: ToolState -} - -const messageBase = { - id: MessageID, - sessionID: partBase.sessionID, -} - -const FileDiff = Schema.Struct({ - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), - additions: Schema.Finite, - deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) - -export const User = Schema.Struct({ - ...messageBase, - role: Schema.Literal("user"), - time: Schema.Struct({ - created: Timestamp, - }), - format: Schema.optional(Format), - summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff), - }), - ), - agent: Schema.String, - model: Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - variant: Schema.optional(Schema.String), - }), - system: Schema.optional(Schema.String), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), -}).annotate({ identifier: "UserMessage" }) -export type User = Types.DeepMutable> - -export const Part = Schema.Union([ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, -]).annotate({ discriminator: "type", identifier: "Part" }) -export type Part = - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - -const AssistantErrorSchema = Schema.Union([ - AuthError.EffectSchema, - NamedError.Unknown.EffectSchema, - OutputLengthError.EffectSchema, - AbortedError.EffectSchema, - StructuredOutputError.EffectSchema, - ContextOverflowError.EffectSchema, - ContentFilterError.EffectSchema, - APIError.EffectSchema, -]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type - -export const TextPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPartInput" }) -export type TextPartInput = Types.DeepMutable> - -export const FilePartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePartInput" }) -export type FilePartInput = Types.DeepMutable> - -export const AgentPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPartInput" }) -export type AgentPartInput = Types.DeepMutable> - -export const SubtaskPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPartInput" }) -export type SubtaskPartInput = Types.DeepMutable> - -export const Assistant = Schema.Struct({ - ...messageBase, - role: Schema.Literal("assistant"), - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(AssistantErrorSchema), - parentID: MessageID, - modelID: ModelV2.ID, - providerID: ProviderV2.ID, - mode: Schema.String, - agent: Schema.String, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - summary: Schema.optional(Schema.Boolean), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - structured: Schema.optional(Schema.Any), - variant: Schema.optional(Schema.String), - finish: Schema.optional(Schema.String), -}).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { - error?: AssistantError -} - -export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) -export type Info = User | Assistant - -export const WithParts = Schema.Struct({ - info: Info, - parts: Schema.Array(Part), -}) -export type WithParts = { - info: Info - parts: Part[] -} - -const options = { - durable: { - aggregate: "sessionID", - version: 1, - }, -} as const - -const SessionSummary = Schema.Struct({ - additions: Schema.Finite, - deletions: Schema.Finite, - files: Schema.Finite, - diffs: optionalOmitUndefined(Schema.Array(FileDiff)), -}) - -const SessionTokens = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) - -const SessionShare = Schema.Struct({ - url: Schema.String, -}) - -const SessionRevert = Schema.Struct({ - messageID: MessageID, - partID: optionalOmitUndefined(PartID), - snapshot: optionalOmitUndefined(Schema.String), - diff: optionalOmitUndefined(Schema.String), -}) - -const SessionModel = Schema.Struct({ - id: ModelV2.ID, - providerID: ProviderV2.ID, - variant: optionalOmitUndefined(Schema.String), -}) - -export const SessionInfo = Schema.Struct({ - id: SessionSchema.ID, - slug: Schema.String, - projectID: ProjectV2.ID, - workspaceID: optionalOmitUndefined(WorkspaceV2.ID), - directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionSchema.ID), - summary: optionalOmitUndefined(SessionSummary), - cost: optionalOmitUndefined(Schema.Finite), - tokens: optionalOmitUndefined(SessionTokens), - share: optionalOmitUndefined(SessionShare), - title: Schema.String, - agent: optionalOmitUndefined(Schema.String), - model: optionalOmitUndefined(SessionModel), - version: Schema.String, - metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(Schema.Finite), - }), - permission: optionalOmitUndefined(PermissionV1.Ruleset), - revert: optionalOmitUndefined(SessionRevert), -}).annotate({ identifier: "Session" }) -export type SessionInfo = typeof SessionInfo.Type - -export const Event = { - Created: EventV2.define({ - type: "session.created", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - Updated: EventV2.define({ - type: "session.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - Deleted: EventV2.define({ - type: "session.deleted", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - MessageUpdated: EventV2.define({ - type: "message.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: Info, - }, - }), - MessageRemoved: EventV2.define({ - type: "message.removed", - ...options, - schema: { - sessionID: SessionSchema.ID, - messageID: MessageID, - }, - }), - PartUpdated: EventV2.define({ - type: "message.part.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - part: Part, - time: Schema.Finite, - }, - }), - PartRemoved: EventV2.define({ - type: "message.part.removed", - ...options, - schema: { - sessionID: SessionSchema.ID, - messageID: MessageID, - partID: PartID, - }, - }), -} +export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index bd2f2eee980c..e2b2a5df046c 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,10 +1,14 @@ import { describe, expect } from "bun:test" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" +import { Event } from "@opencode-ai/schema/event" +import { Session } from "@opencode-ai/schema/session" +import { SessionEvent } from "@opencode-ai/schema/session-event" +import { SessionV1 } from "@opencode-ai/schema/session-v1" import { Database } from "@opencode-ai/core/database/database" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" -import { AbsolutePath, DateTimeUtcFromMillis } from "@opencode-ai/core/schema" +import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { eq } from "drizzle-orm" import { location } from "./fixture/location" @@ -16,10 +20,6 @@ const locationLayer = Layer.succeed( location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }), ), ) -const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer) -const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) -const itWithoutLocation = testEffect(eventLayer) - const Message = EventV2.define({ type: "test.message", schema: { @@ -70,18 +70,16 @@ const VersionedMessage = EventV2.define({ }, }) -const SyncTimestamp = EventV2.define({ - type: "test.timestamp", - durable: { - version: 1, - aggregate: "id", - }, - schema: { - id: Schema.String, - timestamp: DateTimeUtcFromMillis, - }, +const DurableMessage = SessionV1.Event.MessageRemoved +const durableData = (sessionID: Session.ID, text: string) => ({ + sessionID, + messageID: SessionV1.MessageID.ascending(`msg_${text}`), }) +const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer) +const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) +const itWithoutLocation = testEffect(eventLayer) + describe("EventV2", () => { it.effect("publishes events with the current location", () => Effect.gen(function* () { @@ -122,26 +120,21 @@ describe("EventV2", () => { }), ) - it.effect("stores definitions in the exported registry", () => - Effect.sync(() => { - expect(EventV2.registry.get(Message.type)).toBe(Message) - }), - ) - - it.effect("keeps the latest sync definition in the registry", () => + it.effect("selects the latest durable definition independent of declaration order", () => Effect.sync(() => { const latest = EventV2.define({ type: "test.out-of-order", durable: { version: 2, aggregate: "id" }, schema: { id: Schema.String }, }) - EventV2.define({ + const historical = EventV2.define({ type: "test.out-of-order", durable: { version: 1, aggregate: "id" }, schema: { id: Schema.String }, }) - expect(EventV2.registry.get("test.out-of-order")).toBe(latest) + expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest) + expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest) }), ) @@ -363,19 +356,19 @@ describe("EventV2", () => { it.effect("replays durable aggregate events after a sequence and tails new events", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) - yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) const fiber = yield* events .durable({ aggregateID, after: 0 }) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* events.publish(SyncMessage, { id: aggregateID, text: "two" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "two")) expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ - [1, { id: aggregateID, text: "one" }], - [2, { id: aggregateID, text: "two" }], + [1, durableData(aggregateID, "one")], + [2, durableData(aggregateID, "two")], ]) }), ) @@ -383,20 +376,15 @@ describe("EventV2", () => { it.effect("catches durable aggregate events published during replay handoff", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) - yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) - expect( - Array.from(yield* Fiber.join(fiber)).map((event) => [ - event.durable?.seq, - (event.data as { text: string }).text, - ]), - ).toEqual([ - [0, "zero"], - [1, "one"], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [0, durableData(aggregateID, "zero")], + [1, durableData(aggregateID, "one")], ]) }), ) @@ -415,16 +403,16 @@ describe("EventV2", () => { yield* Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Deferred.await(readStarted) pause = false - yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "during handoff")) yield* Deferred.succeed(continueRead, undefined) expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ - [0, { id: aggregateID, text: "during handoff" }], + [0, durableData(aggregateID, "during handoff")], ]) }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) }), @@ -433,7 +421,7 @@ describe("EventV2", () => { it.effect("coalesces durable aggregate wakes while draining every committed event", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const count = 64 const fiber = yield* events .durable({ aggregateID }) @@ -441,11 +429,11 @@ describe("EventV2", () => { yield* Effect.yieldNow for (let index = 0; index < count; index++) { - yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) }) + yield* events.publish(DurableMessage, durableData(aggregateID, String(index))) } expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual( - Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]), + Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]), ) }), ) @@ -453,14 +441,14 @@ describe("EventV2", () => { it.effect("omits live-only events from durable aggregate streams", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* events.publish(Message, { text: "live only" }) - yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "durable")) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type]) + expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type]) }), ) @@ -487,23 +475,23 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), ) - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "hello" }, + data: durableData(aggregateID, "hello"), }) - expect(received[0]?.type).toBe(SyncMessage.type) - expect(received[0]?.data).toEqual({ id: aggregateID, text: "hello" }) + expect(received[0]?.type).toBe(DurableMessage.type) + expect(received[0]?.data).toEqual(durableData(aggregateID, "hello")) }), ) @@ -511,14 +499,14 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), }) const rows = yield* db .select() @@ -538,11 +526,11 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const envelopeAggregateID = EventV2.ID.create() - const payloadAggregateID = EventV2.ID.create() + const envelopeAggregateID = Session.ID.create() + const payloadAggregateID = Session.ID.create() const received = new Array() - yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" }) - yield* events.project(SyncMessage, (event) => + yield* events.publish(DurableMessage, durableData(payloadAggregateID, "seed")) + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -551,10 +539,10 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID: envelopeAggregateID, - data: { id: payloadAggregateID, text: "replayed" }, + data: durableData(payloadAggregateID, "replayed"), }) .pipe(Effect.exit) const rows = yield* db @@ -580,22 +568,22 @@ describe("EventV2", () => { it.effect("replay defects on sequence mismatch", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }) const exit = yield* events .replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 5, aggregateID, - data: { id: aggregateID, text: "bad" }, + data: durableData(aggregateID, "bad"), }) .pipe(Effect.exit) @@ -606,9 +594,9 @@ describe("EventV2", () => { it.effect("replay decodes synchronized transformed values before projection", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const received = new Array() - yield* events.project(SyncTimestamp, (event) => + const aggregateID = Session.ID.create() + const received = new Array() + yield* events.project(SessionEvent.ContextUpdated, (event) => Effect.sync(() => { received.push(event) }), @@ -616,10 +604,10 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncTimestamp.type, 1), + type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, timestamp: 0 }, + data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" }, }) expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0)) @@ -646,21 +634,21 @@ describe("EventV2", () => { it.effect("replayAll validates contiguous aggregate events", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const source = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "one" }, + data: durableData(aggregateID, "one"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "two" }, + data: durableData(aggregateID, "two"), }, ]) @@ -672,38 +660,38 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const one = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "one" }, + data: durableData(aggregateID, "one"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "two" }, + data: durableData(aggregateID, "two"), }, ]) const two = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, - data: { id: aggregateID, text: "three" }, + data: durableData(aggregateID, "three"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 3, aggregateID, - data: { id: aggregateID, text: "four" }, + data: durableData(aggregateID, "four"), }, ]) const rows = yield* db @@ -723,10 +711,10 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) yield* events.claim(aggregateID, "owner-a") - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -735,10 +723,10 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "ignored" }, + data: durableData(aggregateID, "ignored"), }, { ownerID: "owner-b" }, ) @@ -750,14 +738,14 @@ describe("EventV2", () => { it.effect("strict owner fences exact replay", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const id = EventV2.ID.create() const replayed = { id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "owned" }, + data: durableData(aggregateID, "owned"), } yield* events.replay(replayed, { ownerID: "owner-a" }) @@ -771,11 +759,11 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() - const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" }) + const aggregateID = Session.ID.create() + const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned")) const replayed = { id: published.id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: published.durable!.seq, aggregateID, data: published.data, @@ -792,7 +780,7 @@ describe("EventV2", () => { expect(row?.ownerID).toBe("owner-a") const exit = yield* events .replay( - { ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } }, + { ...replayed, id: EventV2.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") }, { ownerID: "owner-b", strictOwner: true }, ) .pipe(Effect.exit) @@ -804,15 +792,15 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "owned" }, + data: durableData(aggregateID, "owned"), }, { ownerID: "owner-1" }, ) @@ -831,26 +819,26 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "local" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "local")) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "claimed" }, + data: durableData(aggregateID, "claimed"), }, { ownerID: "owner-1" }, ) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, - data: { id: aggregateID, text: "fenced" }, + data: durableData(aggregateID, "fenced"), }, { ownerID: "owner-2" }, ) @@ -875,14 +863,14 @@ describe("EventV2", () => { it.effect("strict replay rejects an owner conflict instead of silently skipping it", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "claimed" }, + data: durableData(aggregateID, "claimed"), }, { ownerID: "owner-1" }, ) @@ -891,10 +879,10 @@ describe("EventV2", () => { .replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "conflict" }, + data: durableData(aggregateID, "conflict"), }, { ownerID: "owner-2", strictOwner: true }, ) @@ -908,14 +896,14 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.listen((event) => Effect.sync(() => received.push(event))) const replayed = { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), } yield* events.replay(replayed, { publish: true }) @@ -929,19 +917,19 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const replayed = { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "original" }, + data: durableData(aggregateID, "original"), } yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.replay(replayed, { publish: true }) const exit = yield* events - .replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true }) + .replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true }) .pipe(Effect.exit) expect(String(exit)).toContain("Replay diverged") @@ -952,23 +940,23 @@ describe("EventV2", () => { it.effect("rejects an event ID reused at another aggregate position", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const id = EventV2.ID.create() yield* events.replay({ id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }) const exit = yield* events .replay({ id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "second" }, + data: durableData(aggregateID, "second"), }) .pipe(Effect.exit) @@ -980,27 +968,27 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const received = new Array() yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }, { ownerID: "owner-1" }, ) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "ignored" }, + data: durableData(aggregateID, "ignored"), }, { ownerID: "owner-2", publish: true }, ) @@ -1047,10 +1035,10 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) yield* events.remove(aggregateID) - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -1058,13 +1046,13 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), }) - expect(received[0]?.data).toEqual({ id: aggregateID, text: "replayed" }) + expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed")) }), ) }) diff --git a/packages/core/test/legacy-event-schema.test.ts b/packages/core/test/legacy-event-schema.test.ts new file mode 100644 index 000000000000..d9a2833b6986 --- /dev/null +++ b/packages/core/test/legacy-event-schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1" +import { SessionV1 } from "../src/v1/session" + +describe("legacy event schema compatibility", () => { + test("Core references canonical SessionV1 definitions", () => { + expect(SessionV1.Event.Created).toBe(Wire.Event.Created) + expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated) + }) + + test("Core retains NamedError constructor identity", () => { + const error = new SessionV1.APIError({ message: "failed", isRetryable: false }) + expect(error).toBeInstanceOf(SessionV1.APIError) + expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } }) + }) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 139217e348a4..87cf66bc1ab3 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -86,6 +86,7 @@ "@openauthjs/openauth": "catalog:", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0463e83f6bef..dacf41d0eeed 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -2,29 +2,20 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance-context" -import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" -import { EventV2 } from "@opencode-ai/core/event" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { commands: Record } export const Event = { - Executed: EventV2.define({ - type: "command.executed", - schema: { - name: Schema.String, - sessionID: SessionID, - arguments: Schema.String, - messageID: MessageID, - }, - }), + Executed: LegacyEvent.CommandExecuted, } export const Info = Schema.Struct({ diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 0fdd6f0c7dc8..40195d480472 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -33,6 +33,7 @@ import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime" +import { WorkspaceEvent } from "@opencode-ai/schema/workspace-event" export const Info = Schema.Struct({ ...WorkspaceInfoSchema.fields, @@ -40,27 +41,10 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Workspace" }) export type Info = WorkspaceInfo & { timeUsed: number } -export const ConnectionStatus = Schema.Struct({ - workspaceID: WorkspaceV2.ID, - status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), -}) -export type ConnectionStatus = Schema.Schema.Type - -export const Event = { - Ready: EventV2.define({ - type: "workspace.ready", - schema: { - name: Schema.String, - }, - }), - Failed: EventV2.define({ - type: "workspace.failed", - schema: { - message: Schema.String, - }, - }), - Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }), -} +export const ConnectionStatus = WorkspaceEvent.ConnectionStatus +export type ConnectionStatus = WorkspaceEvent.ConnectionStatus + +export const Event = WorkspaceEvent function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { diff --git a/packages/opencode/src/event-manifest.ts b/packages/opencode/src/event-manifest.ts new file mode 100644 index 000000000000..b84b3f29798b --- /dev/null +++ b/packages/opencode/src/event-manifest.ts @@ -0,0 +1,3 @@ +export * as EventManifest from "./event-manifest" + +export { Definitions, Durable, Latest } from "@opencode-ai/schema/event-manifest" diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 4cef7311dcbe..836f408e0f48 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -7,9 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" -import "@opencode-ai/core/account" -import "@opencode-ai/core/catalog" -import "@opencode-ai/core/session/event" import { Context, Effect, Layer } from "effect" export class Service extends Context.Service()("@opencode/EventV2Bridge") {} @@ -45,10 +42,7 @@ export const layer = Layer.effect( workspace: workspaceID, payload: { id: event.id, type: event.type, properties: event.data }, }) - const durable = EventV2.registry.get(event.type)?.durable - if (durable === undefined || event.durable === undefined) return - const aggregateID = (event.data as Record)[durable.aggregate] - if (typeof aggregateID !== "string") return + if (event.durable === undefined) return GlobalBus.emit("event", { directory: event.location?.directory ?? ctx?.directory, project: ctx?.project.id, @@ -59,7 +53,7 @@ export const layer = Layer.effect( id: event.id, type: EventV2.versionedType(event.type, event.durable.version), seq: event.durable.seq, - aggregateID, + aggregateID: event.durable.aggregateID, data: event.data, }, }, diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index fd8b8fc8cb6d..31b3add14b1d 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,7 +1,7 @@ -import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Process } from "@/util/process" +import { IdeEvent } from "@opencode-ai/schema/ide-event" const SUPPORTED_IDES = [ { name: "Windsurf" as const, cmd: "windsurf" }, @@ -11,14 +11,7 @@ const SUPPORTED_IDES = [ { name: "VSCodium" as const, cmd: "codium" }, ] -export const Event = { - Installed: EventV2.define({ - type: "ide.installed", - schema: { - ide: Schema.String, - }, - }), -} +export const Event = IdeEvent export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 0ed10dc327dd..b4b888ed78d4 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -8,30 +8,17 @@ import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import path from "path" -import { EventV2 } from "@opencode-ai/core/event" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { NpmConfig } from "@opencode-ai/core/npm-config" +import { InstallationEvent } from "@opencode-ai/schema/installation-event" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type ReleaseType = "patch" | "minor" | "major" -export const Event = { - Updated: EventV2.define({ - type: "installation.updated", - schema: { - version: Schema.String, - }, - }), - UpdateAvailable: EventV2.define({ - type: "installation.update-available", - schema: { - version: Schema.String, - }, - }), -} +export const Event = InstallationEvent export function getReleaseType(current: string, latest: string): ReleaseType { const currMajor = semver.major(current) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 0e7cf82d9d1e..bf19dda7c86d 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,7 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" @@ -14,10 +13,9 @@ import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { LspEvent } from "@opencode-ai/schema/lsp-event" -export const Event = { - Updated: EventV2.define({ type: "lsp.updated", schema: {} }), -} +export const Event = LspEvent const Position = Schema.Struct({ line: NonNegativeInt, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index bb24d4bd9e76..db673244b3d7 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -26,7 +26,6 @@ import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { TuiEvent } from "@/server/tui-event" import open from "open" import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" @@ -35,6 +34,7 @@ import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { McpCatalog } from "./catalog" +import { McpEvent } from "@opencode-ai/schema/mcp-event" const DEFAULT_TIMEOUT = 30_000 const CLIENT_OPTIONS = { @@ -59,20 +59,9 @@ export const Resource = Schema.Struct({ }).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type -export const ToolsChanged = EventV2.define({ - type: "mcp.tools.changed", - schema: { - server: Schema.String, - }, -}) +export const ToolsChanged = McpEvent.ToolsChanged -export const BrowserOpenFailed = EventV2.define({ - type: "mcp.browser.open.failed", - schema: { - mcpName: Schema.String, - url: Schema.String, - }, -}) +export const BrowserOpenFailed = McpEvent.BrowserOpenFailed export const Failed = NamedError.create("MCPFailed", { name: Schema.String, diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 553b3e5b1f3d..92f236594c40 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -6,19 +6,9 @@ import { Deferred, Effect, Layer, Context } from "effect" import os from "os" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" - -export const Event = { - Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }), - Replied: EventV2.define({ - type: "permission.replied", - schema: { - sessionID: PermissionV1.Request.fields.sessionID, - requestID: PermissionV1.ID, - reply: PermissionV1.Reply, - }, - }), -} +import { PermissionV1Event } from "@opencode-ai/schema/permission-v1" + +export const Event = PermissionV1Event export interface Interface { readonly ask: (input: PermissionV1.AskInput) => Effect.Effect diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 5f1b64743a74..82ae979ba3ce 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,46 +16,18 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" +import { AbsolutePath } from "@opencode-ai/core/schema" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/schema/project" -const ProjectVcs = Schema.Literal("git") - -const ProjectIcon = Schema.Struct({ - url: optionalOmitUndefined(Schema.String), - override: optionalOmitUndefined(Schema.String), - color: optionalOmitUndefined(Schema.String), -}) - -const ProjectCommands = Schema.Struct({ - start: optionalOmitUndefined( - Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), - ), -}) - -const ProjectTime = Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - initialized: optionalOmitUndefined(NonNegativeInt), -}) - -export const Info = Schema.Struct({ - id: ProjectV2.ID, - worktree: Schema.String, - vcs: optionalOmitUndefined(ProjectVcs), - name: optionalOmitUndefined(Schema.String), - icon: optionalOmitUndefined(ProjectIcon), - commands: optionalOmitUndefined(ProjectCommands), - time: ProjectTime, - sandboxes: Schema.Array(Schema.String), -}).annotate({ identifier: "Project" }) +export const Info = Project.Info export type Info = Types.DeepMutable> export const Event = { - Updated: EventV2.define({ type: "project.updated", schema: Info.fields }), + Updated: Project.Event.Updated, } type Row = typeof ProjectTable.$inferSelect @@ -72,7 +44,7 @@ export function fromRow(row: Row): Info { return { id: row.id, worktree: row.worktree, - vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined, + vcs: row.vcs ? Schema.decodeUnknownSync(Project.Vcs)(row.vcs) : undefined, name: row.name ?? undefined, icon, time: { @@ -88,15 +60,15 @@ export function fromRow(row: Row): Info { export const UpdateInput = Schema.Struct({ projectID: ProjectV2.ID, name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), }) export type UpdateInput = Types.DeepMutable> export const UpdatePayload = Schema.Struct({ name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), }).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> @@ -135,7 +107,6 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const proc = yield* AppProcess.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service const projectDirectories = yield* ProjectDirectories.Service @@ -168,7 +139,7 @@ export const layer = Layer.effect( }), ) - const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS) + const fakeVcs = Schema.decodeUnknownSync(Schema.optional(Project.Vcs))(Flag.OPENCODE_FAKE_VCS) const scope = yield* Scope.Scope diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index decb3cfd989d..6f8c86b0b3fe 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,12 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" +import { Effect, Layer, Context, Schema, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" import { InstanceState } from "@/effect/instance-state" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Git } from "@/git" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import { VcsEvent } from "@opencode-ai/schema/vcs-event" const PATCH_CONTEXT_LINES = 2_147_483_647 const MAX_PATCH_BYTES = 10_000_000 @@ -234,14 +235,7 @@ const track = Effect.fnUntraced(function* ( export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type -export const Event = { - BranchUpdated: EventV2.define({ - type: "vcs.branch.updated", - schema: { - branch: Schema.optional(Schema.String), - }, - }), -} +export const Event = VcsEvent export const Info = Schema.Struct({ branch: Schema.optional(Schema.String), diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 61bdc40ee8f3..0517ec7ebe68 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,94 +1,28 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Schema, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { SessionID, MessageID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { QuestionID } from "./schema" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" - -// Schemas — these are pure data; nothing checks class identity (see PR -// description) so they're plain `Schema.Struct` + type alias. That lets -// `Question.ask` and other internal sites trust the type contract without a -// re-decode to coerce nested class instances. - -export const Option = Schema.Struct({ - label: Schema.String.annotate({ - description: "Display text (1-5 words, concise)", - }), - description: Schema.String.annotate({ - description: "Explanation of choice", - }), -}).annotate({ identifier: "QuestionOption" }) -export type Option = Schema.Schema.Type - -const base = { - question: Schema.String.annotate({ - description: "Complete question", - }), - header: Schema.String.annotate({ - description: "Very short label (max 30 chars)", - }), - options: Schema.Array(Option).annotate({ - description: "Available choices", - }), - multiple: Schema.optional(Schema.Boolean).annotate({ - description: "Allow selecting multiple choices", - }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.optional(Schema.Boolean).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionInfo" }) -export type Info = Schema.Schema.Type - -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) -export type Prompt = Schema.Schema.Type - -export const Tool = Schema.Struct({ - messageID: MessageID, - callID: Schema.String, -}).annotate({ identifier: "QuestionTool" }) -export type Tool = Schema.Schema.Type - -export const Request = Schema.Struct({ - id: QuestionID, - sessionID: SessionID, - questions: Schema.Array(Info).annotate({ - description: "Questions to ask", - }), - tool: Schema.optional(Tool), -}).annotate({ identifier: "QuestionRequest" }) -export type Request = Schema.Schema.Type - -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) -export type Answer = Schema.Schema.Type - -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionReply" }) -export type Reply = Schema.Schema.Type - -export const Replied = Schema.Struct({ - sessionID: SessionID, - requestID: QuestionID, - answers: Schema.Array(Answer), -}).annotate({ identifier: "QuestionReplied" }) - -export const Rejected = Schema.Struct({ - sessionID: SessionID, - requestID: QuestionID, -}).annotate({ identifier: "QuestionRejected" }) - -export const Event = { - Asked: EventV2.define({ type: "question.asked", schema: Request.fields }), - Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }), - Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }), -} +import { QuestionV1 } from "@opencode-ai/schema/question-v1" + +export const Option = QuestionV1.Option +export type Option = typeof Option.Type +export const Info = QuestionV1.Info +export type Info = typeof Info.Type +export const Prompt = QuestionV1.Prompt +export type Prompt = typeof Prompt.Type +export const Tool = QuestionV1.Tool +export type Tool = typeof Tool.Type +export const Request = QuestionV1.Request +export type Request = typeof Request.Type +export const Answer = QuestionV1.Answer +export type Answer = typeof Answer.Type +export const Reply = QuestionV1.Reply +export type Reply = typeof Reply.Type +export const Replied = QuestionV1.Replied +export const Rejected = QuestionV1.Rejected +export const Event = QuestionV1.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { override get message() { diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 2574594a2301..ed7f1edee7fc 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,10 +1,4 @@ -import { Schema } from "effect" +import { QuestionV1 } from "@opencode-ai/schema/question-v1" -import { Identifier } from "@/id/id" -import { Newtype } from "@opencode-ai/core/schema" - -export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { - static ascending(id?: string): QuestionID { - return this.make(Identifier.ascending("question", id)) - } -} +export const QuestionID = QuestionV1.ID +export type QuestionID = typeof QuestionID.Type diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index a58131255587..9bb8a8c3b04f 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,10 +1,7 @@ -import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" +import { ServerEvent } from "@opencode-ai/schema/server-event" -export const Event = { - Connected: EventV2.define({ type: "server.connected", schema: {} }), - Disposed: EventV2.define({ type: "global.disposed", schema: {} }), -} +export const Event = ServerEvent export const InstanceDisposed = Schema.Struct({ id: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 63cc225ebe9c..7cf59ec2371e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { SkillV2 } from "@opencode-ai/core/skill" @@ -24,15 +25,13 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" -import { Api } from "@opencode-ai/server/api" -// GlobalEventSchema snapshots the registry after event-producing groups register their variants. +import { makeApi } from "@opencode-ai/server/api" import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" const EventSchema = Schema.Union([ - ...EventV2.registry - .values() + ...EventManifest.Latest.values() .map((definition) => Schema.Struct({ id: EventV2.ID, @@ -44,6 +43,8 @@ const EventSchema = Schema.Union([ InstanceDisposed, ]).annotate({ identifier: "Event" }) +export const ServerApi = makeApi(EventManifest.Latest.values().toArray()) + export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(ControlPlaneApi) @@ -73,7 +74,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) .addHttpApi(EventApi) .addHttpApi(InstanceHttpApi) - .addHttpApi(Api) + .addHttpApi(ServerApi) .addHttpApi(PtyConnectApi) .annotate(HttpApi.AdditionalSchemas, [ EventSchema, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 3e13154867df..61daefe8a2d4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,6 @@ -import { Config } from "@/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" import { InstanceDisposed } from "@/server/event" import "@opencode-ai/core/account" import "@/server/event" @@ -13,8 +13,7 @@ const GlobalHealth = Schema.Struct({ version: Schema.String, }) -const SyncEventSchemas = EventV2.registry - .values() +const SyncEventSchemas = EventManifest.Latest.values() .flatMap((definition) => { if (!definition.durable) return [] return [ @@ -38,8 +37,7 @@ const GlobalEventSchema = Schema.Struct({ project: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), payload: Schema.Union([ - ...EventV2.registry - .values() + ...EventManifest.Latest.values() .map((definition) => Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }), ) diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts index 73412b8778b9..3fb3576db009 100644 --- a/packages/opencode/src/server/tui-event.ts +++ b/packages/opencode/src/server/tui-event.ts @@ -1,53 +1 @@ -import { SessionID } from "@/session/schema" -import { PositiveInt } from "@opencode-ai/core/schema" -import { EventV2 } from "@opencode-ai/core/event" -import { Effect, Schema } from "effect" - -const DEFAULT_TOAST_DURATION = 5000 - -export const TuiEvent = { - PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }), - CommandExecute: EventV2.define({ - type: "tui.command.execute", - schema: { - command: Schema.Union([ - Schema.Literals([ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle", - ]), - Schema.String, - ]), - }, - }), - ToastShow: EventV2.define({ - type: "tui.toast.show", - schema: { - title: Schema.optional(Schema.String), - message: Schema.String, - variant: Schema.Literals(["info", "success", "warning", "error"]), - duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ - description: "Duration in milliseconds", - }), - }, - }), - SessionSelect: EventV2.define({ - type: "tui.session.select", - schema: { - sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), - }, - }), -} +export { TuiEvent } from "@opencode-ai/schema/tui-event" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c7ac963c690e..fe4df3461912 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -23,17 +23,10 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { EventV2 } from "@opencode-ai/core/event" import { buildPrompt } from "@opencode-ai/core/session/compaction" +import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event" -export const Event = { - Compacted: EventV2.define({ - type: "session.compacted", - schema: { - sessionID: SessionID, - }, - }), -} +export const Event = SessionCompactionEvent export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 813fe49f325d..798518d0ba57 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,5 +1,4 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { SessionID, MessageID, PartID } from "./schema" +import { SessionID, MessageID } from "./schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ProviderV2 } from "@opencode-ai/core/provider" import { @@ -12,11 +11,9 @@ import { Info, OutputLengthError, Part, - StructuredOutputError, SubtaskPart, User, WithParts, - type ToolPart, } from "@opencode-ai/core/v1/session" import { NamedError } from "@opencode-ai/core/util/error" @@ -61,16 +58,7 @@ export const Event = { Updated: SessionV1.Event.MessageUpdated, Removed: SessionV1.Event.MessageRemoved, PartUpdated: SessionV1.Event.PartUpdated, - PartDelta: EventV2.define({ - type: "message.part.delta", - schema: { - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - field: Schema.String, - delta: Schema.String, - }, - }), + PartDelta: SessionV1.Event.PartDelta, PartRemoved: SessionV1.Event.PartRemoved, } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 038ec9aa0d6e..64d4111088da 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -11,7 +11,6 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@opencode-ai/core/database/database" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -38,7 +37,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" -import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" @@ -309,69 +307,12 @@ export type GlobalListInput = { archived?: boolean } -const CreatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: Info, -}) - -const UpdatedShare = Schema.Struct({ - url: Schema.optional(Schema.NullOr(Schema.String)), -}) - -const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(NonNegativeInt)), - updated: Schema.optional(Schema.NullOr(NonNegativeInt)), - compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), - archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), -}) - -const UpdatedInfo = Schema.Struct({ - id: Schema.optional(Schema.NullOr(SessionID)), - slug: Schema.optional(Schema.NullOr(Schema.String)), - projectID: Schema.optional(Schema.NullOr(ProjectV2.ID)), - workspaceID: Schema.optional(Schema.NullOr(WorkspaceV2.ID)), - directory: Schema.optional(Schema.NullOr(Schema.String)), - path: Schema.optional(Schema.NullOr(Schema.String)), - parentID: Schema.optional(Schema.NullOr(SessionID)), - summary: Schema.optional(Schema.NullOr(Summary)), - cost: Schema.optional(Schema.Finite), - tokens: Schema.optional(Tokens), - share: Schema.optional(UpdatedShare), - title: Schema.optional(Schema.NullOr(Schema.String)), - agent: Schema.optional(Schema.NullOr(Schema.String)), - model: Schema.optional(Schema.NullOr(Model)), - version: Schema.optional(Schema.NullOr(Schema.String)), - metadata: Schema.optional(Schema.NullOr(Metadata)), - time: Schema.optional(UpdatedTime), - permission: Schema.optional(Schema.NullOr(PermissionV1.Ruleset)), - revert: Schema.optional(Schema.NullOr(Revert)), -}) - -const UpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: UpdatedInfo, -}) - export const Event = { Created: SessionV1.Event.Created, Updated: SessionV1.Event.Updated, Deleted: SessionV1.Event.Deleted, - Diff: EventV2.define({ - type: "session.diff", - schema: { - sessionID: SessionID, - diff: Schema.Array(Snapshot.FileDiff), - }, - }), - Error: EventV2.define({ - type: "session.error", - schema: { - sessionID: Schema.optional(SessionID), - // Reuses SessionV1.Assistant.fields.error (already Schema.optional) so - // the derived schema keeps the same discriminated-union shape on the event stream. - error: SessionV1.Assistant.fields.error, - }, - }), + Diff: SessionV1.Event.Diff, + Error: SessionV1.Event.Error, } export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 68758ea6a3a6..a5714c55d5a7 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,53 +1,14 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { NonNegativeInt } from "@opencode-ai/core/schema" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" -export const Info = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("idle"), - }), - Schema.Struct({ - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - message: Schema.String, - action: Schema.optional( - Schema.Struct({ - reason: Schema.String, - provider: Schema.String, - title: Schema.String, - message: Schema.String, - label: Schema.String, - link: Schema.optional(Schema.String), - }), - ), - next: NonNegativeInt, - }), - Schema.Struct({ - type: Schema.Literal("busy"), - }), -]).annotate({ identifier: "SessionStatus" }) -export type Info = Schema.Schema.Type +export const Info = SessionStatusEvent.Info +export type Info = SessionStatusEvent.Info -export const Event = { - Status: EventV2.define({ - type: "session.status", - schema: { - sessionID: SessionID, - status: Info, - }, - }), - // deprecated - Idle: EventV2.define({ - type: "session.idle", - schema: { - sessionID: SessionID, - }, - }), -} +export const Event = SessionStatusEvent export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 6e9eeba62b80..462613522dce 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,31 +1,17 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionID } from "./schema" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" import { TodoTable } from "@opencode-ai/core/session/sql" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { SessionTodo } from "@opencode-ai/schema/session-todo" -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}).annotate({ identifier: "Todo" }) -export type Info = Schema.Schema.Type +export const Info = SessionTodo.Info +export type Info = SessionTodo.Info -export const Event = { - Updated: EventV2.define({ - type: "todo.updated", - schema: { - sessionID: SessionID, - todos: Schema.Array(Info), - }, - }), -} +export const Event = SessionTodo.Event export interface Interface { readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 604e046d9547..c425d08dba7b 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,6 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" +import { Info } from "@opencode-ai/schema/file-diff" export const Patch = Schema.Struct({ hash: Schema.String, @@ -16,16 +17,7 @@ export const Patch = Schema.Struct({ }) export type Patch = typeof Patch.Type -export const FileDiff = Schema.Struct({ - // Optional because legacy/imported `summary_diffs` on disk may omit - // file details and patch text. Required Schema rejected the whole - // session response and broke session loading on Desktop. - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), - additions: Schema.Finite, - deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) +export const FileDiff = Info export type FileDiff = typeof FileDiff.Type const prune = "7.days" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 8ee485b8131a..f21a42c6ac9c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -10,7 +10,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import type { ProjectV2 } from "@opencode-ai/core/project" import { Slug } from "@opencode-ai/core/util/slug" import { errorMessage } from "../util/error" -import { EventV2 } from "@opencode-ai/core/event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context } from "effect" @@ -19,22 +18,9 @@ import { NodePath } from "@effect/platform-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" +import { WorktreeEvent } from "@opencode-ai/schema/worktree-event" -export const Event = { - Ready: EventV2.define({ - type: "worktree.ready", - schema: { - name: Schema.String, - branch: Schema.optional(Schema.String), - }, - }), - Failed: EventV2.define({ - type: "worktree.failed", - schema: { - message: Schema.String, - }, - }), -} +export const Event = WorktreeEvent export const Info = Schema.Struct({ name: Schema.String, diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts new file mode 100644 index 000000000000..a655039a3d22 --- /dev/null +++ b/packages/opencode/test/event-manifest.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest" +import { Todo } from "@/session/todo" +import { EventManifest } from "@/event-manifest" + +describe("public event manifest", () => { + test("contains every latest public wire type once", () => { + expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) + expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) + expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) + expect(EventManifest.Latest.size).toBe(85) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + 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) + }) + + test("contains only the current step settlement versions", () => { + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/schema/src/catalog.ts b/packages/schema/src/catalog.ts new file mode 100644 index 000000000000..54abb5b1280b --- /dev/null +++ b/packages/schema/src/catalog.ts @@ -0,0 +1,6 @@ +export * as Catalog from "./catalog" + +import { define, inventory } from "./event" + +const Updated = define({ type: "catalog.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts new file mode 100644 index 000000000000..ed3fb98bb299 --- /dev/null +++ b/packages/schema/src/durable-event-manifest.ts @@ -0,0 +1,10 @@ +export * as DurableEventManifest from "./durable-event-manifest" + +import { Event } from "./event" +import { SessionEvent } from "./session-event" +import { SessionV1 } from "./session-v1" + +export const Durable = Event.durable([ + ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), + ...SessionEvent.DurableDefinitions, +]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts new file mode 100644 index 000000000000..b681362e83be --- /dev/null +++ b/packages/schema/src/event-manifest.ts @@ -0,0 +1,84 @@ +export * as EventManifest from "./event-manifest" + +import { Catalog } from "./catalog" +import { Durable } from "./durable-event-manifest" +import { Event } from "./event" +import { FileSystem } from "./filesystem" +import { FileSystemWatcher } from "./filesystem-watcher" +import { InstallationEvent } from "./installation-event" +import { Integration } from "./integration" +import { LegacyEvent } from "./legacy-event" +import { LspEvent } from "./lsp-event" +import { McpEvent } from "./mcp-event" +import { ModelsDev } from "./models-dev" +import { Permission } from "./permission" +import { PermissionV1 } from "./permission-v1" +import { Plugin } from "./plugin" +import { Project } from "./project" +import { ProjectDirectories } from "./project-directories" +import { Pty } from "./pty" +import { Question } from "./question" +import { QuestionV1 } from "./question-v1" +import { Reference } from "./reference" +import { ServerEvent } from "./server-event" +import { SessionCompactionEvent } from "./session-compaction-event" +import { SessionEvent } from "./session-event" +import { SessionStatusEvent } from "./session-status-event" +import { SessionTodo } from "./session-todo" +import { SessionV1 } from "./session-v1" +import { TuiEvent } from "./tui-event" +import { VcsEvent } from "./vcs-event" +import { WorkspaceEvent } from "./workspace-event" +import { WorktreeEvent } from "./worktree-event" + +const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined) +const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined) + +const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions) + +const foundationDefinitions = Event.inventory( + ...ModelsDev.Event.Definitions, + ...Integration.Event.Definitions, + ...Catalog.Event.Definitions, + ...coreDefinitions, +) + +const featureDefinitions = Event.inventory( + ...FileSystem.Event.Definitions, + ...Reference.Event.Definitions, + ...Permission.Event.Definitions, + ...Plugin.Event.Definitions, + ...ProjectDirectories.Event.Definitions, + ...FileSystemWatcher.Event.Definitions, + ...Pty.Event.Definitions, + ...Question.Event.Definitions, +) + +export const ServerDefinitions = Event.inventory( + ...foundationDefinitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, +) + +export const Definitions = Event.inventory( + ...foundationDefinitions, + ...sessionV1LiveDefinitions, + ...InstallationEvent.Definitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, + ...LspEvent.Definitions, + ...PermissionV1.Event.Definitions, + ...TuiEvent.Definitions, + ...McpEvent.Definitions, + ...LegacyEvent.Definitions, + ...Project.Event.Definitions, + ...SessionStatusEvent.Definitions, + ...QuestionV1.Event.Definitions, + ...SessionCompactionEvent.Definitions, + ...VcsEvent.Definitions, + ...WorkspaceEvent.Definitions, + ...WorktreeEvent.Definitions, + ...ServerEvent.Definitions, +) +export const Latest = Event.latest(Definitions) +export { Durable } diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts new file mode 100644 index 000000000000..58bbd690e6cc --- /dev/null +++ b/packages/schema/src/event.ts @@ -0,0 +1,125 @@ +export * as Event from "./event" + +import { Schema } from "effect" +import { ascending } from "./identifier" +import { Location } from "./location" +import { withStatics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( + Schema.brand("Event.ID"), + withStatics((schema) => ({ create: () => schema.make("evt_" + ascending()) })), +) +export type ID = typeof ID.Type + +export type Definition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = Schema.Top & { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly durable?: { + readonly aggregateID: string + readonly seq: number + readonly version: number + } + readonly location?: Location.Ref + readonly metadata?: Record +} + +export function define< + const Type extends string, + Fields extends Readonly>>, +>(input: { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly schema: Fields +}): Schema.Schema>>> & Definition> { + const data = Schema.Struct(input.schema) + return Object.assign( + Schema.Struct({ + id: ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + durable: Schema.optional( + Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number }), + ), + location: Schema.optional(Location.Ref), + data, + }).annotate({ identifier: input.type }), + { + type: input.type, + ...(input.durable === undefined ? {} : { durable: input.durable }), + data, + }, + ) as Schema.Schema>>> & Definition> +} + +export function inventory>(...definitions: Definitions) { + return Object.freeze(definitions) +} + +export function latest(definitions: ReadonlyArray) { + return readonlyMap( + definitions.reduce((result, definition) => { + const existing = result.get(definition.type) + if (!existing) { + result.set(definition.type, definition) + return result + } + if (definition.durable && existing.durable && definition.durable.version !== existing.durable.version) { + if (definition.durable.version > existing.durable.version) result.set(definition.type, definition) + return result + } + if (definition !== existing) throw new Error(`Duplicate latest event definition for ${definition.type}`) + return result + }, new Map()), + ) +} + +export function versionedType(type: string, version: number) { + return `${type}.${version}` +} + +export function durable(definitions: ReadonlyArray) { + return readonlyMap( + definitions.reduce((result, definition) => { + if (!definition.durable) return result + const key = versionedType(definition.type, definition.durable.version) + if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) + result.set(key, definition) + return result + }, new Map()), + ) +} + +function readonlyMap(map: Map): ReadonlyMap { + const result: ReadonlyMap = Object.freeze({ + get size() { + return map.size + }, + entries: () => map.entries(), + forEach: (callback: (value: Value, key: Key, map: ReadonlyMap) => void, thisArg?: unknown) => + map.forEach((value, key) => callback.call(thisArg, value, key, result)), + get: (key: Key) => map.get(key), + has: (key: Key) => map.has(key), + keys: () => map.keys(), + values: () => map.values(), + [Symbol.iterator]: () => map[Symbol.iterator](), + }) + return result +} diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts new file mode 100644 index 000000000000..26bfd4155b44 --- /dev/null +++ b/packages/schema/src/file-diff.ts @@ -0,0 +1,12 @@ +export * as FileDiff from "./file-diff" + +import { Schema } from "effect" + +export const Info = Schema.Struct({ + file: Schema.optional(Schema.String), + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, + status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), +}).annotate({ identifier: "SnapshotFileDiff" }) +export type Info = typeof Info.Type diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts new file mode 100644 index 000000000000..5e4da777cae8 --- /dev/null +++ b/packages/schema/src/filesystem-watcher.ts @@ -0,0 +1,13 @@ +export * as FileSystemWatcher from "./filesystem-watcher" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +const Updated = define({ + type: "file.watcher.updated", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index f638696e61bc..045f7a7d2ebe 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -1,8 +1,15 @@ export * as FileSystem from "./filesystem" import { Schema } from "effect" +import { define, inventory } from "./event" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" +const Edited = define({ + type: "file.edited", + schema: { file: Schema.String }, +}) +export const Event = { Edited, Definitions: inventory(Edited) } + export interface Entry extends Schema.Schema.Type {} export const Entry = Schema.Struct({ path: RelativePath, diff --git a/packages/schema/src/ide-event.ts b/packages/schema/src/ide-event.ts new file mode 100644 index 000000000000..ca4218602143 --- /dev/null +++ b/packages/schema/src/ide-event.ts @@ -0,0 +1,13 @@ +export * as IdeEvent from "./ide-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Installed = Event.define({ + type: "ide.installed", + schema: { + ide: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Installed) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index e68140d9eead..afeffe069028 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -2,6 +2,7 @@ export { Agent } from "./agent" export { Command } from "./command" export { Connection } from "./connection" export { Credential } from "./credential" +export { Event } from "./event" export { FileSystem } from "./filesystem" export { Integration } from "./integration" export { LLM } from "./llm" diff --git a/packages/schema/src/installation-event.ts b/packages/schema/src/installation-event.ts new file mode 100644 index 000000000000..69ecf6708468 --- /dev/null +++ b/packages/schema/src/installation-event.ts @@ -0,0 +1,20 @@ +export * as InstallationEvent from "./installation-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Updated = Event.define({ + type: "installation.updated", + schema: { + version: Schema.String, + }, +}) + +export const UpdateAvailable = Event.define({ + type: "installation.update-available", + schema: { + version: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Updated, UpdateAvailable) diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index b30b333a4118..78bbbe6602f7 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -1,6 +1,7 @@ export * as Integration from "./integration" import { Schema } from "effect" +import { define, inventory } from "./event" export const ID = Schema.String.pipe(Schema.brand("Integration.ID")) export type ID = typeof ID.Type @@ -72,6 +73,16 @@ export type Method = typeof Method.Type export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) export type Inputs = typeof Inputs.Type +const Updated = define({ + type: "integration.updated", + schema: {}, +}) +const ConnectionUpdated = define({ + type: "integration.connection.updated", + schema: { integrationID: ID }, +}) +export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) } + export interface Ref extends Schema.Schema.Type {} export const Ref = Schema.Struct({ id: ID, diff --git a/packages/schema/src/legacy-event.ts b/packages/schema/src/legacy-event.ts new file mode 100644 index 000000000000..21c46df0e07a --- /dev/null +++ b/packages/schema/src/legacy-event.ts @@ -0,0 +1,18 @@ +export * as LegacyEvent from "./legacy-event" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { SessionID } from "./session-id" +import { SessionV1 } from "./session-v1" + +export const CommandExecuted = define({ + type: "command.executed", + schema: { + name: Schema.String, + sessionID: SessionID, + arguments: Schema.String, + messageID: SessionV1.MessageID, + }, +}) + +export const Definitions = inventory(CommandExecuted) diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index 970ec7f0a3d3..02aa65017b2a 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -2,12 +2,12 @@ export * as Location from "./location" import { Effect, Schema } from "effect" import { AbsolutePath } from "./schema" -import { Workspace } from "./workspace" +import { WorkspaceID } from "./workspace-id" export interface Ref extends Schema.Schema.Type {} export const Ref = Schema.Struct({ directory: AbsolutePath, - workspaceID: Schema.optional(Workspace.ID).pipe( + workspaceID: Schema.optional(WorkspaceID).pipe( Schema.withDecodingDefault(Effect.succeed(undefined)), Schema.withConstructorDefault(Effect.succeed(undefined)), ), diff --git a/packages/schema/src/lsp-event.ts b/packages/schema/src/lsp-event.ts new file mode 100644 index 000000000000..b6908469663a --- /dev/null +++ b/packages/schema/src/lsp-event.ts @@ -0,0 +1,7 @@ +export * as LspEvent from "./lsp-event" + +import { Event } from "./event" + +export const Updated = Event.define({ type: "lsp.updated", schema: {} }) + +export const Definitions = Event.inventory(Updated) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts new file mode 100644 index 000000000000..1d050df92738 --- /dev/null +++ b/packages/schema/src/mcp-event.ts @@ -0,0 +1,21 @@ +export * as McpEvent from "./mcp-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const ToolsChanged = Event.define({ + type: "mcp.tools.changed", + schema: { + server: Schema.String, + }, +}) + +export const BrowserOpenFailed = Event.define({ + type: "mcp.browser.open.failed", + schema: { + mcpName: Schema.String, + url: Schema.String, + }, +}) + +export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts new file mode 100644 index 000000000000..4432bc4591ec --- /dev/null +++ b/packages/schema/src/models-dev.ts @@ -0,0 +1,9 @@ +export * as ModelsDev from "./models-dev" + +import { define, inventory } from "./event" + +const Refreshed = define({ + type: "models-dev.refreshed", + schema: {}, +}) +export const Event = { Refreshed, Definitions: inventory(Refreshed) } diff --git a/packages/schema/src/permission-v1.ts b/packages/schema/src/permission-v1.ts new file mode 100644 index 000000000000..d0db4fa94036 --- /dev/null +++ b/packages/schema/src/permission-v1.ts @@ -0,0 +1,67 @@ +export * as PermissionV1 from "./permission-v1" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { Project } from "./project" +import { withStatics } from "./schema" +import { SessionID } from "./session-id" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({ + identifier: "PermissionRule", +}) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) +export type Ruleset = typeof Ruleset.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })), +}).annotate({ identifier: "PermissionRequest" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]) +export type Reply = typeof Reply.Type + +export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({ + identifier: "PermissionReplyBody", +}) +export type ReplyBody = typeof ReplyBody.Type + +export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({ + identifier: "PermissionApproval", +}) +export type Approval = typeof Approval.Type + +export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({ + identifier: "PermissionAskInput", +}) +export type AskInput = typeof AskInput.Type + +export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({ + identifier: "PermissionReplyInput", +}) +export type ReplyInput = typeof ReplyInput.Type + +const Asked = define({ type: "permission.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: ID, reply: Reply }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } +export const PermissionV1Event = Event diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts index 0c3227335ac8..fc4235a5305e 100644 --- a/packages/schema/src/permission.ts +++ b/packages/schema/src/permission.ts @@ -1,6 +1,54 @@ export * as Permission from "./permission" import { Schema } from "effect" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { withStatics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionV2.ID"), + withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Source = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("tool"), + messageID: Schema.String, + callID: Schema.String, + }), +]).annotate({ identifier: "PermissionV2.Source" }) +export type Source = typeof Source.Type + +const RequestFields = { + sessionID: SessionID, + action: Schema.String, + resources: Schema.Array(Schema.String), + save: Schema.Array(Schema.String).pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + source: Source.pipe(Schema.optional), +} + +export const Request = Schema.Struct({ + id: ID, + ...RequestFields, +}).annotate({ identifier: "PermissionV2.Request" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export type Reply = typeof Reply.Type + +const Asked = define({ type: "permission.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + reply: Reply, + }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) export type Effect = typeof Effect.Type diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts new file mode 100644 index 000000000000..6202bcd64eb5 --- /dev/null +++ b/packages/schema/src/plugin.ts @@ -0,0 +1,15 @@ +export * as Plugin from "./plugin" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export type ID = typeof ID.Type +export const PluginID = ID + +const Added = define({ + type: "plugin.added", + schema: { id: ID }, +}) +export const Event = { Added, Definitions: inventory(Added) } +export const PluginEvent = Event diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts new file mode 100644 index 000000000000..a589a6bd3cd6 --- /dev/null +++ b/packages/schema/src/project-directories.ts @@ -0,0 +1,11 @@ +export * as ProjectDirectories from "./project-directories" + +import { define, inventory } from "./event" +import { Project } from "./project" + +const Updated = define({ + type: "project.directories.updated", + schema: { projectID: Project.ID }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } +export const ProjectDirectoriesEvent = Event diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 3a957a034710..dd2ae6554132 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -1,10 +1,43 @@ export * as Project from "./project" import { Schema } from "effect" -import { withStatics } from "./schema" +import { define, inventory } from "./event" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "./schema" export const ID = Schema.String.pipe( Schema.brand("Project.ID"), withStatics((schema) => ({ global: schema.make("global") })), ) export type ID = typeof ID.Type + +export const Vcs = Schema.Literal("git") +export const Icon = Schema.Struct({ + url: optionalOmitUndefined(Schema.String), + override: optionalOmitUndefined(Schema.String), + color: optionalOmitUndefined(Schema.String), +}) +export const Commands = Schema.Struct({ + start: optionalOmitUndefined( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), +}) +export const Time = Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: optionalOmitUndefined(NonNegativeInt), +}) + +export const Info = Schema.Struct({ + id: ID, + worktree: Schema.String, + vcs: optionalOmitUndefined(Vcs), + name: optionalOmitUndefined(Schema.String), + icon: optionalOmitUndefined(Icon), + commands: optionalOmitUndefined(Commands), + time: Time, + sandboxes: Schema.Array(Schema.String), +}).annotate({ identifier: "Project" }) +export type Info = typeof Info.Type + +const Updated = define({ type: "project.updated", schema: Info.fields }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts new file mode 100644 index 000000000000..1836d9da4460 --- /dev/null +++ b/packages/schema/src/pty.ts @@ -0,0 +1,35 @@ +export * as Pty from "./pty" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { NonNegativeInt } from "./schema" +import { withStatics } from "./schema" + +const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) + +export const ID = IDSchema.pipe( + withStatics((schema: typeof IDSchema) => ({ + ascending: (id?: string) => schema.make(id ?? "pty_" + ascending()), + })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + title: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + status: Schema.Literals(["running", "exited"]), + pid: NonNegativeInt, + exitCode: Schema.optional(NonNegativeInt), +}).annotate({ identifier: "Pty" }) +export const PtyInfo = Info + +const Created = define({ type: "pty.created", schema: { info: Info } }) +const Updated = define({ type: "pty.updated", schema: { info: Info } }) +const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) +const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) +export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } +export const PtyEvent = Event diff --git a/packages/schema/src/question-v1.ts b/packages/schema/src/question-v1.ts new file mode 100644 index 000000000000..cea377917ee0 --- /dev/null +++ b/packages/schema/src/question-v1.ts @@ -0,0 +1,66 @@ +export * as QuestionV1 from "./question-v1" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { withStatics } from "./schema" +import { SessionID } from "./session-id" +import { SessionV1 } from "./session-v1" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), +) + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionOption" }) + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }), +}).annotate({ identifier: "QuestionInfo" }) +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) +export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({ + identifier: "QuestionTool", +}) +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Schema.optional(Tool), +}).annotate({ identifier: "QuestionRequest" }) +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionReply" }) +export const Replied = Schema.Struct({ + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), +}).annotate({ + identifier: "QuestionReplied", +}) +export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({ + identifier: "QuestionRejected", +}) + +const Asked = define({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +export const Event = { + Asked, + Replied: RepliedEvent, + Rejected: RejectedEvent, + Definitions: inventory(Asked, RepliedEvent, RejectedEvent), +} diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts new file mode 100644 index 000000000000..3794e811ef2a --- /dev/null +++ b/packages/schema/src/question.ts @@ -0,0 +1,79 @@ +export * as Question from "./question" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { withStatics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionV2.ID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionV2.Option" }) +export type Option = typeof Option.Type + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Allow typing a custom answer (default: true)", + }), +}).annotate({ identifier: "QuestionV2.Info" }) +export type Info = typeof Info.Type + +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export type Prompt = typeof Prompt.Type + +export const Tool = Schema.Struct({ + messageID: Schema.String, + callID: Schema.String, +}).annotate({ identifier: "QuestionV2.Tool" }) +export type Tool = typeof Tool.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Tool.pipe(Schema.optional), +}).annotate({ identifier: "QuestionV2.Request" }) +export type Request = typeof Request.Type + +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export type Answer = typeof Answer.Type + +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionV2.Reply" }) +export type Reply = typeof Reply.Type + +const Asked = define({ type: "question.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "question.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), + }, +}) +const Rejected = define({ + type: "question.v2.rejected", + schema: { + sessionID: SessionID, + requestID: ID, + }, +}) +export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) } diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts index b0a61cee42d8..054139101685 100644 --- a/packages/schema/src/reference.ts +++ b/packages/schema/src/reference.ts @@ -1,8 +1,12 @@ export * as Reference from "./reference" import { Schema } from "effect" +import { define, inventory } from "./event" import { AbsolutePath } from "./schema" +const Updated = define({ type: "reference.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } + export interface LocalSource extends Schema.Schema.Type {} export const LocalSource = Schema.Struct({ type: Schema.Literal("local"), diff --git a/packages/schema/src/server-event.ts b/packages/schema/src/server-event.ts new file mode 100644 index 000000000000..9d8ac2d470ff --- /dev/null +++ b/packages/schema/src/server-event.ts @@ -0,0 +1,8 @@ +export * as ServerEvent from "./server-event" + +import { Event } from "./event" + +export const Connected = Event.define({ type: "server.connected", schema: {} }) +export const Disposed = Event.define({ type: "global.disposed", schema: {} }) + +export const Definitions = Event.inventory(Connected, Disposed) diff --git a/packages/schema/src/session-compaction-event.ts b/packages/schema/src/session-compaction-event.ts new file mode 100644 index 000000000000..ed1169ea6757 --- /dev/null +++ b/packages/schema/src/session-compaction-event.ts @@ -0,0 +1,13 @@ +export * as SessionCompactionEvent from "./session-compaction-event" + +import { Event } from "./event" +import { SessionID } from "./session-id" + +export const Compacted = Event.define({ + type: "session.compacted", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Compacted) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts new file mode 100644 index 000000000000..e7ae782f25ad --- /dev/null +++ b/packages/schema/src/session-event.ts @@ -0,0 +1,497 @@ +export * as SessionEvent from "./session-event" + +import { Schema } from "effect" +import { Event } from "./event" +import { ProviderMetadata, ToolContent } from "./llm" +import { Delivery } from "./session-delivery" +import { Model } from "./model" +import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema" +import { FileAttachment, Prompt } from "./prompt" +import { SessionID } from "./session-id" +import { Location } from "./location" +import { SessionMessageID } from "./session-message-id" +import { SessionMessage } from "./session-message" + +export { FileAttachment } + +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export type Source = typeof Source.Type + +const Base = { + timestamp: DateTimeUtcFromMillis, + sessionID: SessionID, +} +const PromptFields = { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + delivery: Delivery, +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const +const stepSettlementOptions = { + durable: { + aggregate: "sessionID", + version: 2, + }, +} as const + +export const UnknownError = SessionMessage.UnknownError +export type UnknownError = SessionMessage.UnknownError + +export const AgentSwitched = Event.define({ + type: "session.next.agent.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + agent: Schema.String, + }, +}) +export type AgentSwitched = typeof AgentSwitched.Type + +export const ModelSwitched = Event.define({ + type: "session.next.model.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + model: Model.Ref, + }, +}) +export type ModelSwitched = typeof ModelSwitched.Type + +export const Moved = Event.define({ + type: "session.next.moved", + ...options, + schema: { + ...Base, + location: Location.Ref, + subdirectory: RelativePath.pipe(Schema.optional), + }, +}) +export type Moved = typeof Moved.Type + +export const Prompted = Event.define({ + type: "session.next.prompted", + ...options, + schema: PromptFields, +}) +export type Prompted = typeof Prompted.Type + +export const PromptAdmitted = Event.define({ + type: "session.next.prompt.admitted", + ...options, + schema: PromptFields, +}) +export type PromptAdmitted = typeof PromptAdmitted.Type + +export const ContextUpdated = Event.define({ + type: "session.next.context.updated", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + text: Schema.String, + }, +}) +export type ContextUpdated = typeof ContextUpdated.Type + +export const Synthetic = Event.define({ + type: "session.next.synthetic", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + text: Schema.String, + }, +}) +export type Synthetic = typeof Synthetic.Type + +export namespace Shell { + export const Started = Event.define({ + type: "session.next.shell.started", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.shell.ended", + ...options, + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Step { + export const Started = Event.define({ + type: "session.next.step.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + agent: Schema.String, + model: Model.Ref, + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.step.ended", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + finish: Schema.String, + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = typeof Ended.Type + + export const Failed = Event.define({ + type: "session.next.step.failed", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + error: UnknownError, + }, + }) + export type Failed = typeof Failed.Type +} + +export namespace Text { + export const Started = Event.define({ + type: "session.next.text.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + textID: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.text.delta", + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + textID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.text.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + textID: Schema.String, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Reasoning { + export const Started = Event.define({ + type: "session.next.reasoning.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + reasoningID: Schema.String, + providerMetadata: ProviderMetadata.pipe(Schema.optional), + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.reasoning.delta", + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + reasoningID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.reasoning.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessageID.ID, + reasoningID: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(Schema.optional), + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Tool { + const ToolBase = { + ...Base, + assistantMessageID: SessionMessageID.ID, + callID: Schema.String, + } + + export namespace Input { + export const Started = Event.define({ + type: "session.next.tool.input.started", + ...options, + schema: { + ...ToolBase, + name: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. + export const Delta = Event.define({ + type: "session.next.tool.input.delta", + schema: { + ...ToolBase, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.tool.input.ended", + ...options, + schema: { + ...ToolBase, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type + } + + export const Called = Event.define({ + type: "session.next.tool.called", + ...options, + schema: { + ...ToolBase, + tool: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(Schema.optional), + }), + }, + }) + export type Called = typeof Called.Type + + /** + * Replayable bounded running-tool state. Tools should checkpoint semantic + * transitions or at a bounded cadence, not persist every stdout/stderr chunk. + */ + export const Progress = Event.define({ + type: "session.next.tool.progress", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Any), + content: Schema.Array(ToolContent), + }, + }) + export type Progress = typeof Progress.Type + + export const Success = Event.define({ + type: "session.next.tool.success", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Any), + content: Schema.Array(ToolContent), + outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), + result: Schema.Unknown.pipe(Schema.optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(Schema.optional), + }), + }, + }) + export type Success = typeof Success.Type + + export const Failed = Event.define({ + type: "session.next.tool.failed", + ...options, + schema: { + ...ToolBase, + error: UnknownError, + result: Schema.Unknown.pipe(Schema.optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(Schema.optional), + }), + }, + }) + export type Failed = typeof Failed.Type +} + +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: Schema.Finite.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export type RetryError = typeof RetryError.Type + +export const Retried = Event.define({ + type: "session.next.retried", + ...options, + schema: { + ...Base, + attempt: Schema.Finite, + error: RetryError, + }, +}) +export type Retried = typeof Retried.Type + +export namespace Compaction { + export const Started = Event.define({ + type: "session.next.compaction.started", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + }, + }) + export type Started = typeof Started.Type + + export const Delta = Event.define({ + type: "session.next.compaction.delta", + schema: { + ...Base, + messageID: SessionMessageID.ID, + text: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.compaction.ended", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + reason: Started.data.fields.reason, + text: Schema.String, + recent: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export const DurableDefinitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Ended, + Tool.Input.Started, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Reasoning.Started, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Ended, +) + +export const Definitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Delta, + Text.Ended, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, +) + +export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export type DurableEvent = typeof Durable.Type + +export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export type Event = typeof All.Type +export type Type = Event["type"] diff --git a/packages/schema/src/session-id.ts b/packages/schema/src/session-id.ts index 8193f5728c8a..747413e03052 100644 --- a/packages/schema/src/session-id.ts +++ b/packages/schema/src/session-id.ts @@ -1,10 +1,8 @@ -export * as SessionID from "./session-id" - import { Schema } from "effect" import { descending } from "./identifier" import { withStatics } from "./schema" -export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((schema) => { const create = () => schema.make("ses_" + descending()) @@ -14,4 +12,4 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( } }), ) -export type ID = typeof ID.Type +export type SessionID = typeof SessionID.Type diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index 13d795924988..55010c19161c 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -14,7 +14,7 @@ export interface Admitted extends Schema.Schema.Type {} export const Admitted = Schema.Struct({ admittedSeq: NonNegativeInt, id: SessionMessageID.ID, - sessionID: SessionID.ID, + sessionID: SessionID, prompt: Prompt, delivery: Delivery, timeCreated: DateTimeUtcFromMillis, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 806922332d4b..3631c3072cc3 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -49,7 +49,7 @@ export const User = Schema.Struct({ export interface Synthetic extends Schema.Schema.Type {} export const Synthetic = Schema.Struct({ ...Base, - sessionID: SessionID.ID, + sessionID: SessionID, text: Schema.String, type: Schema.Literal("synthetic"), }).annotate({ identifier: "Session.Message.Synthetic" }) diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts new file mode 100644 index 000000000000..5233f97edb06 --- /dev/null +++ b/packages/schema/src/session-status-event.ts @@ -0,0 +1,50 @@ +export * as SessionStatusEvent from "./session-status-event" + +import { Schema } from "effect" +import { Event } from "./event" +import { NonNegativeInt } from "./schema" +import { SessionID } from "./session-id" + +export const Info = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("idle"), + }), + Schema.Struct({ + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + message: Schema.String, + action: Schema.optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: Schema.optional(Schema.String), + }), + ), + next: NonNegativeInt, + }), + Schema.Struct({ + type: Schema.Literal("busy"), + }), +]).annotate({ identifier: "SessionStatus" }) +export type Info = Schema.Schema.Type + +export const Status = Event.define({ + type: "session.status", + schema: { + sessionID: SessionID, + status: Info, + }, +}) + +// deprecated +export const Idle = Event.define({ + type: "session.idle", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Status, Idle) diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts new file mode 100644 index 000000000000..cf62c5186cb9 --- /dev/null +++ b/packages/schema/src/session-todo.ts @@ -0,0 +1,24 @@ +export * as SessionTodo from "./session-todo" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { SessionID } from "./session-id" + +export const Info = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ + description: "Current status of the task: pending, in_progress, completed, cancelled", + }), + priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), +}).annotate({ identifier: "Todo" }) +export type Info = typeof Info.Type +export const SessionTodoInfo = Info + +const Updated = define({ + type: "todo.updated", + schema: { + sessionID: SessionID, + todos: Schema.Array(Info), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/session-v1.ts b/packages/schema/src/session-v1.ts new file mode 100644 index 000000000000..6892041fc552 --- /dev/null +++ b/packages/schema/src/session-v1.ts @@ -0,0 +1,676 @@ +export * as SessionV1 from "./session-v1" + +import { Effect, Schema, Types } from "effect" +import { define, inventory } from "./event" +import { FileDiff } from "./file-diff" +import { PermissionV1 } from "./permission-v1" +import { Project } from "./project" +import { Provider } from "./provider" +import { Model } from "./model" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "./schema" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { WorkspaceID } from "./workspace-id" + +const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })), +) +export type PartID = typeof PartID.Type + +const namedError = (name: Name, fields: Fields) => { + const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({ + identifier: name, + }) + return { Schema: schema, EffectSchema: schema } +} + +export const OutputLengthError = namedError("MessageOutputLengthError", {}) + +export const AuthError = namedError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const AbortedError = namedError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedError("StructuredOutputError", { + message: Schema.String, + retries: NonNegativeInt, +}) +export const APIError = namedError("APIError", { + message: Schema.String, + statusCode: Schema.optional(NonNegativeInt), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type APIError = Schema.Schema.Type +export const ContextOverflowError = namedError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), +}) +export const ContentFilterError = namedError("ContentFilterError", { + message: Schema.String, +}) + +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) {} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) {} + +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", +}) +export type OutputFormat = Schema.Schema.Type + +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, +}).annotate({ identifier: "SnapshotPart" }) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}).annotate({ identifier: "PatchPart" }) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPart" }) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), +}).annotate({ identifier: "ReasoningPart" }) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Finite, + end: Schema.Finite, + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const Range = Schema.Struct({ + start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), + end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "FileSource" }) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, + range: Range, + name: Schema.String, + kind: NonNegativeInt, +}).annotate({ identifier: "SymbolSource" }) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "ResourceSource" }) + +export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", +}) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePart" }) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPart" }) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), +}).annotate({ identifier: "CompactionPart" }) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPart" }) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + error: APIError.EffectSchema, + time: Schema.Struct({ + created: NonNegativeInt, + }), +}).annotate({ identifier: "RetryPart" }) +export type RetryPart = Omit>, "error"> & { + error: APIError +} + +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), +}).annotate({ identifier: "StepStartPart" }) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), +}).annotate({ identifier: "StepFinishPart" }) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}).annotate({ identifier: "ToolStatePending" }) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateRunning" }) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + compacted: Schema.optional(NonNegativeInt), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}).annotate({ identifier: "ToolStateCompleted" }) +export type ToolStateCompleted = Types.DeepMutable> + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateError" }) +export type ToolStateError = Types.DeepMutable> + +export const ToolState = Schema.Union([ + ToolStatePending, + ToolStateRunning, + ToolStateCompleted, + ToolStateError, +]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "ToolPart" }) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: partBase.sessionID, +} + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Timestamp, + }), + format: Schema.optional(Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(FileDiff.Info), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), +}).annotate({ identifier: "UserMessage" }) +export type User = Types.DeepMutable> + +export const Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema, + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + ContentFilterError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPartInput" }) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePartInput" }) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPartInput" }) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPartInput" }) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), + }), + error: Schema.optional(AssistantErrorSchema), + parentID: MessageID, + modelID: Model.ID, + providerID: Provider.ID, + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), +}).annotate({ identifier: "AssistantMessage" }) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} + +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export type Info = User | Assistant + +export const WithParts = Schema.Struct({ + info: Info, + parts: Schema.Array(Part), +}) +export type WithParts = { + info: Info + parts: Part[] +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const + +const SessionSummary = Schema.Struct({ + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, + diffs: optionalOmitUndefined(Schema.Array(FileDiff.Info)), +}) + +const SessionTokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const SessionShare = Schema.Struct({ + url: Schema.String, +}) + +const SessionRevert = Schema.Struct({ + messageID: MessageID, + partID: optionalOmitUndefined(PartID), + snapshot: optionalOmitUndefined(Schema.String), + diff: optionalOmitUndefined(Schema.String), +}) + +const SessionModel = Schema.Struct({ + id: Model.ID, + providerID: Provider.ID, + variant: optionalOmitUndefined(Schema.String), +}) + +export const SessionInfo = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: Project.ID, + workspaceID: optionalOmitUndefined(WorkspaceID), + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(SessionSummary), + cost: optionalOmitUndefined(Schema.Finite), + tokens: optionalOmitUndefined(SessionTokens), + share: optionalOmitUndefined(SessionShare), + title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(SessionModel), + version: Schema.String, + metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(Schema.Finite), + }), + permission: optionalOmitUndefined(PermissionV1.Ruleset), + revert: optionalOmitUndefined(SessionRevert), +}).annotate({ identifier: "Session" }) +export type SessionInfo = typeof SessionInfo.Type + +const events = { + Created: define({ + type: "session.created", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Updated: define({ + type: "session.updated", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Deleted: define({ + type: "session.deleted", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + MessageUpdated: define({ + type: "message.updated", + ...options, + schema: { + sessionID: SessionID, + info: Info, + }, + }), + MessageRemoved: define({ + type: "message.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + }, + }), + PartUpdated: define({ + type: "message.part.updated", + ...options, + schema: { + sessionID: SessionID, + part: Part, + time: Schema.Finite, + }, + }), + PartRemoved: define({ + type: "message.part.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + }, + }), +} + +export const PartDelta = define({ + type: "message.part.delta", + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, + }, +}) + +export const Diff = define({ + type: "session.diff", + schema: { + sessionID: SessionID, + diff: Schema.Array(FileDiff.Info), + }, +}) + +export const Error = define({ + type: "session.error", + schema: { + sessionID: Schema.optional(SessionID), + error: Assistant.fields.error, + }, +}) + +export const Event = { + ...events, + PartDelta, + Diff, + Error, + Definitions: inventory( + events.Created, + events.Updated, + events.Deleted, + events.MessageUpdated, + events.MessageRemoved, + events.PartUpdated, + events.PartRemoved, + PartDelta, + Diff, + Error, + ), +} diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 7e0e16c75506..2bed421d2959 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -6,10 +6,13 @@ import { Location } from "./location" import { Model } from "./model" import { Project } from "./project" import { DateTimeUtcFromMillis, optionalOmitUndefined, RelativePath } from "./schema" +import { SessionEvent } from "./session-event" import { SessionID } from "./session-id" -export const ID = SessionID.ID -export type ID = SessionID.ID +export const ID = SessionID +export type ID = SessionID + +export const Event = SessionEvent export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts new file mode 100644 index 000000000000..41fa4dbeb6c0 --- /dev/null +++ b/packages/schema/src/tui-event.ts @@ -0,0 +1,58 @@ +export * as TuiEvent from "./tui-event" + +import { Effect, Schema } from "effect" +import { Event } from "./event" +import { PositiveInt } from "./schema" +import { SessionID } from "./session-id" + +const DEFAULT_TOAST_DURATION = 5000 + +export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } }) + +export const CommandExecute = Event.define({ + type: "tui.command.execute", + schema: { + command: Schema.Union([ + Schema.Literals([ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle", + ]), + Schema.String, + ]), + }, +}) + +export const ToastShow = Event.define({ + type: "tui.toast.show", + schema: { + title: Schema.optional(Schema.String), + message: Schema.String, + variant: Schema.Literals(["info", "success", "warning", "error"]), + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + description: "Duration in milliseconds", + }), + }, +}) + +export const SessionSelect = Event.define({ + type: "tui.session.select", + schema: { + sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), + }, +}) + +export const Definitions = Event.inventory(PromptAppend, CommandExecute, ToastShow, SessionSelect) diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts new file mode 100644 index 000000000000..ac1f74b98bea --- /dev/null +++ b/packages/schema/src/vcs-event.ts @@ -0,0 +1,13 @@ +export * as VcsEvent from "./vcs-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const BranchUpdated = Event.define({ + type: "vcs.branch.updated", + schema: { + branch: Schema.optional(Schema.String), + }, +}) + +export const Definitions = Event.inventory(BranchUpdated) diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts new file mode 100644 index 000000000000..687af2cc7cb9 --- /dev/null +++ b/packages/schema/src/workspace-event.ts @@ -0,0 +1,32 @@ +export * as WorkspaceEvent from "./workspace-event" + +import { Schema } from "effect" +import { Event } from "./event" +import { WorkspaceID } from "./workspace-id" + +export const ConnectionStatus = Schema.Struct({ + workspaceID: WorkspaceID, + status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), +}) +export type ConnectionStatus = typeof ConnectionStatus.Type + +export const Ready = Event.define({ + type: "workspace.ready", + schema: { + name: Schema.String, + }, +}) + +export const Failed = Event.define({ + type: "workspace.failed", + schema: { + message: Schema.String, + }, +}) + +export const Status = Event.define({ + type: "workspace.status", + schema: ConnectionStatus.fields, +}) + +export const Definitions = Event.inventory(Ready, Failed, Status) diff --git a/packages/schema/src/workspace-id.ts b/packages/schema/src/workspace-id.ts new file mode 100644 index 000000000000..e69a562ffccf --- /dev/null +++ b/packages/schema/src/workspace-id.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect" +import { ascending } from "./identifier" +import { withStatics } from "./schema" + +export const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( + Schema.brand("WorkspaceV2.ID"), + withStatics((schema) => { + const create = () => schema.make("wrk_" + ascending()) + return { + ascending: (id?: string) => { + if (!id) return create() + if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) + return schema.make(id) + }, + create, + } + }), +) +export type WorkspaceID = typeof WorkspaceID.Type diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index c2a7d1a6f943..ce35bf3b2452 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -1,21 +1,9 @@ export * as Workspace from "./workspace" -import { Schema } from "effect" -import { ascending } from "./identifier" -import { withStatics } from "./schema" +import { WorkspaceEvent } from "./workspace-event" +import { WorkspaceID } from "./workspace-id" -export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( - Schema.brand("WorkspaceV2.ID"), - withStatics((schema) => { - const create = () => schema.make("wrk_" + ascending()) - return { - ascending: (id?: string) => { - if (!id) return create() - if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) - return schema.make(id) - }, - create, - } - }), -) -export type ID = typeof ID.Type +export const ID = WorkspaceID +export type ID = WorkspaceID + +export const Event = WorkspaceEvent diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts new file mode 100644 index 000000000000..809fe8d9fe36 --- /dev/null +++ b/packages/schema/src/worktree-event.ts @@ -0,0 +1,21 @@ +export * as WorktreeEvent from "./worktree-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Ready = Event.define({ + type: "worktree.ready", + schema: { + name: Schema.String, + branch: Schema.optional(Schema.String), + }, +}) + +export const Failed = Event.define({ + type: "worktree.failed", + schema: { + message: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Ready, Failed) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts new file mode 100644 index 000000000000..5694afdd30df --- /dev/null +++ b/packages/schema/test/event-manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" +import { EventManifest } from "../src/event-manifest" +import { IdeEvent } from "../src/ide-event" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { SessionV1 } from "../src/session-v1" +import { WorkspaceEvent } from "../src/workspace-event" + +describe("public event manifest", () => { + test("owns the complete public event surface", () => { + expect(EventManifest.ServerDefinitions.length).toBe(55) + expect(EventManifest.Definitions.length).toBe(85) + expect(SessionV1.Event.Definitions).toEqual([ + SessionV1.Event.Created, + SessionV1.Event.Updated, + SessionV1.Event.Deleted, + SessionV1.Event.MessageUpdated, + SessionV1.Event.MessageRemoved, + SessionV1.Event.PartUpdated, + SessionV1.Event.PartRemoved, + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Latest.size).toBe(85) + expect(EventManifest.Durable.size).toBe(32) + }) + + test("uses canonical definitions for current public events", () => { + expect(Session.Event).toBe(SessionEvent) + expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) + expect(Workspace.Event).toBe(WorkspaceEvent) + expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) + expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) + expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) + 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(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) + expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/schema/test/event.test.ts b/packages/schema/test/event.test.ts new file mode 100644 index 000000000000..380faa5a4abd --- /dev/null +++ b/packages/schema/test/event.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Event } from "../src/event" + +describe("public event schemas", () => { + test("definition is pure", () => { + const definitions = Event.inventory() + Event.define({ type: "test.pure", schema: { value: Schema.String } }) + expect(definitions).toEqual([]) + }) + + test("latest selection is independent of declaration order", () => { + const historical = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + const current = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 2 }, + schema: { id: Schema.String, value: Schema.String }, + }) + + expect(Event.latest([historical, current]).get(current.type)).toBe(current) + expect(Event.latest([current, historical]).get(current.type)).toBe(current) + }) + + test("durable definitions are indexed by type and version", () => { + const definition = Event.define({ + type: "test.durable", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + + expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) + }) +}) diff --git a/packages/schema/test/legacy-event.test.ts b/packages/schema/test/legacy-event.test.ts new file mode 100644 index 000000000000..e43c5681f201 --- /dev/null +++ b/packages/schema/test/legacy-event.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { Project } from "../src/project" +import { SessionV1 } from "../src/session-v1" + +describe("legacy public event schemas", () => { + test("owns all SessionV1 definitions", () => { + expect(SessionV1.Event.Definitions.map((event) => event.type)).toEqual([ + "session.created", + "session.updated", + "session.deleted", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.removed", + "message.part.delta", + "session.diff", + "session.error", + ]) + const durable = SessionV1.Event.Definitions.filter((event) => event.durable !== undefined) + expect(durable).toHaveLength(7) + expect(durable.every((event) => event.durable?.aggregate === "sessionID")).toBe(true) + expect(durable.every((event) => event.durable?.version === 1)).toBe(true) + }) + + test("owns the legacy transient public definitions", () => { + expect([ + SessionV1.PartDelta.type, + SessionV1.Diff.type, + SessionV1.Error.type, + PermissionV1.Event.Asked.type, + PermissionV1.Event.Replied.type, + QuestionV1.Event.Asked.type, + QuestionV1.Event.Replied.type, + QuestionV1.Event.Rejected.type, + Project.Event.Updated.type, + LegacyEvent.CommandExecuted.type, + ]).toEqual([ + "message.part.delta", + "session.diff", + "session.error", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "project.updated", + "command.executed", + ]) + }) +}) diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 42573e06484f..a404d53f687d 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,4 +1,4 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { SchemaErrorMiddleware } from "./middleware/schema-error" import { MessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" @@ -8,7 +8,8 @@ import { PermissionGroup } from "./groups/permission" import { FileSystemGroup } from "./groups/fs" import { CommandGroup } from "./groups/command" import { SkillGroup } from "./groups/skill" -import { EventGroup } from "./groups/event" +import { EventGroup, makeEventGroup } from "./groups/event" +import type { Definition } from "@opencode-ai/core/event" import { AgentGroup } from "./groups/agent" import { HealthGroup } from "./groups/health" import { PtyGroup } from "./groups/pty" @@ -20,31 +21,36 @@ import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" import { ProjectCopyGroup } from "./groups/project-copy" -export const Api = HttpApi.make("server") - .add(HealthGroup) - .add(LocationGroup) - .add(AgentGroup) - .add(SessionGroup) - .add(MessageGroup) - .add(ModelGroup) - .add(ProviderGroup) - .add(IntegrationGroup) - .add(CredentialGroup) - .add(PermissionGroup) - .add(FileSystemGroup) - .add(CommandGroup) - .add(SkillGroup) - .add(EventGroup) - .add(PtyGroup) - .add(QuestionGroup) - .add(ReferenceGroup) - .add(ProjectCopyGroup) - .annotateMerge( - OpenApi.annotations({ - title: "opencode HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - .middleware(Authorization) - .middleware(SchemaErrorMiddleware) +const makeApiFromGroup = (eventGroup: Group) => + HttpApi.make("server") + .add(HealthGroup) + .add(LocationGroup) + .add(AgentGroup) + .add(SessionGroup) + .add(MessageGroup) + .add(ModelGroup) + .add(ProviderGroup) + .add(IntegrationGroup) + .add(CredentialGroup) + .add(PermissionGroup) + .add(FileSystemGroup) + .add(CommandGroup) + .add(SkillGroup) + .add(eventGroup) + .add(PtyGroup) + .add(QuestionGroup) + .add(ReferenceGroup) + .add(ProjectCopyGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(Authorization) + .middleware(SchemaErrorMiddleware) + +export const makeApi = (definitions: ReadonlyArray) => makeApiFromGroup(makeEventGroup(definitions)) + +export const Api = makeApiFromGroup(EventGroup) diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts index fffa0250d2ee..7e8c02902047 100644 --- a/packages/server/src/groups/event.ts +++ b/packages/server/src/groups/event.ts @@ -1,5 +1,7 @@ import { EventV2 } from "@opencode-ai/core/event" +import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest" import { Location } from "@opencode-ai/core/location" +import type { Definition } from "@opencode-ai/core/event" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -10,25 +12,47 @@ const fields = { location: Schema.optional(Location.Ref), } -const Event = Schema.Union([ - ...EventV2.definitions().map((definition) => - Schema.Struct({ - ...fields, - type: Schema.Literal(definition.type), - data: definition.data as Schema.Struct<{}>, - }).annotate({ identifier: `V2Event.${definition.type}` }), - ), - Schema.Struct({ - ...fields, - type: Schema.Literal("server.connected"), - data: Schema.Struct({}), - }).annotate({ identifier: "V2Event.server.connected" }), -]).annotate({ identifier: "V2Event" }) +const schema = >(definitions: Definitions) => + Schema.Union([ + ...definitions.map((definition) => + Schema.Struct({ + ...fields, + type: Schema.Literal(definition.type), + data: definition.data, + }).annotate({ identifier: `V2Event.${definition.type}` }), + ), + ...(definitions.some((definition) => definition.type === "server.connected") + ? [] + : [ + Schema.Struct({ + ...fields, + type: Schema.Literal("server.connected"), + data: Schema.Struct({}), + }).annotate({ identifier: "V2Event.server.connected" }), + ]), + ]).annotate({ identifier: "V2Event" }) + +export const makeEventGroup = >(definitions: Definitions) => + HttpApiGroup.make("server.event") + .add( + HttpApiEndpoint.get("event.subscribe", "/api/event", { + success: schema(definitions), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.event.subscribe", + summary: "Subscribe to events", + description: "Subscribe to native event payloads for the server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })) + +const EventSchema = schema(PublicEventManifest.Definitions) export const EventGroup = HttpApiGroup.make("server.event") .add( HttpApiEndpoint.get("event.subscribe", "/api/event", { - success: Event, + success: EventSchema, }).annotateMerge( OpenApi.annotations({ identifier: "v2.event.subscribe", @@ -38,5 +62,4 @@ export const EventGroup = HttpApiGroup.make("server.event") ), ) .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })) - -export type Event = typeof Event.Type +export type Event = typeof EventSchema.Type