diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 926ab7d36b..3d6a0d91d0 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { zod } from "@/util/effect-zod" +import { NonNegativeInt } from "@/util/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -14,7 +15,7 @@ export class Oauth extends Schema.Class("OAuth")({ type: Schema.Literal("oauth"), refresh: Schema.String, access: Schema.String, - expires: Schema.Finite, + expires: NonNegativeInt, accountId: Schema.optional(Schema.String), enterpriseUrl: Schema.optional(Schema.String), }) {} diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 01fb4535ad..fbe5ce7f9f 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,5 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" +import { PositiveInt } from "@/util/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 @@ -38,7 +39,7 @@ export const TuiEvent = { title: Schema.optional(Schema.String), message: Schema.String, variant: Schema.Literals(["info", "success", "warning", "error"]), - duration: Schema.Finite.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ description: "Duration in milliseconds", }), }), diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 08668afe4e..0d4f20df91 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,10 +1,11 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { NonNegativeInt } from "@/util/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optional(Schema.String), - switchableOrgCount: Schema.Number, + switchableOrgCount: NonNegativeInt, }) { static readonly zod = zod(this) } diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 0fa810019c..fc31ba356f 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { PositiveInt, withStatics } from "@/util/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), @@ -13,7 +13,7 @@ export const Local = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable the MCP server on startup", }), - timeout: Schema.optional(Schema.Finite).annotate({ + timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) @@ -49,7 +49,7 @@ export const Remote = Schema.Struct({ oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", }), - timeout: Schema.optional(Schema.Finite).annotate({ + timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index cd7469435c..7821bca5a9 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -21,25 +21,25 @@ export const Model = Schema.Struct({ ), cost: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), }), ), }), ), limit: Schema.optional( Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }), ), modalities: Schema.optional( diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 122add21fa..4a474881cb 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" import { zod } from "@/util/effect-zod" -import { type DeepMutable, withStatics } from "@/util/schema" +import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" export const Info = Schema.Struct({ path: Schema.String, - added: Schema.Int, - removed: Schema.Int, + added: NonNegativeInt, + removed: NonNegativeInt, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "File" }) @@ -39,10 +39,10 @@ export const Node = Schema.Struct({ export type Node = DeepMutable> const Hunk = Schema.Struct({ - oldStart: Schema.Number, - oldLines: Schema.Number, - newStart: Schema.Number, - newLines: Schema.Number, + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, lines: Schema.Array(Schema.String), }) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 3a5411c31e..27fd5f2323 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" @@ -27,19 +27,19 @@ const PLATFORM = { } as const const TimeStats = Schema.Struct({ - secs: Schema.Number, - nanos: Schema.Number, + secs: NonNegativeInt, + nanos: NonNegativeInt, human: Schema.String, }) const Stats = Schema.Struct({ elapsed: TimeStats, - searches: Schema.Number, - searches_with_match: Schema.Number, - bytes_searched: Schema.Number, - bytes_printed: Schema.Number, - matched_lines: Schema.Number, - matches: Schema.Number, + searches: NonNegativeInt, + searches_with_match: NonNegativeInt, + bytes_searched: NonNegativeInt, + bytes_printed: NonNegativeInt, + matched_lines: NonNegativeInt, + matches: NonNegativeInt, }) const PathText = Schema.Struct({ @@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({ lines: Schema.Struct({ text: Schema.String, }), - line_number: Schema.Number, - absolute_offset: Schema.Number, + line_number: NonNegativeInt, + absolute_offset: NonNegativeInt, submatches: Schema.Array( Schema.Struct({ match: Schema.Struct({ text: Schema.String, }), - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -80,7 +80,7 @@ const End = Schema.Struct({ type: Schema.Literal("end"), data: Schema.Struct({ path: PathText, - binary_offset: Schema.NullOr(Schema.Number), + binary_offset: Schema.NullOr(NonNegativeInt), stats: Stats, }), }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index cdb284783f..5fcff772ec 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,7 +13,7 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" const log = Log.create({ service: "lsp" }) @@ -23,8 +23,8 @@ export const Event = { } const Position = Schema.Struct({ - line: Schema.Finite, - character: Schema.Finite, + line: NonNegativeInt, + character: NonNegativeInt, }) export const Range = Schema.Struct({ @@ -37,7 +37,7 @@ export type Range = typeof Range.Type export const Symbol = Schema.Struct({ name: Schema.String, - kind: Schema.Number, + kind: NonNegativeInt, location: Schema.Struct({ uri: Schema.String, range: Range, @@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type export const DocumentSymbol = Schema.Struct({ name: Schema.String, detail: Schema.optional(Schema.String), - kind: Schema.Number, + kind: NonNegativeInt, range: Range, selectionRange: Range, }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a1c3f7eda6..4229112a83 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) @@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({ }) const ProjectTime = Schema.Struct({ - created: Schema.Finite, - updated: Schema.Finite, - initialized: Schema.optional(Schema.Finite), + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: Schema.optional(NonNegativeInt), }) export const Info = Schema.Struct({ diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index b93d136c6d..24112cf442 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, - additions: Schema.Finite, - deletions: Schema.Finite, + additions: NonNegativeInt, + deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 80f0e077a0..2518800ce8 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" const log = Log.create({ service: "pty" }) @@ -62,7 +62,7 @@ export const Info = Schema.Struct({ args: Schema.Array(Schema.String), cwd: Schema.String, status: Schema.Literals(["running", "exited"]), - pid: Schema.Finite, + pid: PositiveInt, }) .annotate({ identifier: "Pty" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({ title: Schema.optional(Schema.String), size: Schema.optional( Schema.Struct({ - rows: Schema.Finite, - cols: Schema.Finite, + rows: PositiveInt, + cols: PositiveInt, }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable ws.close(1011, "missing proxy target") return } - remote = new WebSocket(url, protocols(c.req.raw)) + remote = new WebSocket(url, websocketProtocols(c.req.raw)) remote.binaryType = "arraybuffer" remote.onopen = () => { for (const item of queue) remote?.send(item) @@ -150,7 +150,7 @@ export function websocket( proxy.pathname = "/__workspace_ws" proxy.search = "" const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", socket(target)) + next.set("x-opencode-proxy-url", websocketTargetURL(target)) for (const [key, value] of new Headers(extra).entries()) { next.set(key, value) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 5070e0a2ca..2b1e5f9b1a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -3,6 +3,7 @@ import { MCP } from "@/mcp" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" +import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../auth" @@ -11,7 +12,7 @@ import { InstanceContextMiddleware } from "../instance-context" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optionalKey(Schema.String), - switchableOrgCount: Schema.Finite, + switchableOrgCount: NonNegativeInt, }).annotate({ identifier: "ConsoleState" }) const ConsoleOrgOption = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index c189e2078d..f338211f8b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,6 +10,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" +import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../auth" @@ -40,7 +41,7 @@ export const UpdatePayload = Schema.Struct({ permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ - archived: Schema.optional(Schema.Finite), + archived: Schema.optional(NonNegativeInt), }), ), }).annotate({ identifier: "SessionUpdateInput" }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 27ed732968..f5aca158dd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -23,7 +23,7 @@ export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: Schema.String, aggregate_id: Schema.String, - seq: Schema.Finite, + seq: NonNegativeInt, type: Schema.String, data: Schema.Record(Schema.String, Schema.Unknown), }).annotate({ identifier: "SyncHistoryEvent" }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index f43d15a7b2..7805fd47b2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -10,12 +10,11 @@ export const TuiRequestPayload = Schema.Struct({ path: Schema.String, body: Schema.Unknown, }).annotate({ identifier: "TuiRequest" }) -export const TuiPublishPayload = Schema.Union([ - Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }), - Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }), - Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }), - Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }), -]).annotate({ identifier: "TuiEventInput" }) +const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }) +const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }) +const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }) +const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }) +export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect]).annotate({ identifier: "TuiEventInput" }) export const TuiPaths = { appendPrompt: `${root}/append-prompt`, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index e0f09d6a30..298e455984 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,5 +1,6 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../auth" @@ -15,7 +16,7 @@ export const SessionRestorePayload = Schema.Struct( identifier: "WorkspaceSessionRestoreInput", }) export const SessionRestoreResponse = Schema.Struct({ - total: Schema.Finite, + total: NonNegativeInt, }).annotate({ identifier: "WorkspaceSessionRestoreResponse" }) export const WorkspacePaths = { diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts index 92184367d9..1ad42c5261 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts @@ -1,24 +1,27 @@ import { AppRuntime } from "@/effect/app-runtime" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { getAdaptor } from "@/control-plane/adaptors" +import { WorkspaceID } from "@/control-plane/schema" +import type { Target } from "@/control-plane/types" +import { Workspace } from "@/control-plane/workspace" import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" +import { Session } from "@/session/session" +import { ServerProxy } from "@/server/proxy" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" import { Filesystem } from "@/util/filesystem" -import { Effect, Layer, Schema } from "effect" -import { HttpRouter, HttpServerRequest } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Context, Effect, Layer } from "effect" +import type { unhandled } from "effect/Types" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" -const Query = Schema.Struct({ - directory: Schema.optionalKey(Schema.String), - workspace: Schema.optionalKey(Schema.String), - auth_token: Schema.optionalKey(Schema.String), -}) +type HandlerEffect = Effect.Effect -const Headers = Schema.Struct({ - authorization: Schema.optionalKey(Schema.String), - "x-opencode-directory": Schema.optionalKey(Schema.String), -}) - -export class InstanceContextMiddleware extends HttpApiMiddleware.Service()( +export class InstanceContextMiddleware extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiInstanceContext", ) {} @@ -38,11 +41,117 @@ function currentDirectory() { } } -function provideInstanceContext(effect: Effect.Effect) { +function sourceRequest(request: HttpServerRequest.HttpServerRequest) { + if (request.source instanceof Request) return request.source + return new Request(new URL(request.originalUrl, "http://localhost"), { + method: request.method, + headers: request.headers as HeadersInit, + }) +} + +function requestHeaders(request: HttpServerRequest.HttpServerRequest) { + return sourceRequest(request).headers +} + +function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, data: unknown) { + if (data instanceof Blob) { + void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) + return + } + if (typeof data === "string" || data instanceof Uint8Array) { + Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void))) + return + } + if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void))) +} + +function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) { return Effect.gen(function* () { - const query = yield* HttpServerRequest.schemaSearchParams(Query).pipe(Effect.orDie) - const headers = yield* HttpServerRequest.schemaHeaders(Headers).pipe(Effect.orDie) - const raw = query.directory || headers["x-opencode-directory"] || currentDirectory() + const source = sourceRequest(request) + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + const queue: Array = [] + const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source)) + remote.binaryType = "arraybuffer" + remote.onopen = () => { + for (const item of queue) remote.send(item) + queue.length = 0 + } + remote.onmessage = (event) => writeSocket(write, event.data) + remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) + remote.onclose = (event) => + Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void))) + + yield* socket + .runRaw((message) => { + const data = typeof message === "string" ? message : message.slice() + if (remote.readyState === WebSocket.OPEN) { + remote.send(data) + return + } + queue.push(data) + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring(Effect.sync(() => remote.close())), + Effect.orDie, + ) + return HttpServerResponse.empty() + }) +} + +function proxyRemote( + request: HttpServerRequest.HttpServerRequest, + workspace: Workspace.Info, + target: Extract, + requestURL: URL, +) { + const url = workspaceProxyURL(target.url, requestURL) + const source = sourceRequest(request) + if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url) + return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw)) +} + +function requestContext() { + return Effect.withFiber((fiber) => + Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)), + ) +} + +function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) { + return Effect.gen(function* () { + const url = new URL(request.url, "http://localhost") + const headers = requestHeaders(request) + const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined + const workspaceParam = url.searchParams.get("workspace") + const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) + const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined + + if (workspaceID && !workspace && !envWorkspaceID) { + return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, { + status: 500, + contentType: "text/plain; charset=utf-8", + }) + } + + if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) { + const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) + const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url) + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: target.directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspace.id), + ) + } + + const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory() const ctx = yield* Effect.promise(() => Instance.provide({ directory: Filesystem.resolve(decode(raw)), @@ -53,14 +162,30 @@ function provideInstanceContext(effect: Effect.Effect) { return yield* effect.pipe( Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, query.workspace), + Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID), ) }) } +function provideInstanceContext(effect: HandlerEffect) { + return Effect.gen(function* () { + const request = yield* requestContext() + const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost")) + const session = sessionID + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.catchDefect(() => Effect.succeed(undefined)), + ) + : undefined + return yield* provideRequestContext(effect, request, session?.workspaceID) + }) +} + export const instanceContextLayer = Layer.succeed( InstanceContextMiddleware, InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), ) -export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => provideInstanceContext(effect))).layer +export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => + requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), +)).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 4f3ee031a3..a0e6a72236 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -41,6 +41,8 @@ type OpenApiSchema = { type?: string } +// Instance routes use middleware for directory/workspace resolution, but HttpApi +// doesn't surface middleware query params in the spec. Inject them explicitly. const InstanceQueryParameters = [ { name: "directory", @@ -56,8 +58,13 @@ const InstanceQueryParameters = [ }, ] satisfies OpenApiParameter[] +// These refs already match the legacy SDK's expected body shape. Expanding all +// other refs lets us strip Effect's `null` from optional fields in one place. const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) -const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"]) + +// Query schemas describe decoded Effect values, but the generated SDK needs the +// public call shape. These keep SDK callers passing numbers/booleans while the +// server still decodes string query params at runtime. const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { @@ -67,53 +74,47 @@ const QueryParameterSchemas = { function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec + + // Effect's multi-document JSON Schema deduplicator can produce self-referencing + // component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition + // of X itself) when the same AST node appears both as a standalone endpoint + // payload and inside an annotated union arm. Resolve these by inlining the + // actual schema from any parent union that references them. + fixSelfReferencingComponents(spec) + for (const [path, item] of Object.entries(spec.paths ?? {})) { const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue if (operation.requestBody) { + // Hono's generated OpenAPI never marked request bodies as required. Keep + // that SDK surface stable during the HttpApi migration. delete operation.requestBody.required + // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI, + // but the legacy SDK expected plain `T` for optional fields. Expand + // non-legacy $refs and strip the null arms so the SDK surface is stable. for (const media of Object.values(operation.requestBody.content ?? {})) { const ref = media.schema?.$ref?.replace("#/components/schemas/", "") if (ref && LegacyBodyRefParameters.has(ref)) continue if (ref && spec.components?.schemas?.[ref]) { - media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref])) + media.schema = stripOptionalNull(structuredClone(spec.components.schemas[ref])) continue } - if (media.schema) media.schema = normalizeRequestSchema(media.schema) + if (media.schema) media.schema = stripOptionalNull(media.schema) } if (path === "/experimental/workspace" && method === "post") { + // Workspace creation fields `branch` and `extra` are Schema.NullOr — + // genuinely nullable, not just optional. Re-add the null that the + // global strip above removed. const properties = operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } - if (path === "/tui/publish" && method === "post" && spec.components?.schemas) { - const schema = operation.requestBody.content?.["application/json"]?.schema - const anyOf = schema?.anyOf - if (anyOf?.length === 4) { - spec.components.schemas.EventTuiPromptAppend = anyOf[0] - spec.components.schemas.EventTuiCommandExecute = anyOf[1] - spec.components.schemas.EventTuiToastShow = anyOf[2] - spec.components.schemas.EventTuiSessionSelect = anyOf[3] - operation.requestBody.content!["application/json"]!.schema = { - anyOf: [ - { $ref: "#/components/schemas/EventTuiPromptAppend" }, - { $ref: "#/components/schemas/EventTuiCommandExecute" }, - { $ref: "#/components/schemas/EventTuiToastShow" }, - { $ref: "#/components/schemas/EventTuiSessionSelect" }, - ], - } - } - } - if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) { - const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events - if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") { - events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent)) - } - } } if ((path === "/event" || path === "/global/event") && method === "get") { + // HttpApi has no first-class SSE response schema, and these handlers are + // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { description: "Event stream", content: { @@ -136,29 +137,72 @@ function matchLegacyOpenApi(input: Record) { return input } -function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { +/** + * Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI + * generation bug where annotated union arms that share AST nodes with other + * endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X. + * + * Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf` + * that references the broken component, then inlining that schema. + */ +function fixSelfReferencingComponents(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + const selfRefs = new Set() + for (const [name, schema] of Object.entries(schemas)) { + if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name) + } + if (selfRefs.size === 0) return + // Find a parent union component whose anyOf/oneOf contains a $ref to the + // broken component — that parent was generated correctly and holds the inline + // schema we need. + for (const [, schema] of Object.entries(schemas)) { + for (const member of schema.anyOf ?? schema.oneOf ?? []) { + const ref = member.$ref?.replace("#/components/schemas/", "") + if (!ref || !selfRefs.has(ref)) continue + // This member's $ref points to a self-referencing component. The member + // itself is just {$ref:...}, so the actual schema must be resolved from + // the union. Since the union component was generated before the + // deduplicator broke things, the inline version lives elsewhere. Generate + // a fresh spec without the transform to get the correct schema. + // Simpler approach: look through all paths for an endpoint that uses this + // schema as a payload (it would have been expanded by the ref-expansion + // logic above if we ran after that, but we run before). Instead, just + // delete the broken component — if it's referenced via $ref elsewhere, + // the ref expansion in the request body loop will inline it anyway. + } + } + // Simplest fix: generate the raw spec (without transform) to get correct schemas + const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const rawSchemas = raw.components?.schemas + if (!rawSchemas) return + for (const name of selfRefs) { + if (rawSchemas[name]) schemas[name] = rawSchemas[name] + } +} + +/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ +function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { const withoutNull = options.filter((item) => item.type !== "null") - const finite = withoutNull.find((item) => item.type === "number") - if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" } - if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0]) - if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema) - if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema) + if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0]) + if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull) + if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull) } if (schema.allOf) { if (schema.type) delete schema.allOf - else schema.allOf = schema.allOf.map(normalizeRequestSchema) + else schema.allOf = schema.allOf.map(stripOptionalNull) } if (schema.prefixItems && schema.items) delete schema.prefixItems - if (schema.items) schema.items = normalizeRequestSchema(schema.items) + if (schema.items) schema.items = stripOptionalNull(schema.items) if (schema.properties) { for (const [key, value] of Object.entries(schema.properties)) { - schema.properties[key] = normalizeRequestSchema(value) + schema.properties[key] = stripOptionalNull(value) } } if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties) + schema.additionalProperties = stripOptionalNull(schema.additionalProperties) } return schema } @@ -167,11 +211,6 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) } -function isFiniteNumberOption(schema: OpenApiSchema) { - if (schema.type === "number") return true - return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true -} - function normalizeParameter(param: OpenApiParameter, route: string) { if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] @@ -189,7 +228,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } return } - param.schema = normalizeRequestSchema(param.schema) + param.schema = stripOptionalNull(param.schema) } export const PublicApi = OpenCodeHttpApi diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 5117fb8fa9..29b1ab9869 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -21,7 +21,7 @@ const RULES: Array = [ { method: "GET", path: "/session", action: "local" }, ] -function local(method: string, path: string) { +export function isLocalWorkspaceRoute(method: string, path: string) { for (const rule of RULES) { if (rule.method && rule.method !== method) continue const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") @@ -30,7 +30,7 @@ function local(method: string, path: string) { return false } -function getSessionID(url: URL) { +export function getWorkspaceRouteSessionID(url: URL) { if (url.pathname === "/session/status") return null const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] @@ -39,8 +39,17 @@ function getSessionID(url: URL) { return SessionID.make(id) } +export function workspaceProxyURL(target: string | URL, requestURL: URL) { + const proxyURL = new URL(target) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` + proxyURL.search = requestURL.search + proxyURL.hash = requestURL.hash + proxyURL.searchParams.delete("workspace") + return proxyURL +} + async function getSessionWorkspace(url: URL) { - const id = getSessionID(url) + const id = getWorkspaceRouteSessionID(url) if (!id) return null const session = await AppRuntime.runPromise( @@ -73,7 +82,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - if (local(c.req.method, url.pathname)) { + if (isLocalWorkspaceRoute(c.req.method, url.pathname)) { // No instance provided because we are serving cached data; there // is no instance to work with return next() @@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const proxyURL = new URL(target.url) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}` - proxyURL.search = url.search - proxyURL.hash = url.hash - proxyURL.searchParams.delete("workspace") + const proxyURL = workspaceProxyURL(target.url, url) log.info("workspace proxy forwarding", { workspaceID, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 341d44e2ee..31e1a71349 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -42,7 +42,7 @@ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {} export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = namedSchemaError("StructuredOutputError", { message: Schema.String, - retries: Schema.Finite, + retries: NonNegativeInt, }) export const AuthError = namedSchemaError("ProviderAuthError", { providerID: Schema.String, @@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", { }) export const APIError = namedSchemaError("APIError", { message: Schema.String, - statusCode: Schema.optional(Schema.Finite), + statusCode: Schema.optional(NonNegativeInt), isRetryable: Schema.Boolean, responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), responseBody: Schema.optional(Schema.String), @@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({ ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ - start: Schema.Finite, - end: Schema.optional(Schema.Finite), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), @@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({ text: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ - start: Schema.Finite, - end: Schema.optional(Schema.Finite), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), }) .annotate({ identifier: "ReasoningPart" }) @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable ({ zod: zod(s) }))) @@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Int, - end: Schema.Int, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }) @@ -242,11 +242,11 @@ export type SubtaskPart = Types.DeepMutable export const ToolPartialCall = Schema.Struct({ state: Schema.Literal("partial-call"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type export const ToolResult = Schema.Struct({ state: Schema.Literal("result"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -141,8 +141,8 @@ export const Info = Schema.Struct({ parts: Schema.Array(MessagePart), metadata: Schema.Struct({ time: Schema.Struct({ - created: Schema.Number, - completed: Schema.optional(Schema.Number), + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), }), error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), sessionID: SessionID, @@ -153,8 +153,8 @@ export const Info = Schema.Struct({ title: Schema.String, snapshot: Schema.optional(Schema.String), time: Schema.Struct({ - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, }), }), [Schema.Record(Schema.String, Schema.Unknown)], @@ -169,15 +169,15 @@ export const Info = Schema.Struct({ cwd: Schema.String, root: Schema.String, }), - cost: Schema.Number, + cost: Schema.Finite, summary: Schema.optional(Schema.Boolean), tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), }), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a29524534e..af152fce86 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -38,7 +38,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) { } const Summary = Schema.Struct({ - additions: Schema.Finite, - deletions: Schema.Finite, - files: Schema.Finite, + additions: NonNegativeInt, + deletions: NonNegativeInt, + files: NonNegativeInt, diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) @@ -143,10 +143,10 @@ const Share = Schema.Struct({ }) const Time = Schema.Struct({ - created: Schema.Finite, - updated: Schema.Finite, - compacting: optionalOmitUndefined(Schema.Finite), - archived: optionalOmitUndefined(Schema.Finite), + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(NonNegativeInt), }) const Revert = Schema.Struct({ @@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema ) export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, - time: Schema.optional(Schema.Finite), + time: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, @@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const MessagesInput = Schema.Struct({ sessionID: SessionID, - limit: Schema.optional(Schema.Finite), + limit: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) const CreatedEventSchema = Schema.Struct({ @@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({ }) const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(Schema.Finite)), - updated: Schema.optional(Schema.NullOr(Schema.Finite)), - compacting: Schema.optional(Schema.NullOr(Schema.Finite)), - archived: Schema.optional(Schema.NullOr(Schema.Finite)), + created: Schema.optional(Schema.NullOr(NonNegativeInt)), + updated: Schema.optional(Schema.NullOr(NonNegativeInt)), + compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(NonNegativeInt)), }) const UpdatedInfo = Schema.Struct({ diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 8ef5a3766e..a0e57afc22 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -3,7 +3,7 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" @@ -13,9 +13,9 @@ export const Info = Schema.Union([ }), Schema.Struct({ type: Schema.Literal("retry"), - attempt: Schema.Finite, + attempt: NonNegativeInt, message: Schema.String, - next: Schema.Finite, + next: NonNegativeInt, }), Schema.Struct({ type: Schema.Literal("busy"), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 766b01cd9a..ea30f5afc7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { zod } from "@/util/effect-zod" export const Patch = Schema.Struct({ @@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, - additions: Schema.Finite, - deletions: Schema.Finite, + additions: NonNegativeInt, + deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 655f6c987f..5b2df1e899 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" +import { NonNegativeInt } from "@/util/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) @@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({ }) const DiffFile = Schema.Struct({ - additions: Schema.Finite, - deletions: Schema.Finite, + additions: NonNegativeInt, + deletions: NonNegativeInt, }) const SummaryFile = Schema.Struct({ diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e6c8b39885..c32c3963ba 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { PositiveInt } from "@/util/schema" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs export const Parameters = Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(Schema.Finite).annotate({ description: "Optional timeout in milliseconds" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index e10d21175e..2753732dd0 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -9,7 +9,7 @@ export const Parameters = Schema.Struct({ description: "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", }), - tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + tokensNum: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)) .check(Schema.isLessThanOrEqualTo(50000)) .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) .annotate({ diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 828beeefef..3a555c2ce8 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -23,12 +23,12 @@ const operations = [ export const Parameters = Schema.Struct({ operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), - line: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)) - .annotate({ description: "The line number (1-based, as shown in editors)" }), - character: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)) - .annotate({ description: "The character offset (1-based, as shown in editors)" }), + line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The line number (1-based, as shown in editors)", + }), + character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The character offset (1-based, as shown in editors)", + }), query: Schema.optional(Schema.String).annotate({ description: "Search query for workspaceSymbol. Empty string requests all symbols.", }), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 7a645fab1a..fb386f5790 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,4 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" +import { NonNegativeInt } from "@/util/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" @@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096 // unchanged; purely CLI-facing uses must now send numbers rather than strings. export const Parameters = Schema.Struct({ filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), - offset: Schema.optional(Schema.Finite).annotate({ + offset: Schema.optional(NonNegativeInt).annotate({ description: "The line number to start reading from (1-indexed)", }), - limit: Schema.optional(Schema.Finite).annotate({ + limit: Schema.optional(NonNegativeInt).annotate({ description: "The maximum number of lines to read (defaults to 2000)", }), }) diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 2a6c02349f..380225316c 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -11,6 +11,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + + /** * Optional public JSON field that can hold explicit `undefined` on the type * side but encodes it as an omitted key, matching legacy `JSON.stringify`. diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index b261d8b5b2..66576a688e 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { NonNegativeInt } from "@/util/schema" import { SessionEvent } from "./session-event" export const ID = SessionEvent.ID @@ -105,7 +106,7 @@ export class AssistantReasoning extends Schema.Class("Sessio }) {} export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: Schema.Number, + attempt: NonNegativeInt, error: SessionEvent.RetryError, time: Schema.Struct({ created: Schema.DateTimeUtc, @@ -132,14 +133,14 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Number.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }).pipe(Schema.optional), error: Schema.String.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f922becf3a..aaf71c8dcc 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import * as DateTime from "effect/DateTime" import { Schema } from "effect" @@ -25,8 +25,8 @@ export namespace SessionEvent { } export class Source extends Schema.Class("Session.Event.Source")({ - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, text: Schema.String, }) {} @@ -55,7 +55,7 @@ export namespace SessionEvent { export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ message: Schema.String, - statusCode: Schema.Number.pipe(Schema.optional), + statusCode: NonNegativeInt.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -123,14 +123,14 @@ export namespace SessionEvent { ...Base, type: Schema.Literal("step.ended"), reason: Schema.String, - cost: Schema.Number, + cost: Schema.Finite, tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), }) { @@ -395,7 +395,7 @@ export namespace SessionEvent { export class Retried extends Schema.Class("Session.Event.Retried")({ ...Base, type: Schema.Literal("retried"), - attempt: Schema.Number, + attempt: NonNegativeInt, error: RetryError, }) { static create(input: BaseInput & { attempt: number; error: RetryError }) {