Migrate runtime validators to Effect Schema (#26975)

This commit is contained in:
Kit Langton 2026-05-11 21:41:56 -04:00 committed by GitHub
commit 1007630347
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 176 additions and 161 deletions

View file

@ -1,33 +1,36 @@
import { Database } from "bun:sqlite" import { Database } from "bun:sqlite"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import z from "zod" import { Option, Schema } from "effect"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { EditorSelection } from "./editor" import type { EditorSelection } from "./editor"
const ZedEditorRowSchema = z.object({ const ZedEditorRowSchema = Schema.Struct({
item_kind: z.string(), item_kind: Schema.String,
editor_id: z.number().nullable(), editor_id: Schema.NullOr(Schema.Number),
workspace_id: z.number(), workspace_id: Schema.Number,
workspace_paths: z.string().nullable(), workspace_paths: Schema.NullOr(Schema.String),
timestamp: z.string(), timestamp: Schema.String,
buffer_path: z.string().nullable(), buffer_path: Schema.NullOr(Schema.String),
}) })
const ZedSelectionRowSchema = z.object({ const ZedSelectionRowSchema = Schema.Struct({
selection_start: z.number().nullable(), selection_start: Schema.NullOr(Schema.Number),
selection_end: z.number().nullable(), selection_end: Schema.NullOr(Schema.Number),
}) })
const ZedEditorContentsSchema = z.object({ const ZedEditorContentsSchema = Schema.Struct({
contents: z.string().nullable(), contents: Schema.NullOr(Schema.String),
}) })
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
const utf8 = new TextEncoder() const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema> type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
export type ZedSelectionResult = export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection } | { type: "selection"; selection: EditorSelection }
@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
.all() .all()
const rows = raw.flatMap((row) => { const rows = raw.flatMap((row) => {
const parsed = ZedEditorRowSchema.safeParse(row) const parsed = decodeZedEditorRow(row)
return parsed.success ? [parsed.data] : [] return Option.isSome(parsed) ? [parsed.value] : []
}) })
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const } if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => { const selections = raw.flatMap((selection) => {
const parsed = ZedSelectionRowSchema.safeParse(selection) const parsed = decodeZedSelectionRow(selection)
return parsed.success ? [parsed.data] : [] return Option.isSome(parsed) ? [parsed.value] : []
}) })
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined let db: Database | undefined
try { try {
db = new Database(dbPath, { readonly: true }) db = new Database(dbPath, { readonly: true })
const parsed = ZedEditorContentsSchema.safeParse( const parsed = decodeZedEditorContents(
db db
.query( .query(
`select contents `select contents
@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
) )
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }), .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
) )
if (!parsed.success) return { type: "unavailable" as const } if (Option.isNone(parsed)) return { type: "unavailable" as const }
return { type: "contents" as const, contents: parsed.data.contents } return { type: "contents" as const, contents: parsed.value.contents }
} catch { } catch {
return { type: "unavailable" as const } return { type: "unavailable" as const }
} finally { } finally {

View file

@ -3,92 +3,102 @@ import os from "node:os"
import path from "node:path" import path from "node:path"
import { onCleanup, onMount } from "solid-js" import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import z from "zod" import { Option, Schema, SchemaGetter } from "effect"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
const MCP_PROTOCOL_VERSION = "2025-11-25" const MCP_PROTOCOL_VERSION = "2025-11-25"
const JsonRpcMessageSchema = z.object({ const JsonRpcMessageSchema = Schema.Struct({
id: z.union([z.number(), z.string(), z.null()]).optional(), id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])),
method: z.string().optional(), method: Schema.optional(Schema.String),
params: z.unknown().optional(), params: Schema.optional(Schema.Unknown),
result: z.unknown().optional(), result: Schema.optional(Schema.Unknown),
error: z error: Schema.optional(
.object({ Schema.Struct({
code: z.number().optional(), code: Schema.optional(Schema.Number),
message: z.string().optional(), message: Schema.optional(Schema.String),
}) }),
.optional(), ),
}) })
const PositionSchema = z.object({ const PositionSchema = Schema.Struct({
line: z.number(), line: Schema.Number,
character: z.number(), character: Schema.Number,
}) })
const EditorSelectionRangeSchema = z.object({ const EditorSelectionRangeSchema = Schema.Struct({
text: z.string(), text: Schema.String,
selection: z.object({ selection: Schema.Struct({
start: PositionSchema, start: PositionSchema,
end: PositionSchema, end: PositionSchema,
}), }),
}) })
const EditorSelectionSchema = z const EditorSelectionRangesSchema = Schema.Struct({
.union([ filePath: Schema.String,
z.object({ source: Schema.optional(Schema.Literals(["websocket", "zed"])),
filePath: z.string(), ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),
source: z.enum(["websocket", "zed"]).optional(),
ranges: z.array(EditorSelectionRangeSchema).min(1),
}),
z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
}),
])
.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
)
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
lineEnd: z.number(),
}) })
const EditorServerInfoSchema = z.object({ const EditorSelectionSchema = Schema.Union([
protocolVersion: z.string().optional(), EditorSelectionRangesSchema,
serverInfo: z Schema.Struct({
.object({ text: Schema.String,
name: z.string().optional(), filePath: Schema.String,
version: z.string().optional(), source: Schema.optional(Schema.Literals(["websocket", "zed"])),
}) selection: Schema.Struct({
.optional(), start: PositionSchema,
end: PositionSchema,
}),
}),
]).pipe(
Schema.decodeTo(EditorSelectionRangesSchema, {
decode: SchemaGetter.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const EditorMentionSchema = Schema.Struct({
filePath: Schema.String,
lineStart: Schema.Number,
lineEnd: Schema.Number,
}) })
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema> const EditorServerInfoSchema = Schema.Struct({
export type EditorSelection = z.infer<typeof EditorSelectionSchema> protocolVersion: Schema.optional(Schema.String),
export type EditorMention = z.infer<typeof EditorMentionSchema> serverInfo: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
}),
),
})
const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)
const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)
const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)
const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>
export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>
export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>
export type EditorLabelState = "pending" | "sent" | "none" export type EditorLabelState = "pending" | "sent" | "none"
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema> type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
type EditorConnection = { type EditorConnection = {
url: string url: string
@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const message = parseMessage(event.data) const message = parseMessage(event.data)
if (!message) return if (!message) return
const selection = const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none()
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined if (Option.isSome(selection)) {
if (selection?.success) { setSelection({ ...selection.value, source: "websocket" })
setSelection({ ...selection.data, source: "websocket" })
return return
} }
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none()
if (mention?.success) { if (Option.isSome(mention)) {
mentionListeners.forEach((listener) => listener(mention.data)) mentionListeners.forEach((listener) => listener(mention.value))
return return
} }
@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
pending.delete(message.id) pending.delete(message.id)
if (message.error) return if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none()
if (initialize?.success) { if (Option.isSome(initialize)) {
setStore("server", initialize.data) setStore("server", initialize.value)
send({ method: "notifications/initialized" }) send({ method: "notifications/initialized" })
return return
} }
@ -447,7 +456,7 @@ function parseMessage(value: unknown) {
if (typeof value !== "string") return if (typeof value !== "string") return
try { try {
return JsonRpcMessageSchema.parse(JSON.parse(value)) return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value)))
} catch { } catch {
return return
} }

View file

@ -1,33 +1,35 @@
import path from "path" import path from "path"
import z from "zod"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const Tokens = z.object({ export const Tokens = Schema.Struct({
accessToken: z.string(), accessToken: Schema.mutableKey(Schema.String),
refreshToken: z.string().optional(), refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
expiresAt: z.number().optional(), expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
scope: z.string().optional(), scope: Schema.mutableKey(Schema.optional(Schema.String)),
}) })
export type Tokens = z.infer<typeof Tokens> export type Tokens = Schema.Schema.Type<typeof Tokens>
export const ClientInfo = z.object({ export const ClientInfo = Schema.Struct({
clientId: z.string(), clientId: Schema.mutableKey(Schema.String),
clientSecret: z.string().optional(), clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
clientIdIssuedAt: z.number().optional(), clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
clientSecretExpiresAt: z.number().optional(), clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
}) })
export type ClientInfo = z.infer<typeof ClientInfo> export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
export const Entry = z.object({ export const Entry = Schema.Struct({
tokens: Tokens.optional(), tokens: Schema.mutableKey(Schema.optional(Tokens)),
clientInfo: ClientInfo.optional(), clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)),
codeVerifier: z.string().optional(), codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)),
oauthState: z.string().optional(), oauthState: Schema.mutableKey(Schema.optional(Schema.String)),
serverUrl: z.string().optional(), serverUrl: Schema.mutableKey(Schema.optional(Schema.String)),
}) })
export type Entry = z.infer<typeof Entry> export type Entry = Schema.Schema.Type<typeof Entry>
const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
type AuthData = Record<string, Entry>
const filepath = path.join(Global.Path.data, "mcp-auth.json") const filepath = path.join(Global.Path.data, "mcp-auth.json")
@ -56,8 +58,8 @@ export const layer = Layer.effect(
const all = Effect.fn("McpAuth.all")(function* () { const all = Effect.fn("McpAuth.all")(function* () {
return yield* fs.readJson(filepath).pipe( return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>), Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)), Effect.catch(() => Effect.succeed({} as AuthData)),
) )
}) })
@ -93,7 +95,7 @@ export const layer = Layer.effect(
yield* set(mcpName, entry, serverUrl) yield* set(mcpName, entry, serverUrl)
}) })
const clearField = <K extends keyof Entry>(field: K, spanName: string) => const clearField = (field: keyof Entry, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
const entry = yield* get(mcpName) const entry = yield* get(mcpName)
if (entry) { if (entry) {

View file

@ -1,50 +1,51 @@
import { z } from "zod"
import type { Model } from "@opencode-ai/sdk/v2" import type { Model } from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
export const schema = z.object({ export const schema = Schema.Struct({
data: z.array( data: Schema.Array(
z.object({ Schema.Struct({
model_picker_enabled: z.boolean(), model_picker_enabled: Schema.Boolean,
id: z.string(), id: Schema.String,
name: z.string(), name: Schema.String,
// every version looks like: `{model.id}-YYYY-MM-DD` // every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(), version: Schema.String,
supported_endpoints: z.array(z.string()).optional(), supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
policy: z policy: Schema.optional(
.object({ Schema.Struct({
state: z.string().optional(), state: Schema.optional(Schema.String),
})
.optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
max_context_window_tokens: z.number(),
max_output_tokens: z.number(),
max_prompt_tokens: z.number(),
vision: z
.object({
max_prompt_image_size: z.number(),
max_prompt_images: z.number(),
supported_media_types: z.array(z.string()),
})
.optional(),
}), }),
supports: z.object({ ),
adaptive_thinking: z.boolean().optional(), capabilities: Schema.Struct({
max_thinking_budget: z.number().optional(), family: Schema.String,
min_thinking_budget: z.number().optional(), limits: Schema.Struct({
reasoning_effort: z.array(z.string()).optional(), max_context_window_tokens: Schema.Number,
streaming: z.boolean(), max_output_tokens: Schema.Number,
structured_outputs: z.boolean().optional(), max_prompt_tokens: Schema.Number,
tool_calls: z.boolean(), vision: Schema.optional(
vision: z.boolean().optional(), Schema.Struct({
max_prompt_image_size: Schema.Number,
max_prompt_images: Schema.Number,
supported_media_types: Schema.Array(Schema.String),
}),
),
}),
supports: Schema.Struct({
adaptive_thinking: Schema.optional(Schema.Boolean),
max_thinking_budget: Schema.optional(Schema.Number),
min_thinking_budget: Schema.optional(Schema.Number),
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
streaming: Schema.Boolean,
structured_outputs: Schema.optional(Schema.Boolean),
tool_calls: Schema.Boolean,
vision: Schema.optional(Schema.Boolean),
}), }),
}), }),
}), }),
), ),
}) })
type Item = z.infer<typeof schema>["data"][number] type Item = Schema.Schema.Type<typeof schema>["data"][number]
const decodeModels = Schema.decodeUnknownSync(schema)
function build(key: string, remote: Item, url: string, prev?: Model): Model { function build(key: string, remote: Item, url: string, prev?: Model): Model {
const reasoning = const reasoning =
@ -165,7 +166,7 @@ export async function get(
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch models: ${res.status}`) throw new Error(`Failed to fetch models: ${res.status}`)
} }
return schema.parse(await res.json()) return decodeModels(await res.json())
}) })
const result = { ...existing } const result = { ...existing }