Compare commits

..
Author SHA1 Message Date
Aiden Cline 69c475f053 fix(cli): harden noninteractive runs 2026-09-19 20:19:21 -05:00
86 changed files with 2328 additions and 3111 deletions
+1 -3
View File
@@ -10,8 +10,7 @@
"./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/schema": "./src/servers/ssh/schema.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -95,4 +94,3 @@
"tailwindcss": "4.3.3"
}
}
+1 -15
View File
@@ -3,19 +3,7 @@ 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"
// 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,
})
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
const PartBase = {
content: Schema.String,
@@ -240,5 +228,3 @@ export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pi
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+7 -13
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 { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
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 = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
@@ -114,13 +114,8 @@ export function createHomeProjectsController(home: HomeController) {
extensions: ["json"],
},
async (file) => {
// 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 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({
@@ -189,4 +184,3 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -21,6 +21,7 @@ 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"
@@ -33,12 +34,12 @@ const NewSessionSummary = lazy(async () => {
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Codec.struct({
used: Codec.boolean,
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
})
export const ProviderTipSchema = Codec.struct({
dismissedAt: Codec.number,
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
@@ -266,4 +267,3 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
</Show>
)
}
+22 -11
View File
@@ -1,6 +1,7 @@
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"
@@ -52,24 +53,34 @@ 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))
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]
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],
)
},
connected: () => {
const connected = new Set(providers().connected)
return [...providers().all.values()].filter((p) => connected.has(p.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [...providers().all].filter(
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
},
}
}
+30 -28
View File
@@ -3,11 +3,12 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Schema, SchemaGetter } from "effect"
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"
@@ -17,45 +18,46 @@ import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
variant: Codec.optional(Codec.string),
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Codec.struct({
model: Codec.lenientOptional(ModelKeySchema),
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Codec.struct({
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Codec.lenientOptional(Codec.string),
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
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 SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
)
export const ModelSelectionSchema = Codec.migrate(
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
Current,
Codec.transform(StoredSelection, {
decode: (value) => ({
...value,
session:
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
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),
}),
encode: (value) => value,
}),
),
)
const WORKSPACE_KEY = "__workspace__"
+16 -9
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 { Codec } from "@/runtime/persistence/codec"
import { Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode/ui/context"
import {
I18nProvider,
@@ -12,6 +12,7 @@ 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 {
@@ -55,9 +56,14 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Codec.struct({
locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }),
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 INTL = DESKTOP_NATIVE_LOCALE_TAGS
@@ -154,11 +160,11 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return Codec.decodeOption(LocaleSchema, value) ?? "en"
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
}
export const languageSchema = Codec.struct({
locale: StoredLocaleSchema.fields.locale,
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
@@ -166,7 +172,9 @@ function readStoredLocale() {
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
} catch {
return
}
@@ -288,4 +296,3 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
</I18nProvider>
)
}
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { Codec } from "./codec"
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
test("struct types infer optional and required fields", () => {
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
const value: typeof s.Type = { id: "x" }
const tab: string | undefined = value.tab
const n: number | undefined = value.n
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
const out: string | Codec.Invalid = t.decode({ id: "a" })
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
const empty: typeof onlyOptional.Type = {}
const maybe: string | undefined = empty.tab
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
})
@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "./codec"
describe("Codec", () => {
test("primitives reject the wrong shape and finite numbers only", () => {
expect(Codec.string.decode("a")).toBe("a")
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
expect(Codec.number.decode(1.5)).toBe(1.5)
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
expect(Codec.literal("x").decode("x")).toBe("x")
})
test("struct keeps optional fields absent and rejects invalid required ones", () => {
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
expect(codec.decode([])).toBe(Codec.INVALID)
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
const value: typeof codec.Type = { id: "1", title: undefined }
expect(value.id).toBe("1")
})
test("lenient collections recover what they can", () => {
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
expect(items.decode("nope")).toEqual([])
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
const map = Codec.lenientRecord(Codec.boolean)
expect(map.decode({ a: true, b: "x" })).toEqual({})
expect(map.decode({ a: true })).toEqual({ a: true })
})
test("union, transform and fallback compose", () => {
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
const tab = Codec.union([session, draft])
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
expect(upper.decode("ab")).toBe("AB")
expect(upper.encode("AB")).toBe("ab")
const safe = Codec.fallback(Codec.number, () => 7)
expect(safe.decode("x")).toBe(7)
expect(safe.decode(undefined)).toBe(7)
expect(safe.decode(2)).toBe(2)
})
test("brand constructs and decodes as its base", () => {
const Key = Codec.brand<"ServerConnection.Key">()
const key = Key.make("http://a")
expect(Key.decode(key)).toBe(key)
expect(Key.decode(3)).toBe(Codec.INVALID)
})
test("withInitial recovers field by field and merges new defaults", () => {
const layout = Codec.struct({
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
})
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
sidebar: { opened: false, width: 240 },
theme: "dark",
})
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
expect(codec.decode("{not json")).toBe(Codec.INVALID)
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
})
test("migrate reads the old shape first", () => {
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
const read = Codec.transform(previous, {
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
encode: (value) => ({ tab: value.tabs[0] }),
})
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
})
})
@@ -1,366 +0,0 @@
export * as Codec from "./codec"
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
// graph, where Effect's own module initialisation was the single largest startup cost that was not
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
// value that cannot be recovered, and the lenient combinators recover what they can.
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
export type Invalid = typeof INVALID
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
export interface Of<T, E = unknown> {
readonly [tag]: true
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
readonly Type: T
readonly Encoded: E
readonly optional?: boolean
decode(input: unknown): T | Invalid
encode(value: T): E
}
export type Any = Of<any, any>
export type Type<C extends Any> = C["Type"]
export function isCodec(value: unknown): value is Any {
return typeof value === "object" && value !== null && tag in value
}
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
return { [tag]: true, decode, encode } as Of<T, E>
}
export function is<T>(codec: Of<T>, input: unknown): input is T {
return codec.decode(input) !== INVALID
}
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
const value = codec.decode(input)
return value === INVALID ? undefined : value
}
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
const value = codec.decode(input)
if (value === INVALID) throw new Error("Value does not match its codec")
return value
}
/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */
export function encodeOrThrow<T, E>(codec: Of<T, E>, value: T): E {
const encoded = codec.encode(value)
if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec")
return encoded
}
const identity = <T>(value: T) => value
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
export const nonNegativeInt: Of<number, number> = make(
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
identity,
)
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
return make((v) => (v === value ? value : INVALID), identity)
}
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
const set = new Set<unknown>(values)
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
}
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
make(value: string): string & { readonly [K in B]: B }
} {
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
make: (value: string) => value as never,
})
}
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
}
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
}
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return { ...undefinedOr(codec), optional: true } as never
}
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return {
...make<T | undefined, E | undefined>(
(v) => {
if (v === undefined) return undefined
const value = codec.decode(v)
return value === INVALID ? undefined : value
},
(v) => (v === undefined ? undefined : codec.encode(v)),
),
optional: true,
} as never
}
type Fields = Record<string, Any>
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
type Simplify<T> = { [K in keyof T]: T[K] } & {}
export type StructType<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
>
export type StructEncoded<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
>
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
readonly fields: F
}
// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the
// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides.
export function struct<const F extends Fields>(fields: F, options?: { preserve?: boolean }): Struct<F> {
const entries = Object.entries(fields)
return {
...make<StructType<F>, StructEncoded<F>>(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const record = input as Record<string, unknown>
const out: Record<string, unknown> = options?.preserve ? { ...record } : {}
for (const [key, codec] of entries) {
const present = Object.hasOwn(record, key)
if (!present && codec.optional) continue
const value = codec.decode(record[key])
if (value === INVALID) return INVALID
if (value !== undefined || present) out[key] = value
else delete out[key]
}
return out as StructType<F>
},
(value) => {
const out: Record<string, unknown> = {}
for (const [key, codec] of entries) {
const field = (value as Record<string, unknown>)[key]
if (field === undefined && !Object.hasOwn(value as object, key)) continue
out[key] = codec.encode(field)
}
return out as StructEncoded<F>
},
),
fields,
}
}
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return INVALID
const out: T[] = []
for (const item of input) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out.push(value)
}
return out
},
(value) => value.map((item) => codec.encode(item)),
)
}
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const value = codec.decode(item)
return value === INVALID ? [] : [value]
})
},
(value) => value.map((item) => codec.encode(item)),
)
}
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** A record that drops entries whose values are invalid, the replacement for `catchDecoding` to none. */
export function sparseRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value !== INVALID) out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
const strict = record(codec)
return make(
(input) => {
const value = strict.decode(input)
return value === INVALID ? {} : value
},
strict.encode,
)
}
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
return make(
(input) => {
for (const codec of codecs) {
const value = codec.decode(input)
if (value !== INVALID) return value
}
return INVALID
},
(value) => {
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
return value as C[number]["Encoded"]
},
)
}
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
export function transform<T, E, T2>(
codec: Of<T, E>,
options: { decode: (value: T) => T2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = codec.decode(input)
return value === INVALID ? INVALID : options.decode(value)
},
(value) => codec.encode(options.encode(value)),
)
}
/** Decodes with `source`, maps, then validates with `target`: Effect's `decodeTo` with a transform. */
export function decodeTo<T, E, T2, E2>(
source: Of<T, E>,
target: Of<T2, E2>,
options: { decode: (value: T) => E2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = source.decode(input)
return value === INVALID ? INVALID : target.decode(options.decode(value))
},
(value) => source.encode(options.encode(value)),
)
}
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
return make(
(input) => {
if (input === undefined) return value()
const decoded = codec.decode(input)
return decoded === INVALID ? value() : decoded
},
codec.encode,
)
}
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
return make(
(input) => {
if (typeof input !== "string") return INVALID
try {
return codec.decode(JSON.parse(input))
} catch {
return INVALID
}
},
(value) => JSON.stringify(codec.encode(value)),
)
}
export type Decoder = Pick<Of<unknown>, "decode">
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
return { current, read }
}
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
return !isCodec(definition) && "current" in definition
}
// Stored values recover field by field against the initial value: an object's valid fields are
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
const codec = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition) ? definition.read : unknown
return make(
(input) => {
const stored = read.decode(input)
if (stored === INVALID) return INVALID
return merge(initial, recover(codec, stored, initial))
},
(value) => codec.encode(value),
)
}
function recover(codec: Any, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if ("fields" in codec && isObject(value)) {
const fields = (codec as Struct<Fields>).fields
return Object.fromEntries(
Object.entries(fields).flatMap(([name, field]) => {
const defaults = isObject(initial) ? initial[name] : undefined
const next = recover(field, value[name], defaults)
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
return [[name, next]]
}),
)
}
const decoded = codec.decode(value)
return decoded === INVALID ? initial : decoded
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!isObject(initial) || !isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -3,7 +3,6 @@ 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"
@@ -11,9 +10,9 @@ import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
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))
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 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -26,7 +25,7 @@ describe("persisted consumer schemas", () => {
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(schema, { collapsed: {} }), input)
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
@@ -36,19 +35,21 @@ describe("persisted consumer schemas", () => {
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
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 = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(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(
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {},
pick: { session1: { agent: "plan" } },
}),
@@ -56,7 +57,7 @@ describe("persisted consumer schemas", () => {
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
const state = Schema.decodeUnknownSync(Persistence.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 },
@@ -75,7 +76,7 @@ describe("persisted consumer schemas", () => {
})
test("file views validate scroll positions and line sides independently", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(FileViewsSchema, { file: {} }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
@@ -102,7 +103,7 @@ describe("persisted consumer schemas", () => {
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input)
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
@@ -110,6 +111,3 @@ describe("persisted consumer schemas", () => {
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,5 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Codec } from "./codec"
import { Option, Schema } from "effect"
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 = 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))
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))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
setDocument,
@@ -455,4 +455,3 @@ export async function blobDataUrl(blob: BlobReference, mime: string) {
export function createLegacyBlobReference(dataUrl: string): BlobReference {
return { id: dataUrl, url: dataUrl }
}
+11 -44
View File
@@ -6,7 +6,6 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Codec } from "./codec"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
@@ -473,57 +472,25 @@ export function removePersisted(
}
}
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
| S
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
| Codec.Migrated<Extract<S, Codec.Any>>
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
// stops paying for Effect at startup; both are accepted while the migration is underway.
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
definition: Definition<S>,
initial: S["Type"],
) {
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
const json = Codec.fromJsonString(codec)
return {
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value),
encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value),
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
}
}
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
const initialized = Persistence.withInitial(schema, initial as object)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
return {
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
target: string | PersistTarget,
schema: Definition<S>,
schema: S | Persistence.Migrated<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const codec = serializer<S>(schema, initial)
const { encode, serialize } = codec
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const encode = Schema.encodeSync(initialized)
const serialize = Schema.encodeSync(json)
const normalize = (raw: string) => {
const value = codec.decode(raw)
if (value !== undefined) return serialize(value)
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
}
const store = createStore<S["Type"]>(codec.initial)
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const prefix = `${config.storage ?? "default"}:`
@@ -635,7 +602,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Co
name: config.key,
storage,
serialize,
deserialize: codec.deserialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
// Drafts take the encoded document itself so large text is externalized without the store
// re-parsing the serialized form on every save.
-11
View File
@@ -1,11 +0,0 @@
import type { Brand } from "effect"
import { Codec } from "@/runtime/persistence/codec"
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
{ make: (value: string) => value as ServerKey },
)
@@ -1,14 +1,15 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
import { createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
function serverSchema(canonical?: () => string | undefined) {
return Codec.withInitial(serverState(canonical), initial)
return Persistence.withInitial(serverState(canonical), initial)
}
describe("server persistence schema", () => {
@@ -28,7 +29,7 @@ describe("server persistence schema", () => {
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
const state = Codec.decodeOrThrow(schema, input)
const state = Schema.decodeUnknownSync(schema)(input)
expect(state).toEqual({
list: [
{ type: "http", http: { url: "http://localhost:4096" } },
@@ -47,13 +48,13 @@ describe("server persistence schema", () => {
recentlyClosed: {},
})
expect(input.list[1]).toHaveProperty("username", "legacy")
const encoded = schema.encode(state)
const encoded = Schema.encodeSync(schema)(state)
expect(encoded).toEqual(state)
expect(Codec.decodeOrThrow(schema, encoded)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
})
test("defaults missing or malformed fields and drops invalid entries independently", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(serverSchema(), input))
const decode = Schema.decodeUnknownSync(serverSchema())
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
expect(decode({})).toEqual(empty)
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
@@ -73,7 +74,7 @@ describe("server persistence schema", () => {
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
const schema = serverSchema(() => "https://opencode.example.com")
const state = Codec.decodeOrThrow(schema, {
const state = Schema.decodeUnknownSync(schema)({
list: ["https://opencode.example.com"],
hidden: { "https://opencode.example.com": true },
projects: {
@@ -99,14 +100,14 @@ describe("server persistence schema", () => {
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
expect(schema.encode(state)).toEqual(state)
expect(Codec.decodeOrThrow(schema, state)).toEqual(state)
expect(Schema.encodeSync(schema)(state)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
})
test("reads the latest canonical local prop on each decode", () => {
const props: { canonicalLocalServer?: string } = {}
const schema = serverSchema(() => props.canonicalLocalServer)
const decode = ((input: unknown) => Codec.decodeOrThrow(schema, input))
const decode = Schema.decodeUnknownSync(schema)
const input = {
projects: { remote: [{ worktree: "/project", expanded: true }] },
lastProject: { remote: "/project" },
@@ -121,7 +122,7 @@ describe("server persistence schema", () => {
})
test("migrates a last project without a project list", () => {
expect(Codec.decodeOrThrow(serverSchema(() => "remote"), { lastProject: { remote: "/project" } })).toEqual({
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
list: [],
hidden: {},
projects: {},
@@ -133,7 +134,7 @@ describe("server persistence schema", () => {
describe("model persistence schema", () => {
test("defaults missing state and keeps valid entries beside malformed entries", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelState, { user: [], recent: [], variant: {} }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
const state = decode({
@@ -154,24 +155,24 @@ describe("model persistence schema", () => {
recent: [{ providerID: "provider", modelID: "model" }],
variant: { model: "high" },
})
expect(ModelState.encode(state)).toEqual(state)
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
})
})
describe("directory cache schemas", () => {
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(VcsState, { value: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
expect(VcsState.encode(state)).toEqual(state)
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
})
test("validates project name, icon overrides and startup commands", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProjectState, { value: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: [] })).toEqual({ value: undefined })
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
@@ -184,7 +185,7 @@ describe("directory cache schemas", () => {
commands: { start: "bun dev" },
},
})
expect(ProjectState.encode(state)).toEqual(state)
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
expect(state.value).toEqual({
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
@@ -193,12 +194,12 @@ describe("directory cache schemas", () => {
})
test("validates optional icon strings", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(IconState, { value: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: 42 })).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: "" })).toEqual({ value: "" })
expect(IconState.encode(decode({ value: "data:image/png;base64,abc" }))).toEqual({
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
value: "data:image/png;base64,abc",
})
})
@@ -254,7 +255,7 @@ test.skipIf(isServer)(
const stored = values.get("opencode.global.dat:server")
expect(stored).toBeDefined()
if (!stored) throw new Error("server state was not written")
const decoded = Codec.decodeOrThrow(Codec.fromJsonString(serverSchema()), stored)
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
expect(stored).not.toContain("username")
expect(decoded.list).toEqual(root.state[0].list)
@@ -263,4 +264,3 @@ test.skipIf(isServer)(
}
},
)
+103 -94
View File
@@ -1,127 +1,136 @@
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "./key"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
export const ServerHttpBase = Codec.struct({
url: Codec.string,
password: Codec.optional(Codec.string),
export const ServerHttpBase = Persistence.struct({
url: Schema.String,
password: Schema.optional(Schema.String),
})
export const ServerHttp = Codec.struct({
type: Codec.literal("http"),
export const ServerHttp = Persistence.struct({
type: Schema.Literal("http"),
http: ServerHttpBase,
authToken: Codec.optional(Codec.boolean),
displayName: Codec.optional(Codec.string),
label: Codec.optional(Codec.string),
authToken: Schema.optional(Schema.Boolean),
displayName: Schema.optional(Schema.String),
label: Schema.optional(Schema.String),
})
// Servers were stored as a URL string, then as the HTTP block alone, before the current shape.
const StoredServer = Codec.decodeTo(Codec.union([ServerHttp, ServerHttpBase, Codec.string]), ServerHttp, {
decode: (value) => {
if (typeof value === "string") return { type: "http" as const, http: { url: value } }
if ("http" in value) return value
return { type: "http" as const, http: value }
},
encode: (value) => value,
})
const ProjectList = Codec.lenientArray(
Codec.struct({
worktree: Codec.string,
expanded: Codec.fallback(Codec.boolean, () => true),
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
}),
)
const Projects = Codec.lenientRecord(ProjectList)
const LastProject = Codec.fallback(Codec.sparseRecord(Codec.string), () => ({}))
const State = Codec.struct({
list: Codec.lenientArray(StoredServer),
hidden: Codec.sparseRecord(Codec.boolean),
projects: Codec.record(ProjectList),
lastProject: Codec.sparseRecord(Codec.string),
recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)),
const ProjectList = Persistence.array(
Persistence.struct({
worktree: Schema.String,
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
list: Persistence.array(StoredServer),
hidden: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
lastProject: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
})
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true })
// Projects and last-opened entries recorded under the canonical local server's URL move under
// "local" when that URL is known, so they survive the server changing address.
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Codec.migrate(
return Persistence.migrate(
State,
Codec.transform(StoredState, {
decode: (value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
},
encode: (value) => value,
}),
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
}),
),
)
}
export const ModelState = Codec.struct({
user: Codec.lenientArray(
Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
visibility: Codec.literals(["show", "hide"]),
favorite: Codec.optional(Codec.boolean),
export const ModelState = Persistence.struct({
user: Persistence.array(
Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
visibility: Schema.Literals(["show", "hide"]),
favorite: Schema.optional(Schema.Boolean),
}),
),
recent: Codec.lenientArray(Codec.struct({ providerID: Codec.string, modelID: Codec.string })),
variant: Codec.sparseRecord(Codec.undefinedOr(Codec.string)),
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
variant: Schema.Record(
Schema.String,
Schema.mutableKey(
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
),
})
export const VcsState = Codec.struct({
value: Codec.optional(
Codec.struct({
branch: Codec.optional(Codec.string),
default_branch: Codec.optional(Codec.string),
export const VcsState = Persistence.struct({
value: Schema.optional(
Persistence.struct({
branch: Schema.optional(Schema.String),
default_branch: Schema.optional(Schema.String),
}),
),
})
const ProjectMeta = Codec.struct({
name: Codec.optional(Codec.string),
icon: Codec.optional(
Codec.struct({
override: Codec.optional(Codec.string),
color: Codec.optional(Codec.string),
const ProjectMeta = Persistence.struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(
Persistence.struct({
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
}),
),
commands: Codec.optional(Codec.struct({ start: Codec.optional(Codec.string) })),
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
})
export const ProjectState = Codec.struct({
value: Codec.optional(ProjectMeta),
export const ProjectState = Persistence.struct({
value: Schema.optional(ProjectMeta),
})
export const IconState = Codec.struct({
value: Codec.optional(Codec.string),
export const IconState = Persistence.struct({
value: Schema.optional(Schema.String),
})
@@ -1,12 +1,13 @@
import { describe, expect, test } from "bun:test"
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
import { Schema } from "effect"
import { serverState } from "./persistence"
import { createStore } from "solid-js/store"
import { ServerScope } from "./scope"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
function serverSchema() {
return Codec.withInitial(serverState(), {
return Persistence.withInitial(serverState(), {
list: [],
hidden: {},
projects: {},
@@ -18,7 +19,7 @@ function serverSchema() {
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: Codec.decodeOrThrow(serverSchema(), { list: [{ url: "https://server.example.test" }] }).list,
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
props: [
{
type: "http",
@@ -43,7 +44,7 @@ describe("resolveServerList", () => {
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: Codec.decodeOrThrow(serverSchema(), {
stored: Schema.decodeUnknownSync(serverSchema())({
list: [{ url: "https://server.example.test", password: "saved" }],
}).list,
props: [{ type: "http", http: { url: "https://server.example.test" } }],
@@ -76,7 +77,7 @@ test("treats WSL sidecars as remote server connections", () => {
})
test("keeps exact persisted server identities and prevents removing provided servers", () => {
const stored = Codec.decodeOrThrow(serverSchema(), {
const stored = Schema.decodeUnknownSync(serverSchema())({
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
}).list
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
@@ -90,7 +91,7 @@ test("keeps exact persisted server identities and prevents removing provided ser
})
test("project actions update schema-derived state and follow dynamic server scopes", () => {
const [store, setStore] = createStore(Codec.decodeOrThrow(serverSchema(), {}))
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
server: ServerConnection.Key.make("https://remote.example"),
}
@@ -114,4 +115,3 @@ test("project actions update schema-derived state and follow dynamic server scop
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
})
+12 -13
View File
@@ -1,12 +1,12 @@
import { Codec } from "@/runtime/persistence/codec"
import { Option, Schema } from "effect"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Codec.fromJsonString(
Codec.struct({
urls: Codec.array(Codec.string),
username: Codec.literal("opencode"),
password: Codec.string,
}),
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
@@ -20,10 +20,9 @@ export function serverAddress(value: string) {
}
export function decodePairingCode(value: string) {
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 }
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 }
}
+51 -44
View File
@@ -1,3 +1,4 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
@@ -23,8 +24,7 @@ export function createSshController(input: {
| undefined
>
>({})
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
const tasks = new Map<string, { cancelled: boolean }>()
const tasks = new Map<string, Fiber.Fiber<void>>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
@@ -33,32 +33,31 @@ export function createSshController(input: {
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
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)
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 }))
})
createEffect(() => {
for (const item of input.items()) {
@@ -115,32 +114,40 @@ export function createSshController(input: {
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(config.id, async () => {
await api.start({ ...config, replace: options?.replace })
// Observe admission before treating an older disconnected snapshot as cancellation.
await input.refresh()
})
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)
}),
)
},
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, () => api.respond(id, prompt, value))
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
},
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)
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),
)
},
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
@@ -1,61 +0,0 @@
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
+60 -8
View File
@@ -1,9 +1,63 @@
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 }
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 type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
@@ -16,5 +70,3 @@ export type SshPlatform = {
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
@@ -17,4 +18,3 @@ describe("open app preferences", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,7 +5,8 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
@@ -29,8 +30,8 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Codec.struct({
app: Codec.literals(OPEN_APPS),
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
@@ -241,4 +242,3 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
appExistence.set(app, request)
return request
}
+8 -12
View File
@@ -5,20 +5,17 @@ import {
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
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"]),
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"]),
})
export function createReviewPanelState(platform?: Platform) {
@@ -50,4 +47,3 @@ export function createReviewPanelState(platform?: Platform) {
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -2,8 +2,9 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
@@ -20,10 +21,10 @@ beforeAll(async () => {
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
roundTripTerminalState = (value) =>
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -140,4 +141,3 @@ describe("TerminalState", () => {
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+31 -33
View File
@@ -8,20 +8,18 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
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),
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),
})
export type LocalPTY = typeof PTY.Type
@@ -33,26 +31,28 @@ function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
const State = Codec.struct({
active: Codec.lenientOptional(Codec.string),
all: Codec.lenientArray(PTY),
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
})
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 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 function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -458,5 +458,3 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
},
})
@@ -1,40 +1,40 @@
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,
})
})
})
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,
})
})
})
@@ -2,7 +2,8 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -14,11 +15,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 = 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),
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),
})
function goUpsellKeys(status: SessionStatus) {
@@ -105,4 +106,3 @@ export function useUsageExceededDialogs() {
}),
)
}
+8 -10
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
settingsPersistence,
@@ -12,9 +13,9 @@ import {
terminalFontFamily,
} from "./model"
const schema = Codec.withInitial(settingsPersistence, defaultSettings)
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const encode = (value: typeof settingsSchema.Type) => schema.encode(value)
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
@@ -50,14 +51,14 @@ describe("settings schema", () => {
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input)
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow()
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
test("supplies the existing defaults for an empty document", () => {
@@ -170,7 +171,7 @@ describe("settings schema", () => {
test("does not silently repair invalid values during encoding", () => {
expect(() =>
Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never),
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
).toThrow()
})
})
@@ -202,6 +203,3 @@ describe("settings font families", () => {
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
})
})
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5,7 +5,8 @@ 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 { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -19,8 +20,8 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
@@ -210,4 +211,3 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
</>
)
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import {
activeCommandRegistrations,
addCommandRegistration,
@@ -10,12 +10,12 @@ import {
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(CommandCatalog, input))
const decode = Schema.decodeUnknownSync(CommandCatalog)
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(CommandCatalog.encode(catalog))).toEqual(catalog)
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
@@ -79,4 +79,3 @@ describe("resolveKeybindOption", () => {
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+10 -11
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 { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
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 = 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 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 type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Codec.record(CommandCatalogItem)
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
@@ -480,4 +480,3 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
},
})
@@ -1,22 +1,23 @@
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 { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
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 = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(NotificationStore, { list: [] }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
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(NotificationStore.encode(store))).toEqual(store)
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
@@ -40,4 +41,3 @@ 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 { Codec } from "@/runtime/persistence/codec"
import type { SessionError } from "@opencode/schema/session-error"
import { Schema } from "effect"
import { 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,28 +20,19 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
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 }),
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 }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Codec.struct({ list: Codec.lenientArray(Notification) })
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
type NotificationIndex = {
session: {
@@ -377,5 +368,3 @@ export const useNotification = () => {
const server = useServer()
return server.ctx.notification
}
+8 -8
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
@@ -10,21 +11,21 @@ test("settings has its own layout route", () => {
})
describe("layout persistence", () => {
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
@@ -55,8 +56,8 @@ describe("layout persistence", () => {
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(schema.encode(value)).toEqual(value)
expect(decode(schema.encode(value))).toEqual(value)
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
@@ -168,4 +169,3 @@ describe("pruneSessionKeys", () => {
expect(drop).toEqual([])
})
})
File diff suppressed because it is too large Load Diff
+50 -45
View File
@@ -1,58 +1,63 @@
export * as TabStorage from "./schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "@/runtime/server/key"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const Session = Codec.struct({
type: Codec.literal("session"),
export const Session = Persistence.struct({
type: Schema.Literal("session"),
server: ServerKey,
sessionId: Codec.string,
routeSessionId: Codec.lenientOptional(Codec.string),
routeParentId: Codec.lenientOptional(Codec.string),
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
})
export const Draft = Codec.struct({
type: Codec.literal("draft"),
draftID: Codec.string,
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
server: ServerKey,
directory: Codec.string,
worktree: Codec.lenientOptional(Codec.string),
branch: Codec.lenientOptional(Codec.string),
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
})
// A stored route that only repeats the session id carries nothing; drop it and its parent.
const SessionCodec = Codec.transform(Session, {
decode: (tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
}),
encode: (tab) => tab,
})
export const Tab = Codec.union([Session, Draft])
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
export const Recent = Codec.struct({
key: Codec.optional(Codec.string),
})
export const Info = Codec.struct({
title: Codec.optional(Codec.string),
directory: Codec.optional(Codec.string),
})
export const Infos = Codec.record(Info)
export const Panes = Codec.record(
Codec.struct({
terminal: Codec.optional(Codec.boolean),
review: Codec.optional(Codec.boolean),
terminalHeight: Codec.optional(Codec.number),
sessionWidth: Codec.optional(Codec.number),
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
}),
)
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
export const Closed = Codec.lenientArray(ClosedTab)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.optional(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
+14 -15
View File
@@ -3,12 +3,13 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -25,7 +26,7 @@ describe("tab migration", () => {
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
@@ -63,13 +64,13 @@ describe("tab migration", () => {
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
@@ -80,16 +81,16 @@ describe("tab migration", () => {
test("validates auxiliary tab state", () => {
expect(
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
).toEqual({ key: undefined })
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
})
})
@@ -208,5 +209,3 @@ describe("closed tab stack", () => {
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
@@ -1,13 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(HighlightsStore, { version: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
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(HighlightsStore.encode(value)).toEqual(value)
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
})
@@ -1,18 +1,18 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
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 = Codec.struct({
version: Codec.undefinedOr(Codec.string),
export const HighlightsStore = Persistence.struct({
version: Schema.UndefinedOr(Schema.String),
})
type ParsedRelease = {
@@ -233,4 +233,3 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
}
},
})
+12 -12
View File
@@ -1,19 +1,20 @@
import type { FileContent } from "@/runtime/server/types"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const FileSelection = Codec.struct({
startLine: Codec.number,
startChar: Codec.number,
endLine: Codec.number,
endChar: Codec.number,
export const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
export type FileSelection = typeof FileSelection.Type
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 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 type SelectedLineRange = typeof SelectedLineRange.Type
@@ -43,4 +44,3 @@ export function selectionFromLines(range: SelectedLineRange): FileSelection {
endChar: 0,
}
}
@@ -1,7 +1,8 @@
import { createEffect, createRoot } from "solid-js"
import { produce } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
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"
@@ -10,14 +11,14 @@ const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const FileViewSchema = Codec.struct({
scrollTop: Codec.lenientOptional(Codec.number),
scrollLeft: Codec.lenientOptional(Codec.number),
selectedLines: Codec.lenientOptional(Codec.nullOr(SelectedLineRange)),
const FileViewSchema = Persistence.struct({
scrollTop: Persistence.optional(Schema.Finite),
scrollLeft: Persistence.optional(Schema.Finite),
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
})
export const FileViewsSchema = Codec.struct({
file: Codec.lenientRecord(Codec.fallback(FileViewSchema, () => ({}))),
export const FileViewsSchema = Schema.Struct({
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
})
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
@@ -149,4 +150,3 @@ export function createFileViewCache(scope: ServerScope) {
clear: () => cache.clear(),
}
}
+65 -21
View File
@@ -60,7 +60,17 @@ type ToolState = StartedPart & {
}
type V2Event = EventSubscribeOutput
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
type FormRequest = {
id: string
sessionID: string
metadata?: Readonly<Record<string, unknown>>
fields: ReadonlyArray<{
key: string
type: string
default?: unknown
options?: ReadonlyArray<{ value: string }>
}>
}
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
// session. An exclusive local process may treat them as this run's blockers; an
@@ -79,6 +89,7 @@ export async function runNonInteractivePrompt(input: Input) {
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
const sessions = new Set([input.sessionID])
let submitted = false
let promoted = false
let emittedError = false
@@ -132,7 +143,12 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
const replyPermission = async (request: {
id: string
sessionID: string
action: string
resources: ReadonlyArray<string>
}) => {
if (!input.auto) {
permissionRejected = true
UI.println(
@@ -143,13 +159,13 @@ export async function runNonInteractivePrompt(input: Input) {
}
await input.client.permission
.reply({
sessionID: input.sessionID,
sessionID: request.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.client.session.interrupt({ sessionID: request.sessionID }).catch(() => {})
}
}
@@ -165,6 +181,23 @@ export async function runNonInteractivePrompt(input: Input) {
formCancelled = true
}
const settleForm = async (request: FormRequest) => {
const field =
request.metadata?.kind === "websearch.provider"
? request.fields.find((field) => field.type === "string" && field.options?.length)
: undefined
const value = typeof field?.default === "string" ? field.default : field?.options?.[0]?.value
if (!field || value === undefined) return cancelForm(request)
try {
await input.client.session.form.reply(
{ sessionID: request.sessionID, formID: request.id, answer: { [field.key]: value } },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next().catch((error) => {
@@ -177,19 +210,23 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "session.created" && event.data.parentID && sessions.has(event.data.parentID)) {
sessions.add(event.data.sessionID)
continue
}
if (event.type === "permission.asked" && submitted && sessions.has(event.data.sessionID)) {
await replyPermission(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
(sessions.has(event.data.form.sessionID) ||
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
) {
await cancelForm(event.data.form)
await settleForm(event.data.form)
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
@@ -476,28 +513,31 @@ export async function runNonInteractivePrompt(input: Input) {
if (interrupted || permissionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.execution.failed") {
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
flushStep()
if (!emittedError && !formCancelled) {
emittedError = true
if (!formCancelled) {
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
if (!emittedError) {
emittedError = true
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
}
}
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
if (event.data.reason !== "user") {
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
if (!emittedError) {
emittedError = true
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
}
}
return
}
@@ -525,9 +565,11 @@ export async function runNonInteractivePrompt(input: Input) {
const reconcile = async () => {
const projected = await projectedMessages()
let projectedError: { error: { message: string; [key: string]: unknown }; timestamp: number } | undefined
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
projectedError = message.error ? { error: message.error, timestamp } : undefined
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
@@ -619,11 +661,13 @@ export async function runNonInteractivePrompt(input: Input) {
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
}
if (projectedError && !interrupted && !permissionRejected && !formCancelled) {
process.exitCode = 1
if (!emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
if (!emit("error", projectedError.timestamp, { error: projectedError.error }))
UI.error(projectedError.error.message)
}
}
return {
@@ -706,9 +750,9 @@ export async function runNonInteractivePrompt(input: Input) {
])
await Promise.all([
...(permissions ?? []).map(replyPermission),
...(forms ?? []).map(cancelForm),
...(forms ?? []).map(settleForm),
...(globals && sameLocation(globals.location, input.location)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(settleForm)
: []),
])
if (input.compatibility === "v1") {
+165 -3
View File
@@ -24,6 +24,27 @@ function form(id: string, sessionID: string): FormInfo {
}
}
function webSearchForm(id: string, sessionID: string): FormInfo {
return {
id,
sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
type: "string",
required: true,
custom: false,
options: [
{ value: "allow", label: "Allow search" },
{ value: "disable", label: "Disable search" },
],
},
],
}
}
function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
}
@@ -38,6 +59,37 @@ function prompted(inboxID: string): V2Event {
}
}
function childCreated(): V2Event {
return {
id: "evt_child_created",
created: 0,
type: "session.created",
durable: { aggregateID: "ses_child", seq: 0, version: 1 },
data: {
sessionID: "ses_child",
projectID: "proj_1",
location,
parentID: "ses_1",
slug: "child",
version: "test",
},
}
}
function permissionAsked(sessionID: string): V2Event {
return {
id: "evt_permission",
created: 1,
type: "permission.asked",
data: {
id: "per_1",
sessionID,
action: "shell",
resources: ["rm file"],
},
}
}
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
if (outcome === "interrupted")
return {
@@ -210,9 +262,11 @@ async function run(input: {
turn: (inboxID: string) => V2Event[]
pendingForms?: FormInfo[]
attached?: boolean
auto?: boolean
format?: "default" | "json"
compatibility?: "v1"
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
reply?: (input: { sessionID: string; formID: string; answer: Record<string, unknown> }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
@@ -241,6 +295,7 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.permission, "reply").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
@@ -252,6 +307,8 @@ async function run(input: {
}) as never,
)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session.form, "reply").mockImplementation((request) => (input.reply?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session, "interrupt").mockImplementation(() => ok(undefined) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -276,7 +333,7 @@ async function run(input: {
files: [],
thinking: false,
format: input.format ?? "default",
auto: false,
auto: input.auto ?? false,
attached: input.attached ?? false,
compatibility: input.compatibility,
renderTool: input.renderTool ?? (() => Promise.resolve()),
@@ -312,6 +369,105 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps exit zero when a failed step is recovered", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), stepStarted(), stepFailed("socket closed"), settled()],
})
expect(output.exitCode ?? 0).toBe(0)
expect(output.stdout).toContain('"type":"error"')
expect(output.stdout).toContain("socket closed")
})
test("keeps terminal execution failures fatal after a failed step", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), stepFailed("socket closed"), executionFailed("retries exhausted")],
})
expect(output.exitCode).toBe(1)
})
test("does not infer failure from a recovered projected step", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), settled()],
messages: (messageID) => [
{
id: "msg_success",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "recovered" }],
finish: "stop",
time: { created: 4, completed: 5 },
},
{
id: "msg_failed",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "error",
error: { type: "provider.transport", message: "socket closed" },
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
})
expect(output.exitCode).toBe(0)
expect(output.stdout).toContain("recovered")
})
test("selects the default web search option instead of cancelling", async () => {
const sdk = await run({
turn: (messageID) => [formCreated(webSearchForm("frm_search", "ses_1")), prompted(messageID), settled()],
})
expect(sdk.session.form.reply).toHaveBeenCalledWith({
sessionID: "ses_1",
formID: "frm_search",
answer: { choice: "allow" },
})
expect(sdk.session.form.cancel).not.toHaveBeenCalled()
})
test("rejects blockers owned by child sessions", async () => {
const sdk = await run({
turn: (messageID) => [
prompted(messageID),
childCreated(),
permissionAsked("ses_child"),
formCreated(form("frm_child", "ses_child")),
settled(),
],
})
expect(sdk.permission.reply).toHaveBeenCalledWith({
sessionID: "ses_child",
requestID: "per_1",
decision: "reject",
})
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_child" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_child", formID: "frm_child" })
})
test("auto-approves permissions owned by child sessions", async () => {
const sdk = await run({
auto: true,
turn: (messageID) => [prompted(messageID), childCreated(), permissionAsked("ses_child"), settled()],
})
expect(sdk.permission.reply).toHaveBeenCalledWith({
sessionID: "ses_child",
requestID: "per_1",
decision: "once",
})
expect(sdk.session.interrupt).not.toHaveBeenCalled()
})
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
@@ -429,7 +585,10 @@ describe("runNonInteractivePrompt", () => {
}
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
globalOptions,
)
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
@@ -443,7 +602,10 @@ describe("runNonInteractivePrompt", () => {
})
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_live" },
expect.anything(),
)
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
File diff suppressed because one or more lines are too long
-28
View File
@@ -28,29 +28,6 @@ const sentry =
})
: false
// Every module the entry reaches through static imports lands in one chunk. Automatic splitting
// otherwise fragments the initial graph into ~50 files shared with lazy routes, and each file costs
// the renderer a main-thread request round trip through the main process before first paint.
type ChunkingContext = { getModuleInfo(id: string): { isEntry: boolean; importers: readonly string[] } | null }
const initialGraph = new WeakMap<ChunkingContext, Map<string, boolean>>()
function inInitialGraph(id: string, ctx: ChunkingContext) {
const memo = initialGraph.get(ctx) ?? new Map<string, boolean>()
initialGraph.set(ctx, memo)
const visit = (id: string, path: Set<string>): boolean => {
const known = memo.get(id)
if (known !== undefined) return known
if (path.has(id)) return false
const info = ctx.getModuleInfo(id)
if (!info) return false
path.add(id)
const result = info.isEntry || info.importers.some((importer) => visit(importer, path))
path.delete(id)
memo.set(id, result)
return result
}
return visit(id, new Set())
}
export default defineConfig(({ command }) => ({
main: {
resolve: {
@@ -133,11 +110,6 @@ const require = __cjs_mod__.createRequire(import.meta.url);
input: {
main: "src/renderer/index.html",
},
output: {
codeSplitting: {
groups: [{ name: (id, ctx) => (inInitialGraph(id, ctx) ? "app" : null), priority: 10 }],
},
},
},
},
},
+4 -17
View File
@@ -191,14 +191,8 @@ const phaseOrder = [
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["window → renderer assets served", "windowVisible", "rendererAssetsServed"],
["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"],
["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"],
["onboarding → logging ready", "onboardingDecided", "loggingReady"],
["logging → first log line", "loggingReady", "appStarting"],
["first log line → storage open", "appStarting", "storageOpen"],
["storage → initialization done", "storageOpen", "initializationDone"],
["initialization → layers ready", "initializationDone", "layersReady"],
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
["layers → first log line", "bundleEvaluated", "appStarting"],
["layers → renderer process", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
@@ -403,15 +397,8 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
entryStart: main.marks.entry && main.marks.entry - spawnAt,
electronReady: main.marks.ready && main.marks.ready - spawnAt,
rendererAssetsServed: main.marks.served && main.marks.served - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt,
loggingReady: main.marks.logging && main.marks.logging - spawnAt,
crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
storageOpen: main.marks.storage && main.marks.storage - spawnAt,
initializationDone: main.marks.init && main.marks.init - spawnAt,
layersReady: main.marks.layers && main.marks.layers - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
@@ -606,8 +593,8 @@ function mainLog() {
// A window shown before the logger existed reports when it was shown; the line itself is later.
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
if (shown) windowShownAt = Number(shown)
if (/app starting|layers ready/.test(message))
for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value)
if (/app starting/.test(message))
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
}
}
@@ -1,10 +0,0 @@
param([Parameter(Mandatory)][string]$Name, [int]$Runs = 5)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot\..
# electron-vite directly: the package's prebuild hook re-downloads the CLI, which the bench keeps fixed.
bunx electron-vite build 2>&1 | Select-String -Pattern "built in|error" | Select-Object -Last 3
if (-not (Test-Path out\main\index.js)) { throw "build failed" }
bunx electron-builder --win --dir --config electron-builder.config.ts 2>&1 | Select-String -Pattern "error|signing with signtool.*OpenCode Dev" | Select-Object -Last 2
if (Test-Path "dist\$Name-unpacked") { Remove-Item "dist\$Name-unpacked" -Recurse -Force }
Rename-Item -Path dist\win-unpacked -NewName "$Name-unpacked"
bun ./scripts/bench-startup.ts --exe "dist\base-unpacked\OpenCode Dev.exe" --compare "dist\$Name-unpacked\OpenCode Dev.exe" --runs $Runs --warmup 1 --window-at=-1700,20 --out "dist\bench-startup\$Name" 2>&1 | Select-String -Pattern "^warm service|^\s{2,}|^phases|^\S+\s+\d+\s+\(|^report|^warm-up|Error|error" | Select-Object -Last 45
@@ -1,94 +0,0 @@
// Attribute a renderer .cpuprofile's self time to original source files through the build's
// source maps. Run with: bun scripts/profile-by-source.ts <profile.cpuprofile> [out/renderer/assets]
import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { TraceMap, originalPositionFor } from "C:/Users/Lukem/.local/share/opencode/worktree/6c1049/quiet-wolf-2/node_modules/.bun/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
const profilePath = process.argv[2]!
const assets = process.argv[3] ?? "out/renderer/assets"
const profile = JSON.parse(readFileSync(profilePath, "utf8"))
const maps = new Map<string, TraceMap>()
for (const name of readdirSync(assets).filter((f) => f.endsWith(".js.map"))) {
maps.set(name.slice(0, -4), new TraceMap(JSON.parse(readFileSync(join(assets, name), "utf8"))))
}
const nodes = new Map<number, any>()
for (const n of profile.nodes) nodes.set(n.id, n)
const self = new Map<string, number>()
const byPkg = new Map<string, number>()
const group = (source: string) => {
const n = source.replace(/\\/g, "/")
const nm = n.match(/node_modules\/(?:\.bun\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)(?:\/dist\/([^/]+))?/)
if (nm) return nm[1] === "effect" ? `effect/${(nm[2] ?? "").replace(/\.js$/, "")}` : nm[1]
const pk = n.match(/packages\/([^/]+)\/src\/(.+)$/)
return pk ? `${pk[1]}/${pk[2]}` : n.slice(-50)
}
const parent = new Map<number, number>()
for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id)
const resolve = (frame: any) => {
const name = frame.functionName
if (name === "(program)" || name === "(garbage collector)") return { label: name, fn: name }
const file = frame.url.split("/").pop()
const map = maps.get(file)
if (!map) return { label: `(no map) ${file}`, fn: name }
const pos = originalPositionFor(map, { line: frame.lineNumber + 1, column: frame.columnNumber })
return { label: pos.source ? group(pos.source) : `(unmapped) ${file}`, fn: pos.name ?? name }
}
const labelOf = (frame: any) => resolve(frame).label
// A sample belongs to the render phase once Solid's root is on the stack; everything before that is
// module evaluation, everything after the first render is later work (hydration, effects, timers).
const stackHas = (id: number, test: (label: string, fn: string) => boolean) => {
for (let cur: number | undefined = id; cur !== undefined; cur = parent.get(cur)) {
const resolved = resolve(nodes.get(cur).callFrame)
if (test(resolved.label, resolved.fn)) return true
}
return false
}
const phases = { evaluate: new Map<string, number>(), render: new Map<string, number>(), later: new Map<string, number>() }
let phase: keyof typeof phases = "evaluate"
let t = 0
let total = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") {
if (phase === "render" && dt > 5) phase = "later"
continue
}
total += dt
if (phase === "evaluate" && stackHas(node.id, (label, fn) => label === "solid-js" && (fn === "render" || fn === "createRoot")))
phase = "render"
const label = labelOf(node.callFrame)
const bucket = phases[phase]
bucket.set(label, (bucket.get(label) ?? 0) + dt)
self.set(label, (self.get(label) ?? 0) + dt)
const pkg = label.split("/").slice(0, label.startsWith("effect/") || label.startsWith("@") ? 2 : 1).join("/")
byPkg.set(pkg, (byPkg.get(pkg) ?? 0) + dt)
}
console.log(`busy ${total.toFixed(0)} ms over ${t.toFixed(0)} ms`)
void phases
// Timeline: 25 ms buckets with the top sources, so module evaluation, render and hydration show as bands.
const buckets = new Map<number, Map<string, number>>()
t = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") continue
const b = Math.floor(t / 25) * 25
const m = buckets.get(b) ?? new Map()
const label = labelOf(node.callFrame).replace(/^(\.\.\/)+/, "")
m.set(label, (m.get(label) ?? 0) + dt)
buckets.set(b, m)
}
console.log("\n== timeline (25 ms buckets) ==")
for (const [b, m] of [...buckets].sort((a, c) => a[0] - c[0])) {
const busy = [...m.values()].reduce((a, c) => a + c, 0)
if (busy < 1) continue
const top = [...m].sort((a, c) => c[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" | ")
console.log(String(b).padStart(5), busy.toFixed(0).padStart(3), top)
}
console.log("\n== by package (all) ==")
for (const [k, v] of [...byPkg].sort((a, b) => b[1] - a[1]).slice(0, 20)) console.log(v.toFixed(1).padStart(7), k)
-8
View File
@@ -5,7 +5,6 @@ import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { DesktopLogging } from "./native/logging"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
@@ -16,15 +15,8 @@ marks.bundle = Date.now()
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
marks.layers = Date.now()
yield* Effect.logInfo("layers ready", { marks })
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
yield* Effect.sleep("500 millis")
const logging = yield* DesktopLogging.Service
yield* logging.startCrashReporter
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
+1 -10
View File
@@ -2,10 +2,7 @@
import { marks } from "./lifecycle/marks"
import { app } from "electron"
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
import { startSidecarProbe } from "./service/sidecar-probe"
import { registerStorageSnapshotHandler } from "./storage/snapshot"
import { createEarlyWindow } from "./windows/early"
import { rendererAssetsServed } from "./windows/protocol"
import { registerRendererScheme } from "./windows/scheme"
// This module stays small on purpose. Electron holds the ready event until the entry module has
@@ -17,16 +14,10 @@ if (acquireApplicationLock()) {
registerRendererScheme()
// Window first, then the bundle: starting the import before ready delays ready itself, because the
// module graph evaluates on the same thread Chromium needs to finish initialising.
void app.whenReady().then(async () => {
void app.whenReady().then(() => {
marks.ready = Date.now()
registerStorageSnapshotHandler()
createEarlyWindow()
marks.window = Date.now()
startSidecarProbe()
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
marks.served = Date.now()
return import("./desktop")
})
}
@@ -4,7 +4,7 @@ import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/scheme"
import { isRendererUrl } from "../windows/protocol"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
+2 -5
View File
@@ -65,15 +65,12 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
const post = () => {
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
}
win.webContents.on("did-finish-load", post)
// The first window starts loading before the layers exist and may already be done.
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
})
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { enableCompileCache } from "node:module"
import { homedir, tmpdir } from "node:os"
import path from "node:path"
import { app } from "electron"
@@ -32,8 +31,6 @@ export function configureApplication() {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
// V8 bytecode for the main bundle survives between launches, like the renderer's code cache.
enableCompileCache(path.join(app.getPath("userData"), "compile-cache"))
}
export function acquireApplicationLock() {
@@ -4,7 +4,6 @@ import { app } from "electron"
import { Context, Effect, Layer } from "effect"
import { DesktopLogging } from "../native/logging"
import { getStore } from "../storage/store"
import { marks } from "./marks"
import {
loadProxyEnvironment,
preferApplicationEnvironment,
@@ -23,15 +22,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
// System certificates, the proxy and the net log serve later network work; the first window and
// its IPC port do not wait for them.
yield* Effect.forkScoped(
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
)
yield* loadProxyEnvironment
yield* Effect.promise(() => app.whenReady())
yield* logging.startNetwork
yield* prepareDesktop
marks.init = Date.now()
return Service.of({
version: app.getVersion(),
updaterStore: getStore("opencode.updater"),
@@ -4,8 +4,7 @@ import { app } from "electron"
import { Effect, Path } from "effect"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows"
import { scoped } from "../native/logging"
import { registerRendererProtocol, setDockIcon } from "../windows"
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
@@ -38,11 +37,7 @@ export const prepareDesktop = Effect.gen(function* () {
const paths = yield* DesktopPaths.resolve
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
const runFork = Effect.runForkWith(yield* Effect.context())
setProtocolReporter((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
registerRendererProtocol(paths.rendererRoot)
yield* registerRendererProtocol()
setDockIcon(path, paths)
})
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { marks } from "./marks"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
@@ -158,7 +157,6 @@ export const layer = Layer.unwrap(
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
marks.onboarding = Date.now()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+3 -6
View File
@@ -1,7 +1,4 @@
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
// layer reports the early ones with "app starting" and the rest with "layers ready", so the startup
// benchmark can split the time before the renderer gets its IPC port into Electron's own
// initialisation, our entry, the main bundle and each layer.
export const marks: { entry: number } & Partial<
Record<"ready" | "window" | "served" | "bundle" | "onboarding" | "logging" | "crash" | "storage" | "init" | "layers", number>
> = { entry: Date.now() }
// layer reports them with "app starting" so the startup benchmark can split the time before the
// first log line into Electron's own initialisation, our entry, and the main bundle.
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
@@ -8,10 +8,6 @@ import { getStore } from "../storage/store"
const DEFAULT_PROJECT_DIR = "Default Project"
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
@@ -21,8 +17,11 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
}),
{ concurrency: "unbounded" },
)
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const complete = hasExistingAppState(entries)
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
return complete
+2 -10
View File
@@ -19,7 +19,6 @@ let netLogPath: string | undefined
export interface Interface {
readonly startNetwork: Effect.Effect<void>
readonly startCrashReporter: Effect.Effect<void>
readonly exportDebug: Effect.Effect<string>
}
@@ -31,9 +30,7 @@ const serviceLayer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* initLogging(fs, path).pipe(Effect.orDie)
// Old run directories go away in the background; listing them is not worth a wait at startup.
yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void)))
marks.logging = Date.now()
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
yield* Effect.logInfo("app starting", {
version: VERSION,
packaged: app.isPackaged,
@@ -45,12 +42,6 @@ const serviceLayer = Layer.effect(
startNetwork: startNetLog(path).pipe(
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
),
// Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window
// and its IPC port come first.
startCrashReporter: initCrashReporter(fs, path).pipe(
Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))),
Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })),
),
exportDebug,
})
}),
@@ -108,6 +99,7 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
})
yield* cleanup(fs, path)
})
}
@@ -3,7 +3,6 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
import { sidecarProbe } from "./sidecar-probe"
export * as BackgroundService from "./background-service"
@@ -37,7 +36,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
const version = mode === "initial" ? cli.version : undefined
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode/client/service"))
const ensure = () =>
const service = yield* Effect.tryPromise(() =>
client.Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
@@ -47,18 +46,13 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
})
// A compatible service the entry module already found is adopted at once; ensure() still runs
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
const service = early ?? (yield* Effect.tryPromise(ensure))
}),
)
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
yield* Effect.logInfo("v2 CLI background service ready", {
version,
probed: !!early,
...endpoint(url.origin),
})
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
@@ -1,7 +1,6 @@
export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { existsSync, readFileSync } from "node:fs"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
@@ -94,15 +93,9 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
// Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued
// async read waits behind everything else the main thread is doing at that moment.
const shipped = yield* Effect.sync(() => {
try {
return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
})
const shipped = yield* fs
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
if (shipped) {
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
return shipped
@@ -154,7 +147,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
const path = yield* Path.Path
const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = path.join(directory, executableName())
if (existsSync(destination)) {
if (yield* fs.exists(destination)) {
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
return destination
}
@@ -1,32 +0,0 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { app } from "electron"
import type { Endpoint } from "@opencode/client/service"
// The main thread idles between showing the first window and evaluating the main bundle, waiting
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
// background service is already running, so the renderer's first data request is not the first
// moment anyone asks. The probe only looks; a service that has to be started waits for the layers,
// which set the environment the CLI expects.
let probe: Promise<Endpoint | undefined> | undefined
export function startSidecarProbe() {
if (!app.isPackaged) return
const version = bundledVersion()
if (!version) return
probe = import("@opencode/client/service")
.then(({ Service }) => Service.discover({ version }))
.catch(() => undefined)
}
export function sidecarProbe() {
return probe ?? Promise.resolve(undefined)
}
function bundledVersion() {
try {
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
}
+1 -2
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/schema"
import { SshConfig, type SshState } from "@opencode/app/ssh"
import { SshChanged } from "../../shared/ipc-rpc/events"
import { DesktopCli } from "../service/desktop-cli"
import { Shutdown } from "../lifecycle/shutdown"
@@ -88,4 +88,3 @@ export const layer = Layer.effect(
return service
}),
).pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(FetchHttpClient.layer))
@@ -2,9 +2,7 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { marks } from "../lifecycle/marks"
import { openDatabase } from "./database"
import { setStorageSnapshotProvider } from "./snapshot"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
@@ -42,8 +40,6 @@ export const layer = Layer.effect(
storage.close()
}),
)
setStorageSnapshotProvider((names) => Object.fromEntries(names.map((name) => [name, storage.state.items(name)])))
marks.storage = Date.now()
return Service.of(storage)
}),
)
@@ -1,56 +0,0 @@
import { existsSync, readdirSync } from "node:fs"
import path from "node:path"
import { DatabaseSync } from "node:sqlite"
import { app, ipcMain } from "electron"
import { StorageSnapshotChannel, type StorageSnapshot } from "../../shared/ipc-transport"
import { isRendererUrl } from "../windows/scheme"
// A window's preload asks for the namespaces its shell reads before the page runs, so the first
// render is the hydrated one. Until the storage layer is up (the first window asks before it exists)
// the answer comes from the database file; the layer takes over so later windows see queued writes.
type Provider = (names: ReadonlyArray<string>) => StorageSnapshot
let provider: Provider = readFromDisk
export function setStorageSnapshotProvider(next: Provider) {
provider = next
}
export function registerStorageSnapshotHandler() {
ipcMain.handle(StorageSnapshotChannel, (event, names: unknown): StorageSnapshot => {
if (!isRendererUrl(event.senderFrame?.url)) return {}
if (!Array.isArray(names)) return {}
return provider(names.filter((name): name is string => typeof name === "string"))
})
}
// Nothing has been written in this process yet, so every namespace is at revision 0, as the
// storage layer would report before its first update. Legacy electron-store files still waiting
// to be imported would make the database stale for this launch, so the renderer asks the layer then.
function readFromDisk(names: ReadonlyArray<string>): StorageSnapshot {
const userData = app.getPath("userData")
const file = path.join(userData, "drafts.sqlite")
if (!existsSync(file)) return {}
if (readdirSync(userData).some((name) => name === "default.dat" || /^opencode\..+\.dat$/.test(name))) return {}
try {
const db = new DatabaseSync(file)
try {
const rows = db.prepare("SELECT key, value FROM state WHERE name = ?")
return Object.fromEntries(
names.map((name) => [
name,
{
items: Object.fromEntries(
(rows.all(name) as { key: string; value: string }[]).map((row) => [row.key, row.value]),
),
revision: 0,
},
]),
)
} finally {
db.close()
}
} catch {
return {}
}
}
@@ -1,20 +0,0 @@
import { windowBootstrapArgument, type WindowBootstrap } from "../../shared/window-bootstrap"
import { getDefaultServerUrl } from "../service/server-settings"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
// The settings store is already in memory when a window is created, so the renderer gets the
// answers its shell gate would otherwise ask for over IPC. A fresh install has no onboarding
// decision yet; the renderer asks once the layers have made one.
export function windowBootstrap(id: string): WindowBootstrap {
const complete = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
return {
id,
firstLaunchPending: typeof complete === "boolean" ? !complete : undefined,
defaultServerUrl: getDefaultServerUrl(),
}
}
export function windowArguments(id: string) {
return [windowBootstrapArgument(windowBootstrap(id))]
}
@@ -9,6 +9,10 @@ import { getStore } from "../storage/store"
// full window setup in appearance.ts, so both draw the same frame.
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
// Match the renderer's 36px titlebar plus its former 8px content inset.
export const titlebarHeight = 44
@@ -17,13 +21,10 @@ export function tone() {
}
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
// a window shown before the renderer paints already has the right background. Resolving a palette
// costs tens of milliseconds before the first window, so it only happens when nothing is stored.
// a window shown before the renderer paints already has the right background.
export function storedBackgroundColor() {
const stored = getStore().get(BACKGROUND_COLOR_KEY)
if (typeof stored === "string") return stored
const dark = tone() === "dark"
return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"]
return typeof stored === "string" ? stored : oc2Background[tone()]
}
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
+5 -33
View File
@@ -1,25 +1,14 @@
import { randomUUID } from "node:crypto"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { app, BrowserWindow, screen, shell } from "electron"
import { resolveExternalURL } from "../files/external-url"
import { windowArguments } from "./bootstrap"
import { app, BrowserWindow, screen } from "electron"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { registerRendererProtocol } from "./protocol"
import { loadWindow } from "./scheme"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
export type EarlyWindow = {
id: string
win: BrowserWindow
state: WindowState
shownAt: number
// Navigation policy is wired before the layers exist; the adopter swaps in the logged version.
openExternal: (url: string) => void
}
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
let pending: EarlyWindow | undefined
@@ -57,7 +46,7 @@ export function createEarlyWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
additionalArguments: windowArguments(id),
additionalArguments: [windowIDArgument(id)],
},
})
manageWindowState(win, file, state, displays)
@@ -67,24 +56,7 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
const record: EarlyWindow = {
id,
win,
state,
shownAt: Date.now(),
openExternal: (url) => {
const target = resolveExternalURL(url)
if (target) void shell.openExternal(target)
},
}
pending = record
// The renderer boots while the main bundle and layers load, instead of after them. Everything the
// page needs before its first request is wired here; the IPC port arrives once the layers are up.
registerRendererProtocol(path.join(root, "../renderer"))
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => record.openExternal(url))
wireRendererHeaders(win)
loadWindow(win, "index.html")
pending = { id, win, state, shownAt: Date.now() }
}
export function takeEarlyWindow() {
+16 -16
View File
@@ -7,8 +7,7 @@ import { DesktopPaths } from "../paths"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowDataFile } from "../../shared/ipc-transport"
import { windowArguments } from "./bootstrap"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
getBackgroundColor,
getPinchZoomEnabled,
@@ -22,8 +21,7 @@ import {
wireFullscreen,
wireZoom,
} from "./appearance"
import { registerRendererProtocol, setProtocolReporter } from "./protocol"
import { loadWindow } from "./scheme"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { takeEarlyWindow, type EarlyWindow } from "./early"
@@ -50,7 +48,6 @@ export {
getBackgroundColor,
getPinchZoomEnabled,
registerRendererProtocol,
setProtocolReporter,
setBackgroundColor,
setDockIcon,
setPinchZoomEnabled,
@@ -118,23 +115,18 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
...appearance,
webPreferences: {
...appearance.webPreferences,
additionalArguments: windowArguments(id),
additionalArguments: [windowIDArgument(id)],
},
})
// The early window was secured and loaded when it was created; only its external-URL policy is
// upgraded to the logged one.
if (early) early.openExternal = (url) => runFork(openExternalURL(url))
if (!early) {
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
manageWindowState(win, stateFile, state, displays)
}
allowRendererPermissions(win)
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
if (!early) loadWindow(win, "index.html")
loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -190,4 +182,12 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
return { create, restore }
})
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
function safeWindowID(id: string) {
return id.replace(/[^a-zA-Z0-9._-]/g, "-")
}
+57 -69
View File
@@ -1,85 +1,73 @@
import { net, protocol } from "electron"
import path from "node:path"
import type { BrowserWindow } from "electron"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { rendererHost, rendererProtocol } from "./scheme"
export type ProtocolReport = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
// The entry module registers the handler the moment the first window exists, before logging is up,
// so problems go to the console until the logging layer installs a reporter.
let report: ProtocolReport = (level, message, data) => console[level === "error" ? "error" : "warn"](message, data)
export function setProtocolReporter(reporter: ProtocolReport) {
report = reporter
}
// Requests in flight and when the last one arrived. The entry module holds the main bundle back
// until the renderer's initial burst of asset requests has been answered, because this handler
// runs on the main thread and a 100 ms module evaluation would otherwise sit between the renderer
// and its HTML.
let inflight = 0
let served = 0
let lastRequest = 0
export function rendererAssetsServed(options: { quietMs: number; capMs: number }) {
const start = Date.now()
return new Promise<void>((resolve) => {
const check = () => {
const now = Date.now()
if (now - start >= options.capMs) return resolve()
if (served > 0 && inflight === 0 && now - lastRequest >= options.quietMs) return resolve()
setTimeout(check, 5)
}
check()
})
}
export function registerRendererProtocol(rendererRoot: string) {
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const runFork = Effect.runForkWith(yield* Effect.context<never>())
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
inflight++
lastRequest = Date.now()
const url = new URL(request.url)
if (url.host !== rendererHost) {
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
return new Response("Not found", { status: 404 })
}
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(paths.rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
return new Response("Not found", { status: 404 })
}
try {
return await serve(request, rendererRoot)
} finally {
inflight--
served++
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
runFork(
scoped(
"protocol",
Effect.logError("fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
}),
),
)
}
return addDocumentPolicy(response, file)
} catch (error) {
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
return new Response("Not found", { status: 404 })
}
})
})
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
async function serve(request: Request, rendererRoot: string) {
const url = new URL(request.url)
if (url.host !== rendererHost) {
report("warning", "rejected host", { url: request.url })
return new Response("Not found", { status: 404 })
}
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
report("warning", "rejected path", { url: request.url, file })
return new Response("Not found", { status: 404 })
}
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
report("error", "fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
})
}
return addDocumentPolicy(response, file)
} catch (error) {
report("error", "fetch error", { url: request.url, file, error })
return new Response("Not found", { status: 404 })
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
function addDocumentPolicy(response: Response, file: string) {
@@ -1,28 +1,8 @@
import { protocol } from "electron"
import type { BrowserWindow } from "electron"
export const rendererProtocol = "oc"
export const rendererHost = "renderer"
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
// before it loads anything else.
export function registerRendererScheme() {
@@ -1,7 +1,7 @@
import type { BrowserWindow } from "electron"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
import { isRendererUrl } from "./scheme"
import { isRendererUrl } from "./protocol"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
+3 -17
View File
@@ -1,12 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import {
DragCancelEvent,
IpcTransportPort,
StorageSnapshotChannel,
storageSnapshotNames,
type StorageSnapshot,
} from "../shared/ipc-transport"
import { windowBootstrapFromArguments } from "../shared/window-bootstrap"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
@@ -15,15 +9,7 @@ ipcRenderer.on(IpcTransportPort, (event) => {
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
const bootstrap = windowBootstrapFromArguments(process.argv)
// Asked before the page runs, so the stores the shell reads are hydrated on the first render.
const storageSnapshot: Promise<StorageSnapshot> = ipcRenderer
.invoke(StorageSnapshotChannel, storageSnapshotNames(bootstrap.id))
.catch(() => ({}))
contextBridge.exposeInMainWorld("electron", {
windowID: bootstrap.id,
bootstrap,
storageSnapshot,
windowID: windowIDFromArguments(process.argv),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
-5
View File
@@ -1,9 +1,4 @@
import type { StorageSnapshot } from "../shared/ipc-transport"
import type { WindowBootstrap } from "../shared/window-bootstrap"
export type ElectronNative = {
windowID: string
bootstrap: WindowBootstrap
storageSnapshot: Promise<StorageSnapshot>
getPathForFile(file: File): string
}
@@ -5,7 +5,6 @@ import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { SshPlatform } from "@opencode/app/ssh"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -54,7 +53,6 @@ export type ElectronAPI = {
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): string
getWindowBootstrap(): WindowBootstrap
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
+1 -13
View File
@@ -22,9 +22,6 @@ const updaterHandler = (state: UpdaterState) => {
updaterCallbacks.forEach((callback) => callback(state))
}
// One renderer-side copy: the bridge clones on every crossing, so consumption is tracked here.
const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object.entries(snapshot)))
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
@@ -102,15 +99,7 @@ export const api: ElectronAPI = {
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
// The first read of a namespace the preload already fetched is served from that snapshot; later
// reads (a window re-opening a namespace) go to the main process as usual.
storeItems: (name) =>
seeded.then((snapshot) => {
const item = snapshot.get(name)
if (!item) return invoke("StorageItems", { name }).then(mutable)
snapshot.delete(name)
return item
}),
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
storeClear: (name) => invoke("StorageClear", { name }),
onStoreChanged: (cb) =>
@@ -122,7 +111,6 @@ export const api: ElectronAPI = {
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => window.electron.windowID,
getWindowBootstrap: () => window.electron.bootstrap,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+5 -13
View File
@@ -48,23 +48,15 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
drawingReady: false,
route,
})
// The window was created with the answers the shell gate needs; only a fresh install, which has no
// onboarding decision yet, asks over IPC and waits for the port.
const bootstrap = props.api.getWindowBootstrap()
const [firstLaunch] = createResource(() =>
bootstrap.firstLaunchPending !== undefined
? Promise.resolve(bootstrap.firstLaunchPending)
: props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
)
const platform = createDesktopPlatform(props.api, windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(async () => {
if (bootstrap.defaultServerUrl === undefined) return platform.getDefaultServer?.()
return bootstrap.defaultServerUrl ? ServerConnection.Key.make(bootstrap.defaultServerUrl) : null
})
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
+69 -73
View File
@@ -1,14 +1,10 @@
import type { Effect } from "effect"
import type { RpcMessage } from "effect/unstable/rpc"
import type { DesktopRpcClient } from "../shared/ipc-rpc"
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
// directly. Messages cross by structured clone (no serialization layer, binary stays binary), every
// payload in the contract is JSON-native or a Uint8Array, and the main process is trusted, so the
// renderer needs neither the Effect runtime nor the contract's schemas to talk to it. Keeping them
// out of the renderer's initial module graph is worth about a third of its startup script.
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
@@ -17,48 +13,55 @@ type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
type Pending = {
readonly resolve: (value: unknown) => void
readonly reject: (error: unknown) => void
readonly chunk?: (values: ReadonlyArray<unknown>) => void
}
const pending = new Map<number, Pending>()
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
let nextId = 0
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
value.start()
resolve(value)
}
window.addEventListener("message", onMessage)
})
// Let queued work (storage flushes) hand its messages to the port before it closes.
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
window.addEventListener(
"pagehide",
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => port.then((p) => p.close())),
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
{ once: true },
)
void request("DesktopEvents", null, (values) => {
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
})
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
runtime.runFork(
Effect.gen(function* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
@@ -76,49 +79,42 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
}
}
function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) {
const id = nextId++
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject, chunk })
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
void port.then((p) => p.postMessage(message))
})
}
function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
switch (message._tag) {
case "Chunk": {
pending.get(Number(message.requestId))?.chunk?.(message.values)
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
return
}
case "Exit": {
const id = Number(message.requestId)
const entry = pending.get(id)
pending.delete(id)
if (!entry) return
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
return entry.reject(failure(message.exit.cause))
}
case "Defect": {
const error = new Error("Desktop IPC defect", { cause: message.defect })
pending.forEach((entry) => entry.reject(error))
pending.clear()
return
}
case "ClientProtocolError": {
console.error("[desktop-ipc] protocol error", message.error)
return
}
}
}
// The RPC failure a caller sees is the encoded error the handler failed with, as before; defects
// and interrupts surface as errors.
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
const failed = cause.find((item) => item._tag === "Fail")
if (failed) return failed.error
const died = cause.find((item) => item._tag === "Die")
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
return new Error("Desktop IPC request interrupted")
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
codecFor: Schema.toCodecJson,
send: (_clientId, request) =>
Effect.sync(() => {
value.postMessage(request)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
)
}
@@ -1,22 +1,9 @@
import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode/app/desktop"
import { storedLocaleValue } from "./locale-value"
// The stored language lives in the main process's SQLite store, behind the IPC port. A copy of the
// last answer in localStorage lets the shell mount without waiting for the port; the store is still
// asked every launch and the copy refreshed, and the language provider hydrates from the store
// itself, so a stale copy costs one visible switch, not a wrong language.
const cacheKey = "opencode.desktop.language"
export async function preloadStoredLocale(platform: Platform) {
const fresh = Promise.resolve(platform.storage?.("opencode.global.dat").getItem("language")).then(
(raw) => {
localStorage.setItem(cacheKey, raw ?? "")
return raw
},
() => undefined,
)
const cached = localStorage.getItem(cacheKey)
const locale = storedLocale(cached ?? (await fresh))
const raw = await platform.storage?.("opencode.global.dat").getItem("language")
const locale = storedLocale(raw)
if (!locale) return
if (locale !== "en") await loadLocaleDict(locale)
return locale
@@ -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/schema"
import { SshState } from "@opencode/app/ssh"
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
@@ -64,4 +64,3 @@ 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)
+1 -2
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/schema"
import { SshHttp, SshStart, SshState } from "@opencode/app/ssh"
export const SshRpcs = RpcGroup.make(
Rpc.make("SshGetState", { success: SshState }),
@@ -15,4 +15,3 @@ export const SshRpcs = RpcGroup.make(
Rpc.make("SshForget", { payload: { id: Schema.String } }),
Rpc.make("SshOpenConfig"),
)
@@ -1,16 +1,2 @@
export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
export const StorageSnapshotChannel = "desktop-storage-snapshot"
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
// The namespaces a window reads while its shell mounts. The preload asks for them before the page
// runs so the first render already has them; mirrors windowStorage() in
// packages/app/src/runtime/persistence/storage.ts.
export function storageSnapshotNames(windowID: string) {
return ["opencode.global.dat", "default.dat", windowDataFile(windowID)]
}
export function windowDataFile(id: string) {
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
}
@@ -1,17 +1,13 @@
import { describe, expect, test } from "bun:test"
import { windowBootstrapArgument, windowBootstrapFromArguments } from "./window-bootstrap"
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
describe("window bootstrap", () => {
test("round-trips through argv", () => {
const bootstrap = { id: "win a/b ü", firstLaunchPending: false, defaultServerUrl: "http://127.0.0.1:1234" }
expect(windowBootstrapFromArguments(["electron", windowBootstrapArgument(bootstrap)])).toEqual(bootstrap)
test("round-trips the window ID through renderer arguments", () => {
const id = "window/id with spaces"
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
})
test("keeps unknown values absent", () => {
expect(windowBootstrapFromArguments([windowBootstrapArgument({ id: "x" })])).toEqual({ id: "x" })
})
test("throws when the argument is missing", () => {
expect(() => windowBootstrapFromArguments(["electron"])).toThrow("Window bootstrap argument not found")
test("requires a window ID argument", () => {
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
})
})
@@ -1,19 +1,11 @@
// What the main process already knows when it creates a window, handed to the renderer through the
// preload's argv so the shell can mount before the IPC port exists. Undefined means "ask over IPC".
export type WindowBootstrap = {
id: string
firstLaunchPending?: boolean
defaultServerUrl?: string | null
const windowIDPrefix = "--opencode-window-id="
export function windowIDArgument(id: string) {
return windowIDPrefix + encodeURIComponent(id)
}
const prefix = "--opencode-window="
export function windowBootstrapArgument(bootstrap: WindowBootstrap) {
return prefix + encodeURIComponent(JSON.stringify(bootstrap))
}
export function windowBootstrapFromArguments(args: readonly string[]): WindowBootstrap {
const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!value) throw new Error("Window bootstrap argument not found")
return JSON.parse(decodeURIComponent(value))
export function windowIDFromArguments(args: readonly string[]) {
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
if (!value) throw new Error("Window ID argument not found")
return decodeURIComponent(value)
}