Compare commits

..
Author SHA1 Message Date
LukeParkerDev 6a57105b2c refactor(app): keep the SSH contract schemas out of the renderer
The Effect schemas move to servers/ssh/schema.ts, imported by the desktop main process for its RPC server; the renderer gets types only. The SSH attempt controller tracks its one in-flight request per server with a cancellation flag instead of Effect fibers, with the same settle-on-cancel behaviour.
2026-09-20 16:37:02 +10:00
LukeParkerDev 7930c5f676 refactor(app): port the model selection and terminal stores to plain codecs 2026-09-20 16:37:02 +10:00
LukeParkerDev a440d0bced refactor(app): port language, drafts, pairing, review panel, open-in-app and upsell stores to plain codecs
Also replaces the Effect Iterable pipeline in the provider catalog with array methods.
2026-09-20 16:37:01 +10:00
LukeParkerDev 169ababd60 refactor(app): port command, notification, highlights, home, file-view and workspace-tip stores to plain codecs
Session import keeps validating against the shared Effect schema through a dynamic import, so the shared schema and Effect load only when a file is imported. Persisted notification errors are checked structurally instead of through the shared schema. composer/schema.ts carries Effect twins of the file selection shapes until it is ported.
2026-09-20 16:37:00 +10:00
32 changed files with 420 additions and 389 deletions
+3 -1
View File
@@ -10,7 +10,8 @@
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh/schema": "./src/servers/ssh/schema.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -94,3 +95,4 @@
"tailwindcss": "4.3.3"
}
}
+15 -1
View File
@@ -3,7 +3,19 @@ import { checksum } from "@opencode/util/encode"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import { Persistence } from "@/runtime/persistence/schema"
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
// Effect twins of workspaces/files/types until this module moves to plain codecs.
const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
const PartBase = {
content: Schema.String,
@@ -228,3 +240,5 @@ export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pi
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+13 -7
View File
@@ -11,15 +11,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode/ui/context/dialog"
import { createResource } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
export const HomeServersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
@@ -114,8 +114,13 @@ export function createHomeProjectsController(home: HomeController) {
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
// Validating an imported file is the one place the shared Effect schema is needed here.
const [{ Schema }, { SessionTransfer }] = await Promise.all([
import("effect"),
import("@opencode/schema/session-transfer"),
])
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
@@ -184,3 +189,4 @@ export function createHomeProjectsController(home: HomeController) {
}
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
+6 -6
View File
@@ -5,7 +5,7 @@ import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-j
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { debounce } from "@solid-primitives/scheduled"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -21,7 +21,6 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
@@ -34,12 +33,12 @@ const NewSessionSummary = lazy(async () => {
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
export const WorkspaceOnboardingSchema = Codec.struct({
used: Codec.boolean,
})
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
export const ProviderTipSchema = Codec.struct({
dismissedAt: Codec.number,
})
export const WorkspaceTipSchema = ProviderTipSchema
@@ -267,3 +266,4 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
</Show>
)
}
+11 -22
View File
@@ -1,7 +1,6 @@
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import type { ProviderListResponse } from "@/runtime/server/types"
import { useIntegrations } from "./integrations"
@@ -53,34 +52,24 @@ export function useProviders(directory: Accessor<string | undefined>) {
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...v],
)
const more = [...providers().all.values()]
.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id))
.map((p) => ({ id: p.id, name: p.name }))
return [...catalog, ...more]
},
connected: () => {
const connected = new Set(providers().connected)
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
return [...providers().all.values()].filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
const paid = [...providers().all].filter(
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
)
return paid
},
}
}
+28 -30
View File
@@ -3,12 +3,11 @@ import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -18,46 +17,45 @@ import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
const ModelKeySchema = Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
variant: Codec.optional(Codec.string),
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
const ChoiceSchema = Codec.struct({
model: Codec.lenientOptional(ModelKeySchema),
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
})
const StateSchema = Schema.Struct({
const StateSchema = Codec.struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
agent: Codec.lenientOptional(Codec.string),
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
const SessionsSchema = Codec.record(Codec.fallback(Codec.undefinedOr(StateSchema), () => undefined))
const Current = Codec.struct({ session: SessionsSchema })
const StoredSelection = Codec.struct(
{
session: Codec.lenientOptional(Codec.record(Codec.unknown)),
pick: Codec.lenientOptional(Codec.record(Codec.unknown)),
},
{ preserve: true },
)
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
export const ModelSelectionSchema = Codec.migrate(
Current,
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
Codec.transform(StoredSelection, {
decode: (value) => ({
...value,
session:
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
}),
),
encode: (value) => value,
}),
)
const WORKSPACE_KEY = "__workspace__"
+9 -16
View File
@@ -1,7 +1,7 @@
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { createSimpleContext } from "@opencode/ui/context"
import {
I18nProvider,
@@ -12,7 +12,6 @@ import {
type UiPluralCategory,
} from "@opencode/ui/context/i18n"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import en from "@/runtime/i18n/en"
import { dict } from "@opencode/ui/i18n/en"
import {
@@ -56,14 +55,9 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Schema.Struct({
locale: Schema.String.pipe(
Schema.decodeTo(LocaleSchema, {
decode: SchemaGetter.transform(normalizeLocale),
encode: SchemaGetter.transform((locale) => locale),
}),
),
const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Codec.struct({
locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }),
})
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
@@ -160,11 +154,11 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
return Codec.decodeOption(LocaleSchema, value) ?? "en"
}
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
export const languageSchema = Codec.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
@@ -172,9 +166,7 @@ function readStoredLocale() {
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale
} catch {
return
}
@@ -296,3 +288,4 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
</I18nProvider>
)
}
@@ -3,6 +3,7 @@ import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
@@ -10,9 +11,9 @@ import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
const onboarding = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceOnboardingSchema, { used: false }), input))
const tip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProviderTipSchema, { dismissedAt: 0 }), input))
const workspaceTip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }), input))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -25,7 +26,7 @@ describe("persisted consumer schemas", () => {
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(schema, { collapsed: {} }), input)
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
@@ -35,21 +36,19 @@ describe("persisted consumer schemas", () => {
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
expect(decode({})).toEqual({ session: {} })
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
expect(state.session.session1?.agent).toBe("build")
expect(state.session.__workspace__).toBeUndefined()
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(state)
const encoded = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
expect(decode(JSON.parse(encoded))).toEqual(state)
})
test("current model selections take precedence over legacy picks", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
session: {},
pick: { session1: { agent: "plan" } },
}),
@@ -57,7 +56,7 @@ describe("persisted consumer schemas", () => {
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
session: {
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
@@ -76,7 +75,7 @@ describe("persisted consumer schemas", () => {
})
test("file views validate scroll positions and line sides independently", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(FileViewsSchema, { file: {} }), input))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
@@ -103,7 +102,7 @@ describe("persisted consumer schemas", () => {
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input)
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
@@ -111,3 +110,6 @@ describe("persisted consumer schemas", () => {
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,5 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Option, Schema } from "effect"
import { Codec } from "./codec"
export type BlobReference = { id: string; url: string }
@@ -312,12 +312,12 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
getItem: async (key) => {
const value = await driver.get(key)
if (value === null) return null
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
const parsed = Codec.fromJsonString(Codec.unknown).decode(value)
// Let the owning persistence codec apply its invalid-document policy.
if (parsed === Codec.INVALID) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed), grace)
return JSON.stringify(await decode(parsed))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
setDocument,
@@ -455,3 +455,4 @@ export async function blobDataUrl(blob: BlobReference, mime: string) {
export function createLegacyBlobReference(dataUrl: string): BlobReference {
return { id: dataUrl, url: dataUrl }
}
+13 -12
View File
@@ -1,12 +1,12 @@
import { Option, Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
const pairing = Codec.fromJsonString(
Codec.struct({
urls: Codec.array(Codec.string),
username: Codec.literal("opencode"),
password: Codec.string,
}),
)
export function serverAddress(value: string) {
@@ -20,9 +20,10 @@ export function serverAddress(value: string) {
}
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
const result = Codec.decodeOption(pairing, value)
if (!result) return
const urls = [...new Set(result.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.password }
}
+44 -51
View File
@@ -1,4 +1,3 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
@@ -24,7 +23,8 @@ export function createSshController(input: {
| undefined
>
>({})
const tasks = new Map<string, Fiber.Fiber<void>>()
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
const tasks = new Map<string, { cancelled: boolean }>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
@@ -33,31 +33,32 @@ export function createSshController(input: {
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
const finish = (id: string, task: { cancelled: boolean }) => {
if (task.cancelled) return
task.cancelled = true
if (tasks.get(id) === task) tasks.delete(id)
setAttempts(id, "submitting", false)
}
const interrupt = (id: string) => {
const task = tasks.get(id)
if (task) finish(id, task)
}
const run = (id: string, work: () => Promise<unknown>) => {
setAttempts(id, { submitting: true, error: false })
const task = { cancelled: false }
tasks.set(id, task)
work().then(
() => finish(id, task),
() => {
if (task.cancelled) return
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
finish(id, task)
},
)
}
onCleanup(() => {
for (const id of [...tasks.keys()]) interrupt(id)
})
createEffect(() => {
for (const item of input.items()) {
@@ -114,40 +115,32 @@ export function createSshController(input: {
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
run(config.id, async () => {
await api.start({ ...config, replace: options?.replace })
// Observe admission before treating an older disconnected snapshot as cancellation.
await input.refresh()
})
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
run(id, () => api.respond(id, prompt, value))
},
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
cancel: (id: string) => {
const api = input.api
interrupt(id)
setAttempts(id, undefined)
if (!api) return
void api
.cancel(id)
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
.catch(() => undefined)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Schema } from "effect"
// The SSH state contract between the desktop main process and its renderer. Only the main process
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
+8 -60
View File
@@ -1,63 +1,9 @@
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
export type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
@@ -70,3 +16,5 @@ export type SshPlatform = {
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
@@ -18,3 +17,4 @@ describe("open app preferences", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,8 +5,7 @@ import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
@@ -30,8 +29,8 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
export const OpenAppPreferences = Codec.struct({
app: Codec.literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
@@ -242,3 +241,4 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
appExistence.set(app, request)
return request
}
+12 -8
View File
@@ -5,17 +5,20 @@ import {
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
const ReviewPanel = Codec.struct({
sidebarOpened: Codec.boolean,
sidebarWidth: Codec.make<number, number>(
(v) =>
typeof v === "number" && v >= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN && v <= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX
? v
: Codec.INVALID,
(v) => v,
),
expandMode: Codec.literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
@@ -47,3 +50,4 @@ export function createReviewPanelState(platform?: Platform) {
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -2,9 +2,8 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/runtime/server/scope"
import { base64Encode } from "@opencode/util/encode"
import { Persist } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
@@ -21,10 +20,10 @@ beforeAll(async () => {
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
roundTripTerminalState = (value) =>
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -141,3 +140,4 @@ describe("TerminalState", () => {
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+33 -31
View File
@@ -8,18 +8,20 @@ import { base64Encode } from "@opencode/util/encode"
import { defaultTitle, titleNumber } from "./title"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
import { Codec } from "@/runtime/persistence/codec"
const PTY = Persistence.struct({
id: Schema.NonEmptyString,
title: Persistence.fallback(Schema.String, () => ""),
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
rows: Persistence.optional(Schema.Finite),
cols: Persistence.optional(Schema.Finite),
buffer: Persistence.optional(Schema.String),
scrollY: Persistence.optional(Schema.Finite),
cursor: Persistence.optional(Schema.Finite),
const PTY = Codec.struct({
id: Codec.make<string, string>(
(v) => (typeof v === "string" && v.length > 0 ? v : Codec.INVALID),
(v) => v,
),
title: Codec.fallback(Codec.string, () => ""),
titleNumber: Codec.fallback(Codec.number, () => 0),
rows: Codec.lenientOptional(Codec.number),
cols: Codec.lenientOptional(Codec.number),
buffer: Codec.lenientOptional(Codec.string),
scrollY: Codec.lenientOptional(Codec.number),
cursor: Codec.lenientOptional(Codec.number),
})
export type LocalPTY = typeof PTY.Type
@@ -31,28 +33,26 @@ function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
const State = Codec.struct({
active: Codec.lenientOptional(Codec.string),
all: Codec.lenientArray(PTY),
})
export const TerminalState = State.pipe(
Schema.decodeTo(Schema.toType(State), {
decode: SchemaGetter.transform((value) => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export const TerminalState = Codec.transform(State, {
decode: (value): typeof State.Type => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
},
encode: (value) => value,
})
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -458,3 +458,5 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
},
})
@@ -1,40 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
}),
)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
import { describe, expect, test } from "bun:test"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Codec } from "@/runtime/persistence/codec"
const schema = Codec.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
@@ -2,8 +2,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -15,11 +14,11 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
export const GoUpsellState = Codec.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Codec.nullOr(Codec.number),
})
function goUpsellKeys(status: SessionStatus) {
@@ -106,3 +105,4 @@ export function useUsageExceededDialogs() {
}),
)
}
+4 -4
View File
@@ -5,8 +5,7 @@ import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -20,8 +19,8 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
export const ModelProvidersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
@@ -211,3 +210,4 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
</>
)
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import {
activeCommandRegistrations,
addCommandRegistration,
@@ -10,12 +10,12 @@ import {
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = Schema.decodeUnknownSync(CommandCatalog)
const decode = ((input: unknown) => Codec.decodeOrThrow(CommandCatalog, input))
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
expect(decode({})).toEqual({})
expect(() => decode({ open: { title: 1 } })).toThrow()
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
expect(decode(CommandCatalog.encode(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
@@ -79,3 +79,4 @@ describe("resolveKeybindOption", () => {
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+11 -10
View File
@@ -2,8 +2,8 @@ import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
@@ -109,16 +109,16 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
type CommandSource = "palette" | "keybind" | "slash"
export const CommandCatalogItem = Persistence.struct({
title: Schema.String,
description: Schema.optional(Schema.String),
category: Schema.optional(Schema.String),
keybind: Schema.optional(Schema.String),
slash: Schema.optional(Schema.String),
hidden: Schema.optional(Schema.Boolean),
export const CommandCatalogItem = Codec.struct({
title: Codec.string,
description: Codec.optional(Codec.string),
category: Codec.optional(Codec.string),
keybind: Codec.optional(Codec.string),
slash: Codec.optional(Codec.string),
hidden: Codec.optional(Codec.boolean),
})
export type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export const CommandCatalog = Codec.record(CommandCatalogItem)
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
@@ -480,3 +480,4 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
},
})
@@ -1,23 +1,22 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
test("notification persistence validates and salvages individual notifications", () => {
const valid: Notification[] = [
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
]
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(NotificationStore, { list: [] }), input))
const store = decode({
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
})
expect(store.list).toEqual(valid)
expect(decode({})).toEqual({ list: [] })
expect(decode({ list: {} })).toEqual({ list: [] })
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
expect(decode(NotificationStore.encode(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
@@ -41,3 +40,4 @@ test("opens notification sessions through the tab router", () => {
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
})
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { SessionError } from "@opencode/schema/session-error"
import { Codec } from "@/runtime/persistence/codec"
import type { SessionError } from "@opencode/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import type { ServerSDK } from "@/runtime/server/client"
@@ -10,7 +10,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
@@ -20,19 +20,28 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
const NotificationBase = {
directory: Schema.optional(Schema.String),
session: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Unknown),
time: Schema.Finite,
viewed: Schema.Boolean,
}
export const Notification = Schema.Union([
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
const NotificationBase = {
directory: Codec.optional(Codec.string),
session: Codec.optional(Codec.string),
metadata: Codec.optional(Codec.unknown),
time: Codec.number,
viewed: Codec.boolean,
}
// The error payload is whatever the server reported; shape-checking it here would load the shared
// Effect schema into the renderer's startup path for a value the server already validated.
const StoredSessionError = Codec.make<SessionError.Error, unknown>(
(value) =>
typeof value === "object" && value !== null && "name" in value && "message" in value
? (value as unknown as SessionError.Error)
: Codec.INVALID,
(value) => value,
)
export const Notification = Codec.union([
Codec.struct({ ...NotificationBase, type: Codec.literal("turn-complete") }),
Codec.struct({ ...NotificationBase, type: Codec.literal("error"), error: StoredSessionError }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
export const NotificationStore = Codec.struct({ list: Codec.lenientArray(Notification) })
type NotificationIndex = {
session: {
@@ -368,3 +377,5 @@ export const useNotification = () => {
const server = useServer()
return server.ctx.notification
}
@@ -1,13 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(HighlightsStore, { version: undefined }), input))
expect(decode({})).toEqual({ version: undefined })
expect(decode({ version: null })).toEqual({ version: undefined })
const value = decode({ version: "1.2.3", legacy: true })
expect(value).toEqual({ version: "1.2.3" })
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
expect(HighlightsStore.encode(value)).toEqual(value)
})
@@ -1,18 +1,18 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { usePlatform } from "@/runtime/platform/platform"
import { useSettings } from "@/settings/model"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { DialogReleaseNotes, type Highlight } from "@/shell/updates/release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
export const HighlightsStore = Persistence.struct({
version: Schema.UndefinedOr(Schema.String),
export const HighlightsStore = Codec.struct({
version: Codec.undefinedOr(Codec.string),
})
type ParsedRelease = {
@@ -233,3 +233,4 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
}
},
})
+12 -12
View File
@@ -1,20 +1,19 @@
import type { FileContent } from "@/runtime/server/types"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
export const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
export const FileSelection = Codec.struct({
startLine: Codec.number,
startChar: Codec.number,
endLine: Codec.number,
endChar: Codec.number,
})
export type FileSelection = typeof FileSelection.Type
export const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
export const SelectedLineRange = Codec.struct({
start: Codec.number,
end: Codec.number,
side: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
endSide: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
})
export type SelectedLineRange = typeof SelectedLineRange.Type
@@ -44,3 +43,4 @@ export function selectionFromLines(range: SelectedLineRange): FileSelection {
endChar: 0,
}
}
@@ -1,8 +1,7 @@
import { createEffect, createRoot } from "solid-js"
import { produce } from "solid-js/store"
import { Schema } from "effect"
import { Codec } from "@/runtime/persistence/codec"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import { SelectedLineRange } from "./types"
import type { ServerScope } from "@/runtime/server/scope"
@@ -11,14 +10,14 @@ const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const FileViewSchema = Persistence.struct({
scrollTop: Persistence.optional(Schema.Finite),
scrollLeft: Persistence.optional(Schema.Finite),
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
const FileViewSchema = Codec.struct({
scrollTop: Codec.lenientOptional(Codec.number),
scrollLeft: Codec.lenientOptional(Codec.number),
selectedLines: Codec.lenientOptional(Codec.nullOr(SelectedLineRange)),
})
export const FileViewsSchema = Schema.Struct({
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
export const FileViewsSchema = Codec.struct({
file: Codec.lenientRecord(Codec.fallback(FileViewSchema, () => ({}))),
})
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
@@ -150,3 +149,4 @@ export function createFileViewCache(scope: ServerScope) {
clear: () => cache.clear(),
}
}
+2 -1
View File
@@ -5,7 +5,7 @@ import { NodeChildProcessSpawner } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { app, shell, type WebContents } from "electron"
import { homedir } from "node:os"
import { SshConfig, type SshState } from "@opencode/app/ssh"
import { SshConfig, type SshState } from "@opencode/app/ssh/schema"
import { SshChanged } from "../../shared/ipc-rpc/events"
import { DesktopCli } from "../service/desktop-cli"
import { Shutdown } from "../lifecycle/shutdown"
@@ -88,3 +88,4 @@ export const layer = Layer.effect(
return service
}),
).pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(FetchHttpClient.layer))
@@ -3,7 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { SshState } from "@opencode/app/ssh"
import { SshState } from "@opencode/app/ssh/schema"
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
@@ -64,3 +64,4 @@ export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
+2 -1
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh/schema"
export const SshRpcs = RpcGroup.make(
Rpc.make("SshGetState", { success: SshState }),
@@ -15,3 +15,4 @@ export const SshRpcs = RpcGroup.make(
Rpc.make("SshForget", { payload: { id: Schema.String } }),
Rpc.make("SshOpenConfig"),
)