Compare commits

..
Author SHA1 Message Date
Kit Langton ff09490bcc fix(core): retain locations while sessions are executing 2026-09-06 10:14:33 -04:00
132 changed files with 1157 additions and 5482 deletions
+4 -2
View File
@@ -432,7 +432,6 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -1037,9 +1036,12 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
}
}
+4
View File
@@ -146,6 +146,10 @@
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
@@ -415,7 +415,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -428,7 +431,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
const breakpoints = BedrockCache.breakpoints()
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
-8
View File
@@ -639,14 +639,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
: state.usage,
}
const candidate = event.candidates?.[0]
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
return Effect.fail(
ProviderShared.eventError(
state.route,
`Gemini stopped with ${candidate.finishReason}`,
ProviderShared.encodeJson(event),
),
)
if (!candidate?.content)
return Effect.succeed([
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
-12
View File
@@ -9,8 +9,6 @@ import {
AIError,
InvalidProviderOutputError,
LLMEvent,
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReasonDetails,
type LLMRequest,
@@ -702,16 +700,6 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
normalized: mapFinishReason(choice.finish_reason),
raw: choice.finish_reason,
}
if (finishReason.normalized === "error") {
const details = {
message: `Mistral Chat stopped with ${finishReason.raw}`,
body: ProviderShared.encodeJson(event),
}
return yield* new AIError({
reason:
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
})
}
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
return yield* ProviderShared.eventError(
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import type { CacheHint } from "../../schema/index.js"
import { newBreakpoints, ttlBucket } from "./cache.js"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. Bedrock
@@ -13,46 +13,24 @@ export const CachePointBlock = Schema.Struct({
})
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
// These legacy Claude releases support explicit caching, but only for five minutes.
const CLAUDE_5M = [
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
]
// Callers share the four-breakpoint budget across system, messages, and tools.
// Callers pass a shared counter through every `block()` call site so the
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
export const BEDROCK_BREAKPOINT_CAP = 4
export const breakpoints = (modelID: string) => {
// Substring matching also handles regional prefixes and model-bearing ARNs.
const short = CLAUDE_5M.some((id) => modelID.includes(id))
return {
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
// Other model families use implicit caching where available.
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
ttl1h: !short,
}
}
export type Breakpoints = ReturnType<typeof breakpoints>
export type { Breakpoints } from "./cache.js"
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
if (!breakpoints.supported) return undefined
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
}
export * as BedrockCache from "./bedrock-cache.js"
@@ -1,119 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
describe("Bedrock Converse cache policy", () => {
for (const id of [
"deepseek.r1-v1:0",
"meta.llama3-3-70b-instruct-v1:0",
"mistral.mistral-large-2402-v1:0",
"qwen.qwen3-coder-480b-a35b-v1:0",
"openai.gpt-oss-120b-1:0",
"cohere.command-r-v1:0",
"anthropic.claude-instant-v1",
"anthropic.claude-v1",
"anthropic.claude-v2",
"anthropic.claude-v2:1",
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-5-sonnet-20240620-v1:0",
"amazon.nova-lite-v1:0",
"global.amazon.nova-2-lite-v1:0",
"custom-model",
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
]) {
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
Effect.gen(function* () {
// Exercise both automatic placement and manual hints at every lowering site.
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
cache: policy,
system: [{ type: "text", text: "System prefix", cache }],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
messages: [
Message.user([{ type: "text", text: "Question", cache }]),
Message.system([{ type: "text", text: "Update", cache }]),
Message.assistant([
{ type: "text", text: "Answer", cache },
{ type: "reasoning", text: "Unsigned reasoning", cache },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
],
}),
)
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
expect(prepared.body).toMatchObject({
modelId: id,
system: [{ text: "System prefix" }],
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
messages: [
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
{
role: "assistant",
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
},
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
],
})
}),
)
}
}
for (const [id, ttl] of [
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
["global.anthropic.claude-sonnet-99", "1h"],
["anthropic.claude-new-family-99", "1h"],
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
] as const) {
for (const ttlSeconds of [undefined, 3600]) {
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
system: [
{ type: "text", text: "Agent" },
{ type: "text", text: "Project" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
prompt: "Question",
cache:
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
}),
)
const marker = {
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
}
expect(prepared.body).toMatchObject({
modelId: id,
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
})
if (ttlSeconds === undefined || ttl === undefined)
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
}),
)
}
}
})
@@ -136,7 +136,7 @@ const captureHeaders = (target: LanguageModel) =>
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const baseRequest = LLM.request({
id: "req_1",
@@ -155,7 +155,7 @@ describe("Bedrock Converse route", () => {
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
inferenceConfig: { maxTokens: 64, temperature: 0 },
+6 -21
View File
@@ -1643,14 +1643,19 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () =>
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
@@ -1661,26 +1666,6 @@ describe("Gemini route", () => {
)
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const raw of [
"MALFORMED_FUNCTION_CALL",
"UNEXPECTED_TOOL_CALL",
"NO_IMAGE",
"TOO_MANY_TOOL_CALLS",
"MISSING_THOUGHT_SIGNATURE",
"MALFORMED_RESPONSE",
]) {
const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } },
message: `Gemini stopped with ${raw}`,
})
}
}),
)
+1 -16
View File
@@ -600,6 +600,7 @@ describe("Mistral Chat", () => {
["stop", "stop"],
["model_length", "length"],
["tool_calls", "tool-calls"],
["error", "error"],
["future_reason", "unknown"],
] as const) {
const response = yield* LLMClient.generate(request).pipe(
@@ -608,22 +609,6 @@ describe("Mistral Chat", () => {
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const [raw, tag] of [
["error", "UnknownProvider"],
["network_error", "ProviderInternal"],
] as const) {
const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } },
message: `Mistral Chat stopped with ${raw}`,
})
}
const truncated = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
@@ -53,7 +53,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
@@ -267,7 +267,7 @@ for (const state of ["closed", "unopened"] as const) {
messages.resolve()
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
}
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-worktree")
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
@@ -154,8 +154,8 @@ test("worktree deletion sends the project location separately from the target",
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Delete workspace-1”?", exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete workspace-1”?", exact: true })
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
-2
View File
@@ -7,7 +7,5 @@ export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./r
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
export { useWslServers } from "./servers/wsl/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
+15 -59
View File
@@ -34,8 +34,6 @@ export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -101,15 +99,7 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<NewSessionTips
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
props.workspace.selection.value() !== "create" &&
props.workspace.project.managed() === 0
}
onWorkspace={() => select("create")}
/>
<ProviderTip />
</div>
</div>
)
@@ -128,90 +118,56 @@ export function NewSessionStatus(props: { visible: boolean }) {
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
function ProviderTip() {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
Persist.global("new-session.workspace-tip"),
WorkspaceTipSchema,
{ dismissedAt: 0 },
)
const workspaceVisible = createMemo(
() =>
props.workspaceEligible &&
workspaceReady() &&
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
)
const providerVisible = createMemo(
const visible = createMemo(
() =>
providers.ready() &&
providerReady() &&
persistedReady() &&
providers.paid().length === 0 &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
if (providerVisible()) return "provider"
if (workspaceVisible()) return "workspace"
})
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: () => tip() !== undefined,
show: visible,
element: () => ref() ?? null,
})
const open = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
props.onWorkspace()
return
}
const openProviders = () => {
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
const dismiss = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
return
}
setProviderState("dismissedAt", Date.now())
}
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="new-session-tip"
data-visible={tip() !== undefined}
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={open}
onClick={openProviders}
>
<span class="truncate">
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
</span>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<Tooltip
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
@@ -220,7 +176,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={dismiss}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<Icon name="xmark-small" />
</button>
@@ -1,5 +1,5 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
@@ -56,50 +56,6 @@ export function createNewSessionWorkspaceController(input: {
const current = projectID ? data.project.get(projectID) : undefined
return current ? normalizeProjectInfo(current) : undefined
})
const worktreeSource = createMemo(
() => {
const project = currentProject()
return project ? { projectID: project.id, directory: project.worktree } : undefined
},
undefined,
{ equals: (a, b) => a?.projectID === b?.projectID && a?.directory === b?.directory },
)
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
projectID: source.projectID,
items: await serverSDK.api.worktree
.list({ location: { directory: source.directory } })
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
}))
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated") void worktreeActions.refetch()
}),
)
const worktreeItems = createMemo(() => {
const project = currentProject()
if (!project) return []
const loaded = worktrees.latest
return loaded?.projectID === project.id ? loaded.items : project.worktrees
})
const worktreeDirectories = createMemo(() => {
const project = currentProject()
if (!project) return []
const directories = [
...worktreeItems().map((item) => item.directory),
...project.worktrees.map((item) => item.directory),
...(project.sandboxes ?? []),
]
return directories
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
})
const managedWorktrees = createMemo(() => {
const project = currentProject()
if (!project) return 0
return worktreeItems().filter(
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
).length
})
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
@@ -110,10 +66,7 @@ export function createNewSessionWorkspaceController(input: {
const project = currentProject()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ||
worktreeDirectories().some((item) => sameDirectory(item, worktree))
? worktree
: undefined
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = currentProject()
@@ -144,7 +97,7 @@ export function createNewSessionWorkspaceController(input: {
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const branch = createMemo(() =>
@@ -169,12 +122,7 @@ export function createNewSessionWorkspaceController(input: {
workspace: createMemo(() => {
const project = currentProject()
const current = value()
return (
current === "create" ||
(!!project &&
(isWorkspaceDirectory(project, current) ||
worktreeDirectories().some((item) => sameDirectory(item, current))))
)
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => {
input.setSelectedWorktree(undefined)
@@ -194,8 +142,10 @@ export function createNewSessionWorkspaceController(input: {
},
project: {
root: projectRoot,
workspaces: worktreeDirectories,
managed: managedWorktrees,
workspaces: () => {
const project = currentProject()
return project ? workspaceDirectories(project) : []
},
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
@@ -1,6 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -26,9 +25,6 @@ export function PromptWorkspaceSelector(props: {
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
const branchTruncation = createTruncatedText()
const focusWorktreeSearch = () =>
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
@@ -38,8 +34,8 @@ export function PromptWorkspaceSelector(props: {
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "plus"
return "outline-worktree"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
}
const select = (value: string) => {
pending = { type: "select", value }
@@ -70,14 +66,13 @@ export function PromptWorkspaceSelector(props: {
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
@@ -94,10 +89,7 @@ export function PromptWorkspaceSelector(props: {
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon
name={icon()}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
@@ -117,8 +109,14 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.local.tooltip")}
contentClass="max-w-[140px]"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
@@ -128,22 +126,24 @@ export function PromptWorkspaceSelector(props: {
</Show>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="plus" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.new.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<Icon name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
</Menu.Group>
<Show when={props.workspaces.length > 0}>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</>
}
>
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
@@ -156,11 +156,10 @@ export function PromptWorkspaceSelector(props: {
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
focusWorktreeSearch()
requestAnimationFrame(() => searchInput?.focus())
}}
>
<Menu.SubTrigger
onClick={focusWorktreeSearch}
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
@@ -171,7 +170,7 @@ export function PromptWorkspaceSelector(props: {
focusSearch = true
}}
>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
@@ -206,7 +205,7 @@ export function PromptWorkspaceSelector(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
@@ -214,11 +213,6 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
)}
</For>
<Show when={search.workspaces.trim() && workspaces().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
@@ -238,7 +232,6 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
@@ -252,7 +245,7 @@ export function PromptWorkspaceSelector(props: {
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
@@ -326,7 +319,6 @@ export function PromptWorkspaceSelector(props: {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const truncation = createTruncatedText()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
@@ -346,27 +338,15 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Tooltip
placement="top"
value={value()}
disabled={!truncation.truncated()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={truncation.observe} class="min-w-0 truncate">
{value()}
</span>
<span class="min-w-0 truncate">{value()}</span>
</div>
</Tooltip>
)}
</Show>
)
}
function createTruncatedText() {
const [truncated, setTruncated] = createSignal(false)
return {
truncated,
observe: (element: HTMLSpanElement) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
}
}
+11 -27
View File
@@ -659,7 +659,6 @@ export const dict = {
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.files": "Files",
@@ -1251,43 +1250,30 @@ export const dict = {
"workspace.new": "New worktree",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Uses projects current checkout",
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"settings.tab.workspaces": "Worktrees",
"settings.workspaces.description": "Review worktrees and manage disk usage",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No worktrees yet",
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
"settings.workspaces.empty": "No worktrees",
"settings.workspaces.count.one": "{{count}} worktree",
"settings.workspaces.count.other": "{{count}} worktrees",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.sessions.filtered.one": "{{count}} session",
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all worktrees",
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
"settings.workspaces.deleteWithoutSessions.confirm.one":
"This will permanently delete {{count}} worktree without linked sessions.",
"settings.workspaces.deleteWithoutSessions.confirm.other":
"This will permanently delete {{count}} worktrees without linked sessions.",
"settings.workspaces.deleteWithoutSessions.warning":
"Worktrees with unmerged changes or active locations will be kept.",
"settings.workspaces.delete.button": "Delete worktrees",
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
"settings.workspaces.delete.warning":
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
"settings.workspaces.deleteAll.warning":
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will no longer exist.",
"Linked sessions will remain, but their working directory will be permanently removed.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
@@ -1298,10 +1284,9 @@ export const dict = {
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating worktree",
"workspace.lifecycle.created": "Worktree created",
"workspace.lifecycle.deleting": "Deleting…",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with worktrees",
"workspace.onboarding.description": "Each gets its own checkout",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.lifecycle.moving": "Moving to worktree",
"workspace.lifecycle.set": "Worktree set",
"session.summary.title": "Session details",
@@ -1320,10 +1305,9 @@ export const dict = {
"workspace.status.checking": "Checking for unmerged changes…",
"workspace.status.error": "Unable to verify git status.",
"workspace.status.clean": "No unmerged changes detected.",
"workspace.status.dirty": "Unmerged changes will be lost.",
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
"workspace.delete.title": "Delete worktree",
"workspace.delete.confirm": "Delete {{name}}”?",
"workspace.delete.location": "Location",
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
"workspace.delete.button": "Delete worktree",
"workspace.reset.title": "Reset worktree",
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
@@ -49,36 +49,6 @@ migration rules remain explicit in their schemas.
invalid entries individually. Valid entries still pass through their codecs.
- Recovery is not a substitute for an explicit historical shape transformation.
## Writes
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
hides, and the write is skipped when the serialized form did not change. Reactive observers
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
change wins over a value arriving from another window, and over a stored value that finishes
loading after the user already edited. A remote value that arrives while the store is dirty
is held until the save runs; if the local setter calls turned out not to change the
serialized form, the remote value is adopted instead of being lost.
## Namespaces
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
namespace is loaded from the host once, reads are Map lookups from then on, and writes
update the cache immediately while being batched into one host round trip per flush
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
flush on page hide is on the wire before the page goes away; the desktop platform flushes
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
local write carries a sequence number that is kept until the host accepts that exact write;
until then neither the initial load, a change from another window (`accept`), nor the retry
of an older failed batch can replace the key. The host also stamps every update with a
monotonic revision, returned in the ack and carried by change events and loads, so an event
that reaches a window after a newer ack or load for the same key is recognised as stale and
dropped; an event held back during an in-flight write is applied after the ack when the host
ordered it later.
## Migrations
Describe shipped representations with schemas and transform their typed values
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
@@ -12,7 +12,6 @@ describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -20,7 +19,6 @@ describe("persisted consumer schemas", () => {
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
@@ -1,247 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
// that the test delivers to other windows whenever it chooses, like the real event stream.
function host(initial: Record<string, Record<string, string>> = {}) {
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
const calls: Call[] = []
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
let revision = 0
let fail: (insert: Record<string, string>) => boolean = () => false
let gate: Promise<void> | undefined
const driver: NamespaceDriver = {
items: async (name) => {
calls.push({ kind: "items", name })
return { items: Object.fromEntries(data.get(name) ?? []), revision }
},
update: async (name, insert, remove) => {
calls.push({ kind: "update", name, insert, remove })
await gate
if (fail(insert)) throw new Error("disk full")
const items = data.get(name) ?? new Map()
for (const [key, value] of Object.entries(insert)) items.set(key, value)
for (const key of remove) items.delete(key)
data.set(name, items)
events.push({ name, insert, remove, revision: ++revision })
return revision
},
clear: async (name) => {
calls.push({ kind: "clear", name })
data.delete(name)
},
}
return {
driver,
data,
calls,
events,
updates: () => calls.filter((call) => call.kind === "update"),
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
(fail = typeof value === "boolean" ? () => value : value),
setGate: (value: Promise<void> | undefined) => (gate = value),
deliver: (target: NamespaceStorage, index: number) => {
const event = events[index]!
target.accept(event.insert, event.remove, event.revision)
},
}
}
describe("namespace storage", () => {
test("loads the namespace once and serves reads from memory", async () => {
const h = host({ w: { tabs: "[]", recent: "{}" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
expect(await storage.getItem("tabs")).toBe("[]")
expect(await storage.getItem("recent")).toBe("{}")
expect(await storage.getItem("missing")).toBeNull()
expect(await storage.getLength()).toBe(2)
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
})
test("reads its own writes immediately and coalesces them into one update", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
void storage.setItem("tabs", "[1,2]")
void storage.removeItem("recent")
expect(await storage.getItem("tabs")).toBe("[1,2]")
expect(await storage.getItem("recent")).toBeNull()
expect(h.updates()).toHaveLength(0)
await wait(30)
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
})
test("writes made while loading win over the loaded snapshot", async () => {
const h = host({ w: { tabs: "old" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
const read = storage.getItem("tabs")
void storage.setItem("tabs", "new")
expect(await read).toBe("new")
})
test("flush writes now and resolves after the driver accepted the batch", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
await storage.flush()
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
await storage.flush()
expect(h.updates()).toHaveLength(1)
})
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
h.setFail(true)
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
await storage.flush()
expect(h.data.get("w")).toBeUndefined()
h.setFail(false)
void storage.setItem("tabs", "[2]")
await storage.flush()
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
})
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
void storage.flush()
void storage.setItem("tabs", "[2]")
void storage.flush()
// Both batches reached the driver while the first reply is still outstanding.
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
first.resolve()
await storage.flush()
})
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const accepted = Promise.withResolvers<number>()
const driver: NamespaceDriver = {
items: () => loaded.promise,
update: () => accepted.promise,
clear: async () => undefined,
}
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
void storage.setItem("model", "local")
void storage.flush()
storage.accept({ model: "other-window-older" }, [], 1)
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
expect(await read).toBe("local")
accepted.resolve(2)
await storage.flush()
expect(await storage.getItem("model")).toBe("local")
storage.accept({ model: "other-window-newer" }, [], 3)
expect(await storage.getItem("model")).toBe("other-window-newer")
})
test("a failed batch does not requeue a value a later batch already replaced", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
h.setFail((insert) => insert.tabs === "old")
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "old")
void storage.flush()
h.setGate(undefined)
void storage.setItem("tabs", "new")
void storage.flush()
await wait(0)
expect(h.data.get("w")?.get("tabs")).toBe("new")
first.resolve()
await storage.flush()
await storage.flush()
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
expect(h.data.get("w")?.get("tabs")).toBe("new")
expect(await storage.getItem("tabs")).toBe("new")
})
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
const h = host({ g: { model: "start" } })
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await one.getItem("model")
await two.getItem("model")
void one.setItem("model", "A")
await one.flush()
void two.setItem("model", "B")
await two.flush()
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
h.deliver(two, 0)
expect(await two.getItem("model")).toBe("B")
expect(h.data.get("g")?.get("model")).toBe("B")
// One still receives B, which is newer than its own ack.
h.deliver(one, 1)
expect(await one.getItem("model")).toBe("B")
})
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
const h = host()
const gate = Promise.withResolvers<void>()
h.setGate(gate.promise)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
void storage.setItem("model", "mine")
void storage.flush()
// Another window's write for the same key landed at the host after ours will.
storage.accept({ model: "theirs" }, [], 2)
expect(await storage.getItem("model")).toBe("mine")
gate.resolve()
await storage.flush()
expect(await storage.getItem("model")).toBe("theirs")
})
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
storage.accept({ model: "inserted" }, [], 41)
loaded.resolve({ items: {}, revision: 42 })
expect(await read).toBeNull()
// The delete event is older than the floor and must stay a no-op either way.
storage.accept({}, ["model"], 42)
expect(await storage.getItem("model")).toBeNull()
// A key inserted by an event newer than the snapshot survives the load.
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
const pending = other.getItem("model")
other.accept({ model: "after-snapshot" }, [], 43)
second.resolve({ items: {}, revision: 42 })
expect(await pending).toBe("after-snapshot")
})
test("an event older than the initial load is ignored", async () => {
const h = host({ g: { model: "loaded" } })
void h.driver.update("g", { model: "loaded" }, [])
await wait(0)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await storage.getItem("model")
storage.accept({ model: "before-load" }, [], 1)
expect(await storage.getItem("model")).toBe("loaded")
storage.accept({}, ["model"], 2)
expect(await storage.getItem("model")).toBeNull()
})
test("clear drops the cache and queued changes and clears the driver", async () => {
const h = host({ w: { tabs: "[]" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
await storage.getItem("tabs")
void storage.setItem("recent", "{}")
await storage.clear()
expect(await storage.getItem("tabs")).toBeNull()
expect(await storage.getItem("recent")).toBeNull()
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
})
})
@@ -1,159 +0,0 @@
import type { AsyncStorage } from "@solid-primitives/storage"
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
// update with a monotonic revision and reports it with reads, acks, and change events.
export type NamespaceDriver = {
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
clear(name: string): Promise<void>
}
export type NamespaceStorage = AsyncStorage & {
/** Write every queued change now. Resolves when the driver has accepted it. */
flush(): Promise<void>
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
accept(insert: Record<string, string>, remove: string[], revision: number): void
}
export const namespaceFlushDelay = 100
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
// on; writes update the cache immediately and are batched into one driver call per flush window,
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
//
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
// recorded until the host acks that exact write; while recorded, nothing external may replace
// the key. Every value the cache holds also carries the host revision it came from, so an event
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
// on the wire before the page goes away.
export function createNamespaceStorage(
driver: NamespaceDriver,
name: string,
options: { delay?: number } = {},
): NamespaceStorage {
const delay = options.delay ?? namespaceFlushDelay
const cache = new Map<string, string>()
const local = new Map<string, { seq: number; value: string | null }>()
const dirty = new Set<string>()
const inflight = new Set<Promise<void>>()
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
const applied = new Map<string, number>()
let floor = -1
// The newest external change for a key that arrived while a local write was still in flight.
const deferred = new Map<string, { revision: number; value: string | null }>()
let seq = 0
let loading: Promise<void> | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const place = (key: string, value: string | null, revision: number) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
applied.set(key, revision)
}
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
// an older event inserted it into the cache while the load was in flight.
const load = () =>
(loading ??= driver.items(name).then((loaded) => {
floor = loaded.revision
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
for (const key of [...cache.keys()]) {
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
}
for (const [key, value] of Object.entries(loaded.items)) {
if (stale(key)) place(key, value, loaded.revision)
}
}))
const write = (key: string, value: string | null) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
local.set(key, { seq: ++seq, value })
dirty.add(key)
timer ??= setTimeout(() => void flush(), delay)
}
// The host accepted this window's value for `key` at `revision`. A change from another window
// that was held back meanwhile wins if the host applied it later than ours.
const acknowledge = (key: string, revision: number) => {
local.delete(key)
const later = deferred.get(key)
deferred.delete(key)
if (later && later.revision > revision) return place(key, later.value, later.revision)
applied.set(key, revision)
}
const flush = () => {
clearTimeout(timer)
timer = undefined
if (dirty.size > 0) {
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
dirty.clear()
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
const request = driver
.update(name, insert, remove)
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
.catch((error: unknown) => {
// Only a value nothing newer has replaced is worth retrying.
batch.filter(current).forEach((entry) => dirty.add(entry.key))
console.error(`[persistence] flush failed for ${name}`, error)
})
.finally(() => inflight.delete(request))
inflight.add(request)
}
return Promise.all(inflight).then(() => undefined)
}
const storage: NamespaceStorage = {
getItem: async (key) => {
await load()
return cache.get(key) ?? null
},
setItem: async (key, value) => write(key, value),
removeItem: async (key) => write(key, null),
clear: async () => {
clearTimeout(timer)
timer = undefined
cache.clear()
local.clear()
dirty.clear()
applied.clear()
deferred.clear()
loading = Promise.resolve()
await driver.clear(name)
},
key: async (index: number) => {
await load()
return [...cache.keys()][index]
},
getLength: async () => {
await load()
return cache.size
},
get length() {
return storage.getLength()
},
flush,
accept(insert, remove, revision) {
// The initial load already reflects everything up to `floor`.
if (revision <= floor) return
const changes = [
...Object.entries(insert).map(([key, value]) => [key, value] as const),
...remove.map((key) => [key, null] as const),
]
for (const [key, value] of changes) {
if (local.has(key)) {
const held = deferred.get(key)
if (!held || revision > held.revision) deferred.set(key, { revision, value })
continue
}
if (revision <= (applied.get(key) ?? floor)) continue
place(key, value, revision)
}
},
}
return storage
}
@@ -1,156 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { flushPersisted, persistStore } from "./persist"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type State = { count: number; label: string }
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
const writes: string[] = []
return createRoot((dispose) => {
const [store, setStore] = createStore<State>({ count: 0, label: "" })
const persist = persistStore({
store,
setStore,
name: "state",
// One fixture serves both the sync and the async storage shape.
storage: {
getItem: () => input.initial ?? null,
setItem: (_key: string, value: string) => {
writes.push(value)
},
removeItem: () => {},
} as SyncStorage | AsyncStorage,
serialize: JSON.stringify,
deserialize: JSON.parse,
sync: input.sync,
delay: input.delay ?? 10,
})
return { store, set: persist.setStore, persist, writes, dispose }
})
}
describe("persistStore", () => {
test("marks the store dirty on set and writes once after the delay", async () => {
const value = setup({})
value.set("count", 1)
value.set("count", 2)
value.set("label", "a")
expect(value.store.count).toBe(2)
expect(value.writes).toEqual([])
await wait(30)
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
value.dispose()
})
test("skips the write when the serialized value did not change", () => {
const value = setup({ delay: 10_000 })
value.set("count", 1)
value.persist.flush()
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("hydrates synchronously from sync storage without writing back", () => {
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
expect(value.store).toEqual({ count: 5, label: "saved" })
value.persist.flush()
expect(value.writes).toEqual([])
value.dispose()
})
test("a set made while async storage loads wins over the loaded value", async () => {
const loading = Promise.withResolvers<string | null>()
const value = setup({ initial: loading.promise, delay: 10_000 })
value.set("count", 9)
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
await loading.promise
expect(value.store.count).toBe(9)
value.dispose()
})
test("applies another window's value when clean and ignores it while dirty", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "remote" })
value.set("label", "local")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "local" })
value.persist.flush()
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
value.dispose()
})
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
value.set("count", 1)
value.persist.flush()
// Setting the same value again marks the store dirty without changing it.
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
expect(value.store.label).toBe("")
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "remote" })
expect(value.writes).toHaveLength(1)
expect(sent).toHaveLength(1)
// A later save must not consider the adopted value a local change.
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("label", "saved")
value.persist.flush()
value.set("label", "saved")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 0, label: "saved" })
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "" })
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
value.dispose()
})
test("disposing the owner and flushPersisted both save pending changes", () => {
const first = setup({ delay: 10_000 })
first.set("count", 1)
first.dispose()
expect(first.writes).toHaveLength(1)
const second = setup({ delay: 10_000 })
second.set("count", 2)
flushPersisted()
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
second.dispose()
expect(second.writes).toHaveLength(1)
})
})
@@ -1,100 +0,0 @@
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
import { getOwner, onCleanup, untrack } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
export const persistSaveDelay = 100
const pending = new Set<() => void>()
/** Serialize and write every store with unsaved changes now. */
export function flushPersisted() {
for (const save of [...pending]) save()
}
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushPersisted()
})
window.addEventListener("pagehide", flushPersisted)
}
// A store whose serialized form is written to storage on a schedule instead of on every setter
// call. The setter only marks the store dirty; serialization happens once per save window, once
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
export function persistStore<T extends object>(input: {
store: Store<T>
setStore: SetStoreFunction<T>
name: string
storage: SyncStorage | AsyncStorage
serialize: (value: T) => string
deserialize: (raw: string) => T
sync?: PersistenceSyncAPI
delay?: number
}) {
const delay = input.delay ?? persistSaveDelay
let dirty = false
let touched = false
let last: string | undefined
// The newest value another window wrote while this store was dirty; applied at save time if the
// local setter calls turned out not to change anything.
let remote: string | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const save = () => {
clearTimeout(timer)
timer = undefined
pending.delete(save)
if (!dirty) return
dirty = false
const held = remote
remote = undefined
const next = untrack(() => input.serialize(input.store))
if (next === last) {
if (held !== undefined && held !== last) hydrate(held)
return
}
last = next
input.sync?.[1](input.name, next)
void input.storage.setItem(input.name, next)
}
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
const apply = input.setStore as unknown as (...values: unknown[]) => void
const setStore = ((...values: unknown[]) => {
apply(...values)
dirty = true
touched = true
pending.add(save)
timer ??= setTimeout(save, delay)
}) as unknown as SetStoreFunction<T>
const hydrate = (raw: string) => {
last = raw
input.setStore(reconcile(input.deserialize(raw)))
}
const init = input.storage.getItem(input.name)
// A value the user already changed is newer than whatever storage held.
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
else if (init) hydrate(init)
input.sync?.[0]((data) => {
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
if (!data.newValue) return
// A real unsaved local change wins over another window's write, as in VS Code's storage
// service; whether the change is real is only known when the store is serialized. Every
// remote value replaces the held one, including a revert to `last`, so the save sees the
// other window's final state rather than an intermediate one.
if (dirty) {
remote = data.newValue
return
}
if (data.newValue === last) return
hydrate(data.newValue)
})
if (getOwner()) onCleanup(save)
return { setStore, init, flush: save }
}
@@ -1,15 +1,14 @@
import { Platform, usePlatform } from "@/runtime/platform/platform"
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
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 { persistStore } from "./persist"
import { Persistence } from "./schema"
type InitType = Promise<string | null> | string | null
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
Store<T>,
SetStoreFunction<T>,
@@ -594,18 +593,13 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
: undefined
if (channel) onCleanup(() => channel.close())
const persist = persistStore({
store: store[0],
setStore: store[1],
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
})
const state = store[0]
const setState = persist.setStore
const init = persist.init
const isAsync = init instanceof Promise
const [ready] = createResource(
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[key] !== child) return
@@ -1,13 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue, isSetupRequest, isSlowRequest } from "./request-queue"
import { createRequestQueue, isSlowRequest } from "./request-queue"
function setup(input?: {
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
}) {
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
@@ -16,13 +10,12 @@ function setup(input?: {
slowLimit: input?.slowLimit,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
setupHeadersTimeoutMs: input?.setupHeadersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL, init?: RequestInit) =>
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource, init)
const request = new Request(resource)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
@@ -50,12 +43,7 @@ describe("createRequestQueue", () => {
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
const input = setup({ limit: 4, slowLimit: 2 })
const paths = [
"/api/vcs?location[directory]=%2Fa",
"/api/vcs/diff?location[directory]=%2Fa",
"/api/worktree",
"/api/session/ses_1",
]
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
const started = () => input.pending.map((item) => new URL(item.url).pathname)
@@ -121,26 +109,6 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("worktree creation gets the setup deadline while worktree reads keep the normal one", async () => {
const input = setup({ limit: 4, headersTimeoutMs: 10, setupHeadersTimeoutMs: 200 })
const create = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa", { method: "POST" })
const list = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa")
const listError = await list.catch((cause: unknown) => cause)
expect((listError as DOMException).name).toBe("TimeoutError")
// Past the normal deadline, the create is still on the wire.
expect(input.pending[0]!.signal.aborted).toBe(false)
input.pending[0]!.resolve()
await expect(create).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("only worktree creation counts as a setup request", () => {
expect(isSetupRequest("POST", "/api/worktree")).toBe(true)
expect(isSetupRequest("GET", "/api/worktree")).toBe(false)
expect(isSetupRequest("POST", "/api/worktree/refresh")).toBe(false)
expect(isSetupRequest("DELETE", "/api/worktree")).toBe(false)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
@@ -19,26 +19,16 @@ export const requestStallMs = 2_000
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
// Creating a worktree runs the project's setup script (dependency installs, fetches) before the
// server answers, so it needs a budget measured in minutes rather than seconds. Cutting it off
// kills the script midway and leaves a registered but half-initialised worktree behind.
export const setupRequestHeadersTimeoutMs = 10 * 60_000
export function isSlowRequest(pathname: string) {
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
export function isSetupRequest(method: string, pathname: string) {
return method === "POST" && pathname === "/api/worktree"
}
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
@@ -46,7 +36,6 @@ export function createRequestQueue(input: {
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
const setupHeadersTimeoutMs = input.setupHeadersTimeoutMs ?? setupRequestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
@@ -112,7 +101,7 @@ export function createRequestQueue(input: {
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
isSetupRequest(request.method, pathname) ? setupHeadersTimeoutMs : headersTimeoutMs,
headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
</span>
}
>
@@ -184,7 +184,7 @@ function WorkspaceMoveAction(props: {
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
@@ -256,10 +256,7 @@ export function SessionSummaryPanel(props: {
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
@@ -7,6 +7,7 @@ import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -27,6 +28,7 @@ export function SessionWorkspaceMenu(props: {
const language = useLanguage()
const serverSDK = useServerSDK()
const data = useData()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
@@ -97,13 +99,13 @@ export function SessionWorkspaceMenu(props: {
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="plus" />
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger>
<Menu.Portal>
@@ -111,7 +113,7 @@ export function SessionWorkspaceMenu(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
)}
@@ -121,6 +123,10 @@ export function SessionWorkspaceMenu(props: {
</Menu.Sub>
</Show>
</Menu.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
+13 -101
View File
@@ -1029,92 +1029,25 @@
display: flex;
flex-direction: column;
gap: 0;
padding: 16px;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
padding: 0;
background-color: transparent;
box-shadow: none;
}
.settings-workspaces-row-motion {
display: grid;
grid-template-rows: minmax(0, 1fr);
opacity: 1;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-row-motion[data-removing="true"] {
grid-template-rows: minmax(0, 0fr);
opacity: 0;
}
.settings-workspaces-row {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: hidden;
transition:
padding-bottom 150ms ease-out,
margin-bottom 150ms ease-out,
border-color 120ms ease-out;
}
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
.settings-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
> .settings-workspaces-row {
padding-bottom: 0;
margin-bottom: 0;
border-color: transparent;
}
.settings-workspaces-empty-motion {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 0fr);
opacity: 0;
overflow: hidden;
pointer-events: none;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-empty-motion[data-visible="true"] {
grid-template-rows: minmax(0, 1fr);
opacity: 1;
pointer-events: auto;
}
.settings-workspaces-empty-motion > .settings-workspaces-empty {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.settings-workspaces-row-motion {
transition: none;
}
.settings-workspaces-row,
.settings-workspaces-empty-motion {
transition: none;
}
}
.settings-workspaces-row-header {
display: flex;
min-width: 0;
@@ -1155,7 +1088,7 @@
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 440;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
@@ -1165,11 +1098,6 @@
cursor: default;
}
.settings-workspaces-path-name {
color: var(--v2-text-text-base);
font-weight: 530;
}
.settings-workspaces-meta {
font-size: 13px;
font-weight: 440;
@@ -1177,10 +1105,6 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-meta-project {
color: var(--v2-text-text-muted);
}
.settings-workspaces-active,
.settings-workspaces-more {
flex-shrink: 0;
@@ -1190,16 +1114,11 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-active {
font-size: 13px;
line-height: var(--line-height-compact);
}
.settings-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px;
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
@@ -1210,8 +1129,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding-block: 10px;
padding-inline: 12px 16px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
@@ -1231,28 +1149,22 @@
.settings-workspaces-session-time {
flex-shrink: 0;
font-size: 13px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty-title {
color: var(--v2-text-text-base);
font-weight: 530;
}
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
+4 -21
View File
@@ -1,5 +1,4 @@
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -36,7 +35,7 @@ const sections = [
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
@@ -55,7 +54,6 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
onMount(() => {
@@ -148,14 +146,7 @@ export const SettingsScreen: Component = () => {
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Menu.RadioItem value={section.value} closeOnSelect>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
@@ -181,12 +172,7 @@ export const SettingsScreen: Component = () => {
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Tabs.Trigger value={section.value}>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
@@ -222,10 +208,7 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
<SettingsWorkspaces activeDirectory={directory()} />
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
@@ -1,8 +1,6 @@
import type { Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -48,9 +46,7 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
props,
) => {
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -60,12 +56,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
deleting: [] as string[],
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
setStore("project", "all")
})
const projectQuery = useQuery(() => ({
@@ -121,7 +111,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
placeholderData: (previous) => previous,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
@@ -134,29 +123,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const workspacesWithoutSessions = createMemo(() => {
if (sessionQuery.isPending || sessionQuery.isError) return []
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
})
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
const project = projectName(workspace.project)
const label = language.plural("settings.workspaces.sessions", count, {
return language.plural("settings.workspaces.sessions", count, {
count,
project,
project: projectName(workspace.project),
})
const start = label.lastIndexOf(project)
if (start < 0) return label
return (
<>
{label.slice(0, start)}
<span class="settings-workspaces-meta-project">{project}</span>
{label.slice(start + project.length)}
</>
)
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
@@ -184,64 +158,54 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: result.active
? language.t("settings.workspaces.delete.blocked.active")
: inspectionMessages(result)[0],
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const key = String(pathKey(workspace.directory))
setStore("deleting", (items) => [...items, key])
try {
const preflight = await inspect(workspace, context)
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
setStore("removing", (items) => [...items, key])
await new Promise((resolve) => setTimeout(resolve, 150))
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
}
let inspectionID = 0
@@ -289,30 +253,13 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteAll")}
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteAll.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
const confirmDeleteWithoutSessions = () => {
if (store.transaction || workspacesWithoutSessions().length === 0) return
const context = captureDeleteContext()
const inventory = [...workspacesWithoutSessions()]
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteWithoutSessions")}
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
@@ -324,45 +271,44 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
<>
<div class="settings-tab-header settings-workspaces-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Show when={filtered().length > 0}>
<Menu placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
@@ -375,83 +321,78 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
/>
<Menu.Portal>
<Menu.Content>
<Show when={workspacesWithoutSessions().length > 0}>
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
{language.t("settings.workspaces.deleteWithoutSessions")}
</Menu.Item>
<Menu.Separator />
</Show>
<Menu.Item onSelect={confirmDeleteAll}>
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</Show>
</div>
</Show>
</div>
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
<SettingsList>
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-empty">
<Show
when={!projectQuery.isPending && !projectQuery.isError}
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
>
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
<span>{language.t("settings.workspaces.empty.description")}</span>
</Show>
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
</div>
</div>
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
{(workspace) => {
const linked = () => workspaceSessions(workspace())
const key = () => String(pathKey(workspace().directory))
const deleting = () => store.deleting.includes(key())
return (
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
}
>
<SettingsList>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-workspaces-row">
<div class="settings-workspaces-row-header">
<div class="settings-workspaces-copy">
<div class="settings-workspaces-main">
<WorkspacePath directory={workspace().directory} />
<Tooltip
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-workspaces-path"
>
{workspace.directory}
</span>
</Tooltip>
</div>
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-workspaces-row-actions">
<Show
when={deleting()}
fallback={
<>
<Show when={lastActive(workspace())}>
{(value) => (
<Tooltip
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace().directory),
})}
disabled={!!store.transaction}
icon={<Icon name="outline-trash" size="small" />}
onClick={() => confirmDelete(workspace())}
/>
</>
}
>
<span class="settings-workspaces-active">{language.t("workspace.lifecycle.deleting")}</span>
<Show when={lastActive(workspace)}>
{(value) => (
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
@@ -460,7 +401,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
{(session) => (
<div class="settings-workspaces-session">
<span>{sessionLabel(session)}</span>
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
</Show>
</div>
@@ -469,48 +410,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
</div>
</Show>
</div>
</div>
)
}}
</Key>
</SettingsList>
)
}}
</For>
</SettingsList>
</Show>
</div>
</div>
</>
)
}
function WorkspacePath(props: { directory: string }) {
const [truncated, setTruncated] = createSignal(false)
const name = () => getFilename(props.directory)
return (
<Tooltip
value={props.directory}
placement="top-start"
disabled={!truncated()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
tabIndex={truncated() ? 0 : undefined}
dir="ltr"
aria-label={props.directory}
class="settings-workspaces-path"
>
<span>{props.directory.slice(0, -name().length)}</span>
<span class="settings-workspaces-path-name">{name()}</span>
</span>
</Tooltip>
)
}
function DialogDeleteWorkspaces(props: {
title: string
confirmation: string
warning: string
onDelete: () => Promise<void>
}) {
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
@@ -523,12 +434,13 @@ function DialogDeleteWorkspaces(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={props.title}
title={language.t("settings.workspaces.deleteAll")}
description={
<div class="flex flex-col gap-2">
<div>{props.confirmation}</div>
<div>{props.warning}</div>
</div>
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
@@ -537,7 +449,7 @@ function DialogDeleteWorkspaces(props: {
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.delete.button")}
{language.t("settings.workspaces.deleteAll")}
</Button>
</DialogFooter>
</Dialog>
@@ -560,7 +472,7 @@ function DialogDeleteWorkspace(props: {
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return []
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
@@ -575,20 +487,18 @@ function DialogDeleteWorkspace(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
title={language.t("workspace.delete.title")}
description={
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-11-regular text-v2-text-text-faint">
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
</span>
<code class="block w-fit max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
</div>
<div>{language.t("settings.workspaces.delete.warning")}</div>
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</div>
</>
}
/>
</DialogHeader>
@@ -596,7 +506,12 @@ function DialogDeleteWorkspace(props: {
<Button type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" disabled={status.isPending || status.isError} onClick={remove}>
<Button
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</Button>
</DialogFooter>
@@ -4,7 +4,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { ServerScope } from "@/runtime/server/scope"
import { createComposerState, type ComposerStore } from "@/composer/state"
import { createComposerEditorActions } from "@/composer/editor/actions"
import { flushPersisted } from "@/runtime/persistence/persist"
function setup(read: () => string | null | Promise<string | null> = () => null) {
return createRoot((dispose) => {
@@ -28,72 +27,83 @@ function setup(read: () => string | null | Promise<string | null> = () => null)
})
}
test("composer-write-batch: a burst of edits persists once with prompt, cursor and retry together", async () => {
test("composer-write-batch: typing persists prompt, cursor and retry together before returning", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.context.add({ type: "file", path: "src/queue.ts", preview: "await queue.flush()" })
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
flushPersisted()
value.writes.length = 0
value.editor.setPrompt([{ type: "text", content: "keep ordering", start: 0, end: 13 }], 13)
value.editor.setCursor(12)
expect(value.writes).toHaveLength(0)
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0]).toMatchObject({
prompt: [{ type: "text", content: "keep ordering", start: 0, end: 13 }],
cursor: 12,
cursor: 13,
context: { items: [{ path: "src/queue.ts", preview: "await queue.flush()" }] },
})
expect(value.writes[0].retry).toBeUndefined()
} finally {
value.dispose()
}
})
test("composer-write-batch: a save with no serialized change writes nothing", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
value.state.mode.set("shell")
flushPersisted()
value.writes.length = 0
value.editor.setCursor(5)
value.state.mode.set("shell")
flushPersisted()
expect(value.writes).toHaveLength(0)
value.state.reset()
flushPersisted()
value.editor.setCursor(13)
expect(value.writes).toHaveLength(1)
expect(value.writes[0]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
value.editor.setCursor(12)
expect(value.writes.map((write) => write.cursor)).toEqual([13, 12])
value.editor.setCursor(12)
expect(value.writes).toHaveLength(2)
} finally {
value.dispose()
}
})
test("composer-write-batch: state replacement snapshots the value at save time", async () => {
test("composer-write-batch: state replacement preserves an omitted cursor and resets in one write", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
flushPersisted()
value.state.mode.set("shell")
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
value.writes.length = 0
const prompt = [{ type: "text" as const, content: "next", start: 0, end: 4 }]
value.state.set(prompt)
prompt[0].content = "changed outside the store"
expect(value.state.current()[0]).toMatchObject({ content: "next" })
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "next", start: 0, end: 4 }])
expect(value.writes[0].cursor).toBe(5)
expect(value.writes[0].retry).toBeUndefined()
value.state.reset()
expect(value.writes).toHaveLength(2)
expect(value.writes[1]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
value.state.mode.set("normal")
value.state.mode.set("normal")
expect(value.writes).toHaveLength(3)
expect(value.writes[2].mode).toBe("normal")
} finally {
value.dispose()
}
})
test("composer-write-batch: the last edit in a window wins and attachments are retained", async () => {
test("composer-write-batch: unchanged mode still clears a retry and context writes remain ordered", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.mode.set("normal")
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
value.writes.length = 0
value.editor.setMode("normal")
expect(value.writes).toHaveLength(1)
expect(value.writes[0].retry).toBeUndefined()
value.state.context.add({ type: "file", path: "first.ts" })
value.state.context.add({ type: "file", path: "second.ts" })
value.state.context.remove(value.state.context.items()[0].key)
expect(value.writes.slice(1).map((write) => write.context.items.map((item) => item.path))).toEqual([
["first.ts"],
["first.ts", "second.ts"],
["second.ts"],
])
} finally {
value.dispose()
}
})
test("composer-write-batch: text replacement and insertion each persist once and retain attachments", async () => {
const value = setup()
try {
await value.state.ready.promise
@@ -110,14 +120,12 @@ test("composer-write-batch: the last edit in a window wins and attachments are r
],
3,
)
flushPersisted()
value.writes.length = 0
value.editor.setText("new")
value.editor.addText(" notes")
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].cursor).toBe(9)
expect(value.writes[0].prompt).toEqual([
expect(value.writes).toHaveLength(2)
expect(value.writes.map((write) => write.cursor)).toEqual([3, 9])
expect(value.writes[1].prompt).toEqual([
{ type: "text", content: "new notes", start: 0, end: 9 },
{
type: "image",
@@ -137,6 +145,7 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
const value = setup(() => loading.promise)
try {
value.editor.setPrompt([{ type: "text", content: "new", start: 0, end: 3 }], 3)
expect(value.writes).toHaveLength(1)
loading.resolve(
JSON.stringify({
prompt: [{ type: "text", content: "old", start: 0, end: 3 }],
@@ -147,15 +156,13 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
await value.state.ready.promise
expect(value.state.current()).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
expect(value.state.cursor()).toBe(3)
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
} finally {
value.dispose()
}
})
test("composer-write-batch: observers see every edit before anything is persisted", async () => {
test("composer-write-batch: persistence still precedes reactive observers", async () => {
const value = setup()
try {
await value.state.ready.promise
@@ -170,22 +177,10 @@ test("composer-write-batch: observers see every edit before anything is persiste
value.editor.addText(" and insert")
value.state.set([{ type: "text", content: "restore", start: 0, end: 7 }], 7)
value.state.reset()
expect(observed).toEqual([0, 0, 0, 0, 0, 0])
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(observed).toEqual([0, 1, 2, 3, 4, 5])
dispose()
})
} finally {
value.dispose()
}
})
test("composer-write-batch: disposing the owner saves pending edits", async () => {
const value = setup()
await value.state.ready.promise
value.editor.setPrompt([{ type: "text", content: "unsaved", start: 0, end: 7 }], 7)
expect(value.writes).toHaveLength(0)
value.dispose()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "unsaved", start: 0, end: 7 }])
})
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { flushPersisted } from "@/runtime/persistence/persist"
import { persisted } from "@/runtime/persistence/storage"
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
@@ -26,7 +25,6 @@ test("persisted model selection hydrates, updates and serializes the schema shap
expect(state.session.session1?.agent).toBe("plan")
setState("session", "session1", { agent: "build", variant: null })
expect(state.session.session1?.agent).toBe("build")
flushPersisted()
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
session: { session1: { agent: "build", variant: null } },
})
@@ -6,7 +6,6 @@ import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
let read: ((value: string | null) => void) | undefined
@@ -110,7 +109,6 @@ describe("prompt persistence", () => {
},
])
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
flushPersisted()
await Bun.sleep(0)
expect(documents.get(key)).toContain("hello")
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { Schema, SchemaGetter } from "effect"
import { createComputed, createRoot } from "solid-js"
import type { Platform } from "@/runtime/platform/platform"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { TabStorage } from "@/shell/tabs/schema"
@@ -58,7 +57,6 @@ describe("schema-backed persistence", () => {
expect(state.key).toBe("session-tab")
setState("key", undefined)
expect(state.key).toBeUndefined()
flushPersisted()
expect(localStorage.getItem(key)).toBe("{}")
} finally {
dispose()
@@ -76,7 +74,6 @@ describe("schema-backed persistence", () => {
expect(state).toEqual({ enabled: true, label: "saved" })
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
setState("enabled", false)
flushPersisted()
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
dispose()
})
@@ -128,7 +125,6 @@ describe("schema-backed persistence", () => {
label: "desktop",
})
root.state[1]("label", "changed")
flushPersisted()
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
enabled: true,
label: "changed",
+14 -12
View File
@@ -3,7 +3,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect/service"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -50,10 +49,8 @@ try {
signal: AbortSignal.timeout(5_000),
})
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await activePluginIDs(info.url, headers)).some((id) => id === "smoke"))
throw new Error("Smoke plugin existed before creation")
// A directory exercises runtime resolution; standalone files bypass the resolver.
const plugin = path.join(root, ".opencode", "plugins", "smoke", "index.ts")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
@@ -80,7 +77,9 @@ try {
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await Effect.runPromise(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
)
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
@@ -145,21 +144,24 @@ function pluginSource() {
return 'export default { id: "smoke", setup: async () => {} }\n'
}
async function activePluginIDs(url: string, headers: HeadersInit) {
async function pluginIDs(url: string, headers: HeadersInit) {
const endpoint = new URL("/api/plugin", url)
endpoint.searchParams.set("location[directory]", root)
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
const body = await Schema.decodeUnknownPromise(Schema.Struct({ data: Schema.Array(Plugin.Info) }))(
await response.json(),
const body: unknown = await response.json()
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
throw new Error("Compiled service returned an invalid plugin list")
}
return body.data.flatMap((plugin) =>
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string" ? [plugin.id] : [],
)
return body.data.flatMap((plugin) => (plugin.state.status === "active" && plugin.id ? [plugin.id] : []))
}
async function waitForPlugin(url: string, headers: HeadersInit) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
if ((await activePluginIDs(url, headers)).some((id) => id === "smoke")) return
if ((await pluginIDs(url, headers)).includes("smoke")) return
await Bun.sleep(25)
}
throw new Error("Compiled service did not activate the created plugin directory")
throw new Error("Compiled service did not discover the created plugin")
}
+1 -5
View File
@@ -605,10 +605,7 @@ export type SessionLogOutput =
readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly reason: "user" | "shutdown" | "superseded" | "inactivity"
}
readonly data: { readonly sessionID: Session.ID; readonly reason: "user" | "shutdown" | "superseded" }
}
| {
readonly id: Event.ID
@@ -1429,7 +1426,6 @@ export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOut
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly canonical?: AbsolutePath | undefined
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
@@ -1018,7 +1018,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { canonical: input["canonical"], name: input["name"], icon: input["icon"], commands: input["commands"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1377,12 +1377,7 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: {
canonical: input["canonical"],
name: input["name"],
icon: input["icon"],
commands: input["commands"],
},
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
+1 -10
View File
@@ -710,7 +710,7 @@ export type SessionExecutionInterrupted = {
type: "session.execution.interrupted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" | "inactivity" }
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" }
}
export type SessionInstructionsUpdated = {
@@ -4672,26 +4672,17 @@ export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly canonical?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["canonical"]
readonly name?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
+11 -83
View File
@@ -1,14 +1,13 @@
export * as Credential from "./credential.js"
import { asc, desc, eq } from "drizzle-orm"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database.js"
import { Bus } from "./bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql.js"
import { ErrorSummary } from "./util/error-summary.js"
export const ID = Credential.ID
export type ID = Credential.ID
@@ -124,21 +123,7 @@ const layer = Layer.effect(
.run()
}),
)
.pipe(
Effect.onError((cause) =>
Effect.logError("credential create failed", {
credentialID: credential.id,
integrationID: credential.integrationID,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
yield* Effect.logInfo("credential created", {
credentialID: credential.id,
integrationID: credential.integrationID,
type: credential.value.type,
})
.pipe(Effect.orDie)
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
@@ -169,19 +154,8 @@ const layer = Layer.effect(
return credential.integration_id
}),
)
.pipe(
Effect.onError((cause) =>
Effect.logError("credential activate failed", {
credentialID: id,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
if (integrationID) {
yield* Effect.logInfo("credential activated", { integrationID, credentialID: id })
yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}
.pipe(Effect.orDie)
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (updates.label === undefined && updates.value === undefined) return
@@ -190,46 +164,15 @@ const layer = Layer.effect(
.from(CredentialTable)
.where(eq(CredentialTable.id, id))
.get()
.pipe(
Effect.onError((cause) =>
Effect.logError("credential update lookup failed", {
credentialID: id,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
if (!credential?.integrationID) {
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_missing" })
return
}
.pipe(Effect.orDie)
if (!credential?.integrationID) return
if (updates.label === credential.label && updates.value === undefined) return
const updated = yield* db
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.returning({ id: CredentialTable.id })
.get()
.pipe(
Effect.onError((cause) =>
Effect.logError("credential update failed", {
credentialID: id,
integrationID: credential.integrationID,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
if (!updated) {
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_removed" })
return
}
yield* Effect.logInfo("credential updated", {
credentialID: id,
integrationID: credential.integrationID,
valueChanged: updates.value !== undefined,
labelChanged: updates.label !== undefined && updates.label !== credential.label,
})
.run()
.pipe(Effect.orDie)
if (updates.label !== undefined && updates.label !== credential.label)
yield* bus.publish(Event.Updated, {}, { global: true })
}),
@@ -248,8 +191,7 @@ const layer = Layer.effect(
.get()
: undefined
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!credential.integration_id || active?.id !== id)
return { switched: false as const, integrationID: credential.integration_id }
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
const replacement = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
@@ -275,22 +217,8 @@ const layer = Layer.effect(
}
}),
)
.pipe(
Effect.onError((cause) =>
Effect.logError("credential remove failed", {
credentialID: id,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
.pipe(Effect.orDie)
if (!removed) return
yield* Effect.logInfo("credential removed", {
credentialID: id,
integrationID: removed.integrationID,
active: removed.switched,
...(removed.switched ? { replacementID: removed.credentialID } : {}),
})
yield* bus.publish(Event.Updated, {}, { global: true })
if (removed.switched)
yield* bus.publish(
+13 -29
View File
@@ -24,7 +24,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const sessions = yield* SessionStore.Service
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
const touch = (ref: Location.Ref) =>
Effect.sync(() => {
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
@@ -51,36 +51,20 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
if (expired.length === 0) return
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
const occupied = new Set(active.flatMap((session) => (session ? [key(session.location)] : [])))
yield* Effect.forEach(
expired,
(entry) =>
Effect.gen(function* () {
const owners = active.flatMap((session) =>
session && key(session.location) === key(entry.ref) ? [session] : [],
)
// Invalidation only detaches the cache entry; borrowers retain the old
// graph. Stop its executions and settle tool cleanup before detaching it.
yield* Effect.forEach(
owners,
(session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }),
{
discard: true,
concurrency: "unbounded",
},
)
const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
// New work admitted during cleanup may now own the cached graph.
if (remaining.some((session) => session && key(session.location) === key(entry.ref))) {
yield* touch(entry.ref)
return
}
entries.delete(key(entry.ref))
yield* Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
}),
{ discard: true, concurrency: "unbounded" },
(entry) => {
// Waiting for a question or a long-running tool emits no activity.
// Invalidating a borrowed graph would strand it behind a new cache entry.
if (occupied.has(key(entry.ref))) return touch(entry.ref)
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
},
{ discard: true },
)
}).pipe(Effect.forever, Effect.forkScoped)
-3
View File
@@ -231,8 +231,6 @@ export const connect = Effect.fnUntraced(function* (
}
if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const fetch = yield* McpOAuth.loggedFetch({ server, directory })
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
const url = new URL(config.url)
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
@@ -242,7 +240,6 @@ export const connect = Effect.fnUntraced(function* (
new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
fetch,
}),
)
+8 -49
View File
@@ -259,41 +259,27 @@ export const layer = (options?: Options) =>
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const run = Effect.runPromiseWith(yield* Effect.context())
const base = {
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
scope: oauth?.scope,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => run(Effect.logInfo("mcp oauth authorization required")),
onRedirect: () => {},
}
const found = (yield* credentials.list(entry.integrationID)).at(-1)
if (!found || found.value.type !== "oauth") {
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
yield* Effect.logInfo("mcp oauth credential unavailable", {
integrationID: entry.integrationID,
reason: found ? "not_oauth" : "missing",
})
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
}
const credentialID = found.id
const methodID = found.value.methodID
const fields = { credentialID, integrationID: entry.integrationID }
yield* Effect.logInfo("mcp oauth credential loaded", {
...fields,
hasRefreshToken: Boolean(found.value.refresh),
hasClientInformation: Boolean(McpOAuth.clientFromCredential(found.value)),
expiresAt: found.value.expires,
expired: found.value.expires !== 0 && found.value.expires <= Date.now(),
})
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await run(credentials.get(credentialID))
const stored = await Effect.runPromise(credentials.get(credentialID))
return stored?.value.type === "oauth" ? stored.value : undefined
}
return McpOAuth.provider({
@@ -304,25 +290,10 @@ export const layer = (options?: Options) =>
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") {
await run(
Effect.logDebug("mcp oauth invalidation skipped", { ...fields, scope, reason: "not_credentials" }),
)
return
}
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
if (!oauth || oauth.refresh !== presented) {
await run(
Effect.logInfo("mcp oauth invalidation skipped", {
...fields,
scope,
reason: oauth ? "token_rotated" : "credential_missing",
}),
)
return
}
await run(Effect.logWarning("mcp oauth credential invalidation requested", { ...fields, scope }))
await run(credentials.remove(credentialID))
if (!oauth || oauth.refresh !== presented) return
await Effect.runPromise(credentials.remove(credentialID))
},
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
@@ -343,16 +314,7 @@ export const layer = (options?: Options) =>
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await run(
Effect.logInfo("mcp oauth tokens received", {
...fields,
credentialPresent: Boolean(previous),
refreshRotated: Boolean(previous && previous.refresh !== value.refresh),
hasRefreshToken: Boolean(value.refresh),
expiresAt: value.expires,
}),
)
await run(credentials.update(credentialID, { value }))
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
@@ -598,10 +560,7 @@ export const layer = (options?: Options) =>
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}).pipe(
Effect.ensuring(entry.startup.open),
Effect.annotateLogs({ server: name, directory: location.directory, connectionID: crypto.randomUUID() }),
)
}).pipe(Effect.ensuring(entry.startup.open))
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
const scope = entry.scope
+9 -90
View File
@@ -1,63 +1,12 @@
export * as McpOAuth from "./oauth.js"
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import { Cause, Deferred, Effect } from "effect"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
Effect.gen(function* () {
const run = Effect.runPromiseWith(yield* Effect.context())
const request: FetchLike = (url, init) => {
const grant = init?.body instanceof URLSearchParams ? init.body.get("grant_type") : undefined
const operation = grant === "refresh_token" ? "refresh" : grant === "authorization_code" ? "exchange" : undefined
const started = Date.now()
return run(
Effect.gen(function* () {
if (operation) yield* Effect.logInfo("mcp oauth request started")
const response = yield* Effect.tryPromise({ try: () => fetch(url, init), catch: (error) => error })
const result = { status: response.status, durationMs: Date.now() - started }
if (operation && !response.ok) {
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
Effect.map((error) => error.errorCode),
Effect.orElseSucceed(() => "unreadable_response"),
)
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
}
if (operation && response.ok) {
yield* Effect.logInfo("mcp oauth request succeeded", result)
}
if (!operation && (response.status === 401 || response.status === 403)) {
yield* Effect.logWarning("mcp http authentication rejected", result)
}
return response
}).pipe(
Effect.onError((cause) => {
if (init?.signal?.aborted) return Effect.logDebug("mcp http request aborted")
return Effect.logWarning("mcp http request failed", {
errors: ErrorSummary.from(Cause.squash(cause)),
durationMs: Date.now() - started,
})
}),
Effect.annotateLogs({
...fields,
requestID: crypto.randomUUID(),
origin: new URL(url).origin,
method: init?.method ?? "GET",
...(operation ? { operation } : {}),
}),
),
)
}
return request
})
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
export interface Store {
@@ -196,12 +145,6 @@ export const authorize = (input: {
readonly methodID: Integration.MethodID
}) =>
Effect.gen(function* () {
const fields = { server: input.name, methodID: input.methodID, oauthAttemptID: crypto.randomUUID() }
const context = yield* Effect.context()
const run = Effect.runPromiseWith(context)
const runFork = Effect.runForkWith(context)
const fetchFn = yield* loggedFetch({ server: input.name }).pipe(Effect.annotateLogs(fields))
yield* Effect.logInfo("mcp oauth authorization started", fields)
const oauth = input.config.oauth || undefined
const store = memoryStore()
const code = yield* Deferred.make<string, Error>()
@@ -217,20 +160,19 @@ export const authorize = (input: {
response.writeHead(404).end("Not found")
return
}
const fail = (reason: string, failure: string) => {
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
const fail = (reason: string) => {
Effect.runFork(Deferred.fail(code, new Error(reason)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(reason, { provider: input.name }))
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) return fail(error, "authorization_error")
if (error) return fail(error)
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
// state parameter exists for, so an attacker can't inject their own authorization code.
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code", "missing_code")
if (!value) return fail("Missing authorization code")
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
@@ -260,7 +202,6 @@ export const authorize = (input: {
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
onRedirect: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
},
store,
})
@@ -269,16 +210,11 @@ export const authorize = (input: {
const tokens = yield* Effect.promise(() => store.tokens())
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
const client = yield* Effect.promise(() => store.clientInformation())
yield* Effect.logInfo("mcp oauth authorization completed", {
...fields,
hasRefreshToken: Boolean(tokens.refresh_token),
expiresIn: tokens.expires_in,
})
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope, fetchFn }),
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
@@ -293,28 +229,11 @@ export const authorize = (input: {
Effect.flatMap((value) =>
Effect.tryPromise({
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value,
scope: oauth?.scope,
fetchFn,
}),
auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
Effect.flatMap(() => finalize),
Effect.onError((cause) =>
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
),
Effect.annotateLogs(fields),
),
}
}).pipe(
Effect.onError((cause) =>
Effect.logWarning("mcp oauth authorization setup failed", {
server: input.name,
methodID: input.methodID,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
)
})
+1 -2
View File
@@ -52,7 +52,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)).execute(request)
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
@@ -60,7 +60,6 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
)
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
+1 -2
View File
@@ -63,11 +63,10 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
max_results: 8,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
const response = yield* HttpClient.filterStatusOk(http)
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
-1
View File
@@ -212,7 +212,6 @@ const layer = Layer.effect(
const row = yield* db
.update(ProjectTable)
.set({
worktree: input.canonical,
name: input.name === undefined ? undefined : input.name || null,
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
+7 -14
View File
@@ -28,17 +28,9 @@ export interface Interface {
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
* rather than fresh work admitted during its cleanup.
* settlement matters.
*/
readonly interrupt: (
sessionID: SessionSchema.ID,
options?: {
readonly continue?: boolean
readonly reason?: "user" | "inactivity"
readonly awaitSettlement?: boolean
},
) => Effect.Effect<boolean>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -46,7 +38,7 @@ export interface Interface {
/** Routes execution from a Session ID to its selected instance's runner. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown" | "inactivity"
type InterruptReason = "user" | "shutdown"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
@@ -128,8 +120,9 @@ export const layer = Layer.effect(
return
}
if (outcome.type === "interrupted") {
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
// A user cancel releases the claim: the turn must not resurrect at the next
// boot. Shutdown interruption keeps it for restart continuity.
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
@@ -154,7 +147,7 @@ export const layer = Layer.effect(
isActive: coordinator.isActive,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
const interrupted = yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return interrupted
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
+3 -25
View File
@@ -17,14 +17,9 @@ export interface Coordinator<Key, E, Reason = never> {
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Returns whether an active execution was
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
* interrupted. Compose with `awaitIdle` for settlement.
*/
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly awaitSettlement?: boolean },
) => Effect.Effect<boolean>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@@ -175,22 +170,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
})
return {
active: Effect.sync(() => new Set(executions.keys())),
isActive,
run,
wake,
interrupt: (key, reason, options) =>
Effect.suspend(() => {
const execution = executions.get(key)
return interrupt(key, reason).pipe(
Effect.tap(() =>
options?.awaitSettlement && execution
? Deferred.await(execution.done).pipe(Effect.ignoreCause)
: Effect.void,
),
)
}),
awaitIdle,
}
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
})
+118 -102
View File
@@ -11,10 +11,6 @@ import { WebSearch } from "../../websearch.js"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
const httpErrors = new Map([
[429, "Web search rate limited (HTTP 429)"],
[401, "Web search authentication failed (HTTP 401)"],
])
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -54,100 +50,106 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (providerID?: WebSearch.ID) =>
websearch.query(
{ ...input, providerID },
{
sessionID: context.sessionID,
onProvider: (provider) => context.progress({ provider: provider.id }),
},
)
const result = yield* search().pipe(
Effect.catchTag("WebSearch.ProviderRequired", () => {
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
websearch.default().pipe(
Effect.flatMap((provider) => {
if (!provider) return ctx.websearch.query(input)
return context
.progress({ provider: provider.id })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
}),
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
value: "allow",
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? "random"
if (providerID === "random") {
yield* websearch.select("random")
return
}
const provider = providers.find((provider) => provider.id === providerID)
if (!provider) return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(provider.id)
return provider.id
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.flatMap(search),
)
}),
)
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? "random"
if (
typeof providerID !== "string" ||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.flatMap((providerID) => {
if (!providerID) return Effect.suspend(search)
return context
.progress({ provider: providerID })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
}),
)
}),
)
const result = yield* search()
const output = {
provider: result.providerID,
results: result.results,
provider: result.data.providerID,
results: result.data.results,
}
const content = output.results.length
? output.results
@@ -166,14 +168,28 @@ export const Plugin = {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
return new ToolFailure({
message:
status === undefined
? fallback
: (httpErrors.get(status) ?? `Web search request failed (HTTP ${status})`),
error,
metadata: { provider: error.providerID },
})
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
),
}),
-31
View File
@@ -1,31 +0,0 @@
export * as ErrorSummary from "./error-summary.js"
import { Option, Schema } from "effect"
const decode = Schema.decodeUnknownOption(
Schema.Struct({
name: Schema.optional(Schema.String),
_tag: Schema.optional(Schema.String),
code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
errno: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Unknown),
}),
)
/** Error messages, stacks and SQL parameters may contain credentials. Retain only diagnostic classifications. */
export function from(error: unknown) {
const errors: { type: string; code?: string | number; errno?: number }[] = []
const seen = new Set<unknown>()
while (error && !seen.has(error) && errors.length < 8) {
seen.add(error)
const result = decode(error)
if (Option.isNone(result)) break
errors.push({
type: result.value._tag ?? (error instanceof Error ? error.name : result.value.name) ?? "unknown",
code: result.value.code,
errno: result.value.errno,
})
error = error instanceof Error ? error.cause : result.value.cause
}
return errors
}
+23 -97
View File
@@ -1,10 +1,7 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import type { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
@@ -58,13 +55,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (
input: Input,
options?: {
readonly sessionID?: Session.ID
readonly onProvider?: (provider: Provider) => Effect.Effect<void>
},
) => Effect.Effect<Response, Error>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearch") {}
@@ -88,17 +79,6 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const cooldowns = new Map<ID, { until: number; error: RequestError }>()
const preferred = new Map<Session.ID | undefined, { provider?: ID }>()
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.clear()))
yield* bus.subscribe([SessionEvent.Deleted, SessionEvent.Moved]).pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
preferred.delete(event.data.sessionID)
}),
),
Effect.forkScoped({ startImmediately: true }),
)
const state = State.create<Data, Editor>({
initial: () => ({ providers: new Map() }),
editor: (editor) => ({
@@ -116,46 +96,26 @@ const layer = Layer.effect(
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const selection = Effect.fn("WebSearch.selection")(function* () {
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
if (
data.selection === false ||
data.selection === "random" ||
(data.selection && data.providers.has(data.selection))
)
return data.selection
const stored = yield* kv.get(ProviderKey)
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
const decoded = Schema.decodeUnknownOption(Selection)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
return Option.getOrUndefined(decoded)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return selection ? data.providers.get(selection) : undefined
})
const randomProvider = (now: number, affinity: { provider?: ID }, attempted?: Set<ID>) => {
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
const providers = state.get().providers
cooldowns.forEach((cooldown, id) => {
if (cooldown.until <= now || !providers.has(id)) cooldowns.delete(id)
})
const current = affinity.provider === undefined ? undefined : providers.get(affinity.provider)
if (current && !cooldowns.has(current.id) && !attempted?.has(current.id)) return current
const available = Array.from(providers.values()).filter(
(provider) => !cooldowns.has(provider.id) && !attempted?.has(provider.id),
)
const provider = available[Math.floor(Math.random() * available.length)]
if (provider) affinity.provider = provider.id
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const provider = yield* defaultProvider()
if (!provider) return yield* new ProviderRequiredError()
return provider
}
const defaultProvider = Effect.fn("WebSearch.default")(function* (choice: Selection | undefined) {
if (choice === false) return yield* new DisabledError()
if (choice === "random") {
// Inspection must not select a provider or reopen consent when every provider is cooling down.
const active = preferred.get(undefined)?.provider
return (
(active === undefined ? undefined : state.get().providers.get(active)) ??
state.get().providers.values().next().value
)
}
return choice ? state.get().providers.get(choice) : undefined
})
return Service.of({
@@ -168,58 +128,24 @@ const layer = Layer.effect(
})).toSorted((a, b) => a.name.localeCompare(b.name))
}),
default: Effect.fn("WebSearch.defaultInfo")(function* () {
const provider = yield* defaultProvider(yield* selection())
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input, options) {
const choice = input.providerID ? undefined : yield* selection()
let provider = input.providerID
? yield* requireProvider(state.get().providers, input.providerID)
: yield* defaultProvider(choice)
if (!provider) return yield* new ProviderRequiredError()
// Keep the cell for this query so deletion/movement cannot reinsert an in-flight session's entry.
const affinity = preferred.get(options?.sessionID) ?? { provider: undefined }
if (choice === "random") {
preferred.set(options?.sessionID, affinity)
provider = randomProvider(yield* Clock.currentTimeMillis, affinity) ?? provider
}
const attempted = new Set<ID>()
while (true) {
if (options?.onProvider) yield* options.onProvider({ id: provider.id, name: provider.name })
let cooldown = choice === "random" ? cooldowns.get(provider.id) : undefined
if (!cooldown || cooldown.until <= (yield* Clock.currentTimeMillis)) {
attempted.add(provider.id)
const result = yield* provider
.execute({ query: input.query })
.pipe(Effect.flatMap(decodeResults), Effect.result)
if (result._tag === "Success") return new Response({ providerID: provider.id, results: result.success })
const cause = result.failure
const error = new RequestError({ providerID: provider.id, cause })
if (choice !== "random" || !HttpClientError.isHttpClientError(cause) || cause.response?.status !== 429)
return yield* error
const now = yield* Clock.currentTimeMillis
cooldown = { until: now + cooldownMillis(cause.response.headers["retry-after"], now), error }
cooldowns.set(provider.id, cooldown)
}
provider = randomProvider(yield* Clock.currentTimeMillis, affinity, attempted)
if (!provider) return yield* cooldown.error
}
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
Effect.flatMap(decodeResults),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Response({ providerID: provider.id, results })
}),
})
}),
)
function cooldownMillis(value: string | undefined, now: number) {
if (!value?.trim()) return 60_000
const seconds = Number(value)
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : 60_000
const date = Date.parse(value)
return Number.isFinite(date) ? Math.max(0, date - now) : 60_000
}
export const node = makeLocationNode({
service: Service,
layer,
+2 -21
View File
@@ -1,36 +1,20 @@
export * as TestWebSearch from "./websearch"
import { Context, Deferred, Effect, Layer } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import type { Session } from "@opencode-ai/schema/session"
export interface Interface extends WebSearch.Interface {
readonly queries: readonly WebSearch.Input[]
readonly sessionIDs: readonly (Session.ID | undefined)[]
/** Waits for query arrivals, not provider execution or query completion. */
readonly wait: (count: number) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
export function httpError(status = 429, retryAfter?: string, url = "https://search.example.com") {
const request = HttpClientRequest.post(url)
return new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(
request,
new Response(null, { status, headers: retryAfter === undefined ? {} : { "Retry-After": retryAfter } }),
),
}),
})
}
// No providers are installed: tests register local executors through transform.
// The normal Bus and KV implementations use the default in-memory database.
export const layer = Layer.effectContext(
@@ -38,7 +22,6 @@ export const layer = Layer.effectContext(
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const websearch = Context.get(context, WebSearch.Service)
const queries: WebSearch.Input[] = []
const sessionIDs: (Session.ID | undefined)[] = []
let started = yield* Deferred.make<void>()
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
@@ -47,15 +30,13 @@ export const layer = Layer.effectContext(
const test = Service.of({
...websearch,
queries,
sessionIDs,
wait,
query: Effect.fnUntraced(function* (input, options) {
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
queries.push({ ...input })
sessionIDs.push(options?.sessionID)
const previous = started
started = yield* Deferred.make<void>()
yield* Deferred.succeed(previous, undefined)
return yield* websearch.query(input, options)
return yield* websearch.query(input)
}),
})
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
+64 -136
View File
@@ -15,10 +15,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
@@ -28,7 +26,7 @@ const locations = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const map = yield* LayerMap.make(
return yield* LayerMap.make(
(ref: Location.Ref) =>
// The fixture only exercises these three Location services.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
@@ -53,11 +51,7 @@ const locations = Layer.effect(
title: "Questions",
fields: [{ key: "runtime", type: "string" }],
})
.pipe(
Effect.orDie,
Effect.as(SessionRunner.DrainResult.Complete()),
Effect.onInterrupt(() => Effect.sleep("5 minutes")),
),
.pipe(Effect.orDie, Effect.as(SessionRunner.DrainResult.Complete())),
})
}),
),
@@ -68,26 +62,12 @@ const locations = Layer.effect(
) as unknown as Layer.Layer<LocationServices>,
{ idleTimeToLive: Duration.infinity },
)
return {
...map,
get: (ref: Location.Ref) => map.get(LocationServiceMap.canonical(ref)),
contextEffect: (ref: Location.Ref) => map.contextEffect(LocationServiceMap.canonical(ref)),
contextEffectOption: (ref: Location.Ref) => map.contextEffectOption(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) => map.invalidate(LocationServiceMap.canonical(ref)),
}
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
LocationServiceMap.node,
SessionExecution.node,
LocationActivity.node,
]),
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, SessionExecution.node, LocationActivity.node]),
[
LocationServiceMap.node.replace(
makeGlobalNode({
@@ -100,122 +80,70 @@ const it = testEffect(
),
)
describe("LocationActivity eviction", () => {
for (const [count, admission] of [
[1, "none"],
[2, "none"],
[1, "other"],
[1, "same"],
] as const) {
const newWork = admission !== "none"
it.effect(
`interrupts ${count} waiting executions before eviction (${admission} session admitted during cleanup)`,
() =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const map = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const sessionIDs = Array.from({ length: count }, (_, index) =>
Session.ID.make(`ses_waiting_question_${index}`),
)
const newcomer = admission === "same" ? sessionIDs[0] : Session.ID.make("ses_new_question")
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values(
Array.from(new Set([...sessionIDs, newcomer]), (sessionID) => ({
id: sessionID,
project_id: Project.ID.global,
slug: "question",
directory: ref.directory,
title: "Waiting question",
version: "test",
})),
)
.run()
.pipe(Effect.orDie)
describe("LocationActivity active execution", () => {
for (const settle of ["answer", "cancel", "interrupt"] as const) {
it.effect(`keeps a waiting question reachable past the deadline until ${settle}`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const map = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const sessionID = Session.ID.make("ses_waiting_question")
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "question",
directory: ref.directory,
title: "Waiting question",
version: "test",
})
.run()
.pipe(Effect.orDie)
const created = yield* Deferred.make<void>()
const newCreated = yield* Deferred.make<void>()
const pending: Form.Info[] = []
const interrupted: SessionEvent.Execution.Interrupted["data"][] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type === SessionEvent.Execution.Interrupted.type) {
interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data))
}
if (event.type !== Form.Event.Created.type) return
pending.push(Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form)
if (pending.length === count) yield* Deferred.succeed(created, undefined)
if (pending.length > count) yield* Deferred.succeed(newCreated, undefined)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const running = yield* Effect.forEach(sessionIDs, (sessionID) =>
execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped),
)
yield* Effect.addFinalizer(() =>
Effect.forEach([...sessionIDs, newcomer], (sessionID) => execution.interrupt(sessionID)).pipe(
Effect.andThen(TestClock.adjust("5 minutes")),
),
)
yield* Deferred.await(created)
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
const forms = Context.get(context, Form.Service)
expect((yield* store.listSuspended()).toSorted()).toEqual(sessionIDs.toSorted())
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
const created = yield* Deferred.make<Form.Info>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Created.type
? Deferred.succeed(created, Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form).pipe(
Effect.asVoid,
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const running = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
const form = yield* Deferred.await(created)
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
// Human input produces no durable activity while the question is pending.
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("62 minutes")
// Interruption has cancelled each question, but slow cleanup still owns the graph.
expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted())
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
expect(yield* forms.list()).toEqual([])
for (const form of pending) expect(yield* forms.state(form.id)).toEqual({ status: "cancelled" })
// The first sweep discovers both cached graphs. No more Session events
// are needed while the human is deciding how to answer.
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("62 minutes")
expect(yield* execution.isActive(sessionID)).toBe(true)
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
const forms = Context.get(context, Form.Service)
expect(yield* forms.list({ sessionID })).toEqual([form])
if (newWork) {
yield* execution.wake(newcomer)
if (admission === "other") yield* Deferred.await(newCreated)
}
yield* TestClock.adjust("5 minutes")
if (newWork) yield* Deferred.await(newCreated)
const results = yield* Effect.forEach(running, Fiber.join)
expect(results.every((exit) => exit._tag === "Failure")).toBe(true)
expect(Array.from(yield* execution.active)).toEqual(newWork ? [newcomer] : [])
expect(yield* store.listSuspended()).toEqual(newWork ? [newcomer] : [])
expect(interrupted.toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
sessionIDs.map((sessionID) => ({ sessionID, reason: "inactivity" })),
)
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual(newWork ? [ref] : [])
if (newWork) {
expect(yield* forms.list({ sessionID: newcomer })).toEqual([pending[count]])
if (admission === "same") {
const later = LocationServiceMap.canonical({ directory: AbsolutePath.make("/later") })
yield* Location.Service.pipe(Effect.provide(map.get(later)), Effect.scoped)
yield* TestClock.adjust("30 minutes")
// Keep fresh work active while a different graph reaches its own deadline.
yield* bus.publish(SessionEvent.Execution.Started, { sessionID: newcomer }, { location: ref })
yield* TestClock.adjust("32 minutes")
expect(Array.from(yield* execution.active)).toEqual([newcomer])
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
}
yield* execution.interrupt(newcomer)
yield* TestClock.adjust("5 minutes")
yield* execution.awaitIdle(newcomer)
yield* TestClock.adjust("62 minutes")
expect(yield* store.listSuspended()).toEqual([])
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
}
}),
if (settle === "answer") yield* forms.reply({ id: form.id, answer: { runtime: "Bun" } })
if (settle === "cancel") yield* forms.cancel(form.id)
if (settle === "interrupt") yield* execution.interrupt(sessionID)
yield* Fiber.join(running)
yield* execution.awaitIdle(sessionID)
expect(yield* forms.state(form.id)).toEqual(
settle === "answer" ? { status: "answered", answer: { runtime: "Bun" } } : { status: "cancelled" },
)
yield* TestClock.adjust("62 minutes")
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
}),
)
}
})
@@ -17,29 +17,24 @@ interface WebSearchRequest {
}
export const requests: WebSearchRequest[] = []
export const signals: AbortSignal[] = []
let responseBody = ""
let responseStatus = 200
export function resetWebSearchFixture(body: string, status = 200) {
export function resetWebSearchFixture(body: string) {
requests.length = 0
signals.length = 0
responseBody = body
responseStatus = status
}
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request, _url, signal) =>
HttpClient.make((request) =>
Effect.sync(() => {
signals.push(signal)
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: responseStatus }))
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
}),
),
)
+1 -20
View File
@@ -7,7 +7,7 @@ import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, signals, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
resetWebSearchFixture(
@@ -30,25 +30,6 @@ beforeEach(() => {
const it = webSearchIntegrationTest
describe("built-in web search providers", () => {
;[WebSearchExa.Plugin, WebSearchParallel.Plugin, WebSearchFirecrawl.Plugin, WebSearchTavily.Plugin].forEach(
(plugin) => {
it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () =>
Effect.gen(function* () {
resetWebSearchFixture("Rate limited", 429)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* websearch.select("random")
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(signals).toHaveLength(1)
expect(signals[0]?.aborted).toBe(true)
}),
)
},
)
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+12 -86
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import type { HttpClientError } from "effect/unstable/http"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
@@ -131,11 +131,10 @@ describe("WebSearchTool registration", () => {
expect(fixture.websearch.queries).toEqual([
{
query: "effect typescript",
providerID: undefined,
providerID: WebSearch.ID.make("exa"),
},
])
expect(fixture.events).toEqual(["permission", "query"])
expect(fixture.websearch.sessionIDs).toEqual([sessionID])
}),
)
@@ -216,7 +215,7 @@ describe("WebSearchTool registration", () => {
})
expect(first.status).toBe("completed")
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
expect(fixture.websearch.sessionIDs).toEqual([sessionID, sessionID])
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(fixture.websearch.queries).toHaveLength(2)
expect(fixture.formRequests).toEqual([
@@ -254,31 +253,12 @@ describe("WebSearchTool registration", () => {
})
expect(second.status).toBe("completed")
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
expect(second.metadata?.provider).toBe(first.metadata?.provider)
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
expect(fixture.formRequests).toHaveLength(1)
expect(fixture.websearch.queries).toHaveLength(3)
}),
)
it.effect("honors automatic consent when the configured provider is unavailable", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.transform((editor) => editor.default.set(WebSearch.ID.make("missing")))
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
const result = yield* executeTool(fixture.registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "websearch", input: { query: "effect" } },
})
expect(result.status).toBe("completed")
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
expect(result.metadata?.provider).toBe(
(yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID,
)
expect(fixture.formRequests).toHaveLength(1)
}),
)
it.effect("asks a second form when choosing another provider", () =>
Effect.gen(function* () {
const fixture = yield* setup
@@ -367,67 +347,6 @@ describe("WebSearchTool registration", () => {
}),
)
it.effect("keeps provider progress, output, and metadata accurate across automatic failover", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select("random")
const first = (yield* fixture.websearch.query({ query: "seed" }, { sessionID })).providerID
yield* fixture.websearch.transform((editor) =>
editor.add({
id: first,
name: first,
execute: () => Effect.fail(TestWebSearch.httpError()),
}),
)
const progress: Tool.Metadata[] = []
const tools = yield* fixture.registry.snapshot()
const result = yield* tools.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-failover", name: "websearch", input: { query: "effect" } },
progress: (metadata) =>
Effect.sync(() => {
progress.push(metadata)
}),
})
const replacement = WebSearch.ID.make(first === "exa" ? "parallel" : "exa")
expect(progress).toEqual([{ provider: first }, { provider: replacement }])
expect(result).toMatchObject({
output: { provider: replacement, results: fixture.results },
metadata: { provider: replacement },
})
expect(fixture.formRequests).toEqual([])
expect((yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID).toBe(replacement)
}),
)
it.effect("does not reopen consent when all automatic providers are cooling down", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.websearch.select("random")
fixture.error = TestWebSearch.httpError()
const tools = yield* fixture.registry.snapshot()
yield* Effect.forEach(["first", "cooling"], (query) =>
Effect.gen(function* () {
const error = yield* tools
.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: `call-${query}`, name: "websearch", input: { query } },
})
.pipe(Effect.flip)
expect(toSessionError(error)).toEqual({
type: "tool.execution",
message: "Web search rate limited (HTTP 429)",
})
expect(error.metadata).toMatchObject({ provider: expect.stringMatching(/^(exa|parallel)$/) })
}),
)
expect(fixture.events.filter((event) => event === "query")).toHaveLength(2)
expect(fixture.formRequests).toEqual([])
}),
)
it.effect("reports safe HTTP failures with the attempted provider", () =>
Effect.gen(function* () {
const fixture = yield* setup
@@ -443,7 +362,14 @@ describe("WebSearchTool registration", () => {
],
({ status, message }, index) =>
Effect.gen(function* () {
fixture.error = TestWebSearch.httpError(status, undefined, "https://mcp.exa.ai/mcp?exaApiKey=secret")
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
fixture.error = new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
})
const progress: Tool.Metadata[] = []
const error = yield* tools
.execute({
+6 -444
View File
@@ -1,34 +1,24 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { Effect, Exit, Scope } from "effect"
import { KV } from "@opencode-ai/core/kv"
import { Bus } from "@opencode-ai/core/bus"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { testEffect } from "./lib/effect"
import { TestWebSearch } from "./lib/websearch"
const it = testEffect(TestWebSearch.layer)
const firstSession = Session.ID.make("ses_search_first")
const secondSession = Session.ID.make("ses_search_second")
const register = (id: string) =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
const calls: WebSearch.ProviderInput[] = []
const failure: { cause?: unknown } = {}
const registration = yield* websearch.transform((editor) => {
yield* websearch.transform((editor) => {
editor.add({
id: providerID,
name: id.toUpperCase(),
execute: (input) =>
Effect.gen(function* () {
Effect.sync(() => {
calls.push(input)
if (failure.cause !== undefined) return yield* Effect.fail(failure.cause)
return [
{
url: `https://${id}.example.com`,
@@ -40,7 +30,7 @@ const register = (id: string) =>
}),
})
})
return { providerID, calls, failure, dispose: registration.dispose }
return { providerID, calls }
})
describe("WebSearch", () => {
@@ -147,445 +137,17 @@ describe("WebSearch", () => {
}),
)
it.effect("keeps the random provider across queries, default lookups, and reloads", () =>
it.effect("chooses a registered provider for random selection", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((editor) => editor.default.set("random"))
const first = yield* websearch.query({ query: "first" })
expect(["exa", "parallel"]).toContain(first.providerID)
expect((yield* websearch.default())?.id).toBe(first.providerID)
yield* websearch.reload()
const results = yield* Effect.all(
Array.from({ length: 10 }, () => websearch.query({ query: "next" })),
{ concurrency: "unbounded" },
)
expect(results.every((result) => result.providerID === first.providerID)).toBe(true)
expect(["exa", "parallel"]).toContain((yield* websearch.query({ query: "random" })).providerID)
}),
)
it.effect("preserves persisted random selection and keeps its provider", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set(WebSearch.ProviderKey, "random")
const first = yield* websearch.query({ query: "legacy" })
expect((yield* websearch.query({ query: "sticky" })).providerID).toBe(first.providerID)
yield* websearch.select("random")
expect(yield* kv.get(WebSearch.ProviderKey)).toBe("random")
expect((yield* websearch.query({ query: "canonical" })).providerID).toBe(first.providerID)
}),
)
it.effect("fails over on rate limits with random and keeps the replacement after cooldown", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((editor) => editor.default.set("random"))
const first = yield* websearch.query({ query: "first" })
const limited = first.providerID === exa.providerID ? exa : parallel
const replacement = first.providerID === exa.providerID ? parallel : exa
limited.failure.cause = TestWebSearch.httpError()
const progress: WebSearch.ID[] = []
expect(
(yield* websearch.query(
{ query: "retry" },
{
onProvider: (provider) =>
Effect.sync(() => {
progress.push(provider.id)
}),
},
)).providerID,
).toBe(replacement.providerID)
expect(progress).toEqual([limited.providerID, replacement.providerID])
expect(limited.calls.at(-1)).toEqual({ query: "retry" })
expect(replacement.calls).toEqual([{ query: "retry" }])
limited.failure.cause = undefined
yield* TestClock.adjust("59 seconds")
expect((yield* websearch.query({ query: "cooling" })).providerID).toBe(replacement.providerID)
expect(limited.calls).toHaveLength(2)
yield* TestClock.adjust("1 second")
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(replacement.providerID)
replacement.failure.cause = TestWebSearch.httpError()
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(limited.providerID)
}),
)
it.effect("reselects when a concurrent query cools down the provider while progress is pending", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
yield* websearch.query({ query: "seed" })
const first = yield* websearch.default()
if (!first) return yield* Effect.die("Expected an automatic provider")
const limited = first.id === exa.providerID ? exa : parallel
const replacement = first.id === exa.providerID ? parallel : exa
const paused = yield* Deferred.make<void>()
const resume = yield* Deferred.make<void>()
const progress: WebSearch.ID[] = []
const pending = yield* websearch
.query(
{ query: "pending" },
{
onProvider: (provider) =>
Effect.gen(function* () {
progress.push(provider.id)
if (provider.id !== first.id) return
yield* Deferred.succeed(paused, undefined)
yield* Deferred.await(resume)
}),
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(paused)
limited.failure.cause = TestWebSearch.httpError()
expect((yield* websearch.query({ query: "trigger" })).providerID).toBe(replacement.providerID)
yield* Deferred.succeed(resume, undefined)
expect((yield* Fiber.join(pending)).providerID).toBe(replacement.providerID)
expect(progress).toEqual([limited.providerID, replacement.providerID])
expect(limited.calls).toEqual([{ query: "seed" }, { query: "trigger" }])
expect(replacement.calls).toEqual([{ query: "trigger" }, { query: "pending" }])
}),
)
it.effect("fails promptly when all providers are cooling down without asking for a provider", () =>
Effect.gen(function* () {
const providers = [yield* register("exa"), yield* register("parallel"), yield* register("tavily")]
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
providers.forEach((provider) => {
provider.failure.cause = TestWebSearch.httpError()
})
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
expect(yield* websearch.default()).toBeDefined()
expect(yield* websearch.query({ query: "still limited" }).pipe(Effect.flip)).toBeInstanceOf(
WebSearch.RequestError,
)
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
}),
)
it.effect("tries each provider only once per query even with a zero cooldown", () =>
Effect.gen(function* () {
const providers = [yield* register("exa"), yield* register("parallel")]
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
providers.forEach((provider) => {
provider.failure.cause = TestWebSearch.httpError(429, "0")
})
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1])
}),
)
;[
{ header: "120", millis: 120_000 },
{ header: "Thu, 01 Jan 1970 00:02:00 GMT", millis: 120_000 },
{ header: undefined, millis: 60_000 },
{ header: "invalid", millis: 60_000 },
{ header: "", millis: 60_000 },
{ header: "-1", millis: 60_000 },
].forEach(({ header, millis }) => {
it.effect(`respects Retry-After ${JSON.stringify(header)} and recovers after cooldown`, () =>
Effect.gen(function* () {
const provider = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
provider.failure.cause = TestWebSearch.httpError(429, header)
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
provider.failure.cause = undefined
yield* TestClock.adjust(millis - 1)
expect(yield* websearch.query({ query: "early" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
expect(provider.calls).toHaveLength(1)
yield* TestClock.adjust(1)
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(provider.providerID)
expect(provider.calls).toHaveLength(2)
}),
)
})
it.effect("does not rotate or cool down providers for other failures", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
const first = yield* websearch.query({ query: "first" })
const provider = first.providerID === exa.providerID ? exa : parallel
yield* Effect.forEach(
[TestWebSearch.httpError(401), TestWebSearch.httpError(500), new Error("timeout")],
(cause) =>
Effect.gen(function* () {
provider.failure.cause = cause
expect(yield* websearch.query({ query: "failure" }).pipe(Effect.flip)).toMatchObject({
providerID: first.providerID,
cause,
})
expect((yield* websearch.default())?.id).toBe(first.providerID)
}),
)
provider.failure.cause = undefined
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(first.providerID)
expect((first.providerID === exa.providerID ? parallel : exa).calls).toEqual([])
}),
)
it.effect("does not fail over fixed or explicitly requested providers", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
exa.failure.cause = TestWebSearch.httpError()
yield* websearch.select(exa.providerID)
expect(yield* websearch.query({ query: "fixed" }).pipe(Effect.flip)).toMatchObject({ providerID: exa.providerID })
yield* websearch.select("random")
expect(yield* websearch.query({ query: "explicit", providerID: exa.providerID }).pipe(Effect.flip)).toMatchObject(
{
providerID: exa.providerID,
},
)
expect(exa.calls).toHaveLength(2)
expect(parallel.calls).toEqual([])
}),
)
it.effect("reselects when the active provider is removed", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
expect((yield* websearch.query({ query: "first" })).providerID).toBe(exa.providerID)
const parallel = yield* register("parallel")
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(exa.providerID)
yield* exa.dispose
expect((yield* websearch.query({ query: "removed" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("uses updated registrations for the sticky provider", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
expect((yield* websearch.query({ query: "original" })).results).toHaveLength(1)
const updated = yield* websearch.transform((editor) =>
editor.add({
id: exa.providerID,
name: "Updated Exa",
execute: () => Effect.succeed([]),
}),
)
expect(yield* websearch.default()).toEqual({ id: exa.providerID, name: "Updated Exa" })
expect((yield* websearch.query({ query: "updated" })).results).toEqual([])
yield* updated.dispose
expect((yield* websearch.query({ query: "restored" })).results).toHaveLength(1)
expect(exa.calls).toEqual([{ query: "original" }, { query: "restored" }])
}),
)
it.effect("keeps independent session affinities across parallel initial and subsequent searches", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
yield* register("tavily")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
const results = yield* Effect.forEach(
[firstSession, secondSession],
(sessionID) =>
Effect.all(
Array.from({ length: 8 }, () => websearch.query({ query: "parallel" }, { sessionID })),
{
concurrency: "unbounded",
},
),
{ concurrency: "unbounded" },
)
expect(results.map((group) => new Set(group.map((result) => result.providerID)).size)).toEqual([1, 1])
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
results[0]?.[0]?.providerID,
)
expect((yield* websearch.query({ query: "later" }, { sessionID: secondSession })).providerID).toBe(
results[1]?.[0]?.providerID,
)
}),
)
it.effect("does not overwrite peer or Location affinity when a session switches providers", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
yield* websearch.query({ query: "location" })
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
const parallel = yield* register("parallel")
exa.failure.cause = TestWebSearch.httpError()
expect((yield* websearch.query({ query: "switch" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
// Even while Exa is cooling down, inspection must not reroute any caller.
expect((yield* websearch.default())?.id).toBe(exa.providerID)
exa.failure.cause = undefined
yield* TestClock.adjust("1 minute")
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(exa.providerID)
expect((yield* websearch.query({ query: "location" })).providerID).toBe(exa.providerID)
expect((yield* websearch.query({ query: "sticky replacement" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
}),
)
it.effect("shares cooldowns without sending a peer back to the rate-limited provider", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.select("random")
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
const parallel = yield* register("parallel")
exa.failure.cause = TestWebSearch.httpError(429, "120")
yield* websearch.query({ query: "switch" }, { sessionID: firstSession })
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(
parallel.providerID,
)
expect(exa.calls).toHaveLength(3)
exa.failure.cause = undefined
yield* TestClock.adjust("2 minutes")
expect((yield* websearch.query({ query: "sticky peer" }, { sessionID: secondSession })).providerID).toBe(
parallel.providerID,
)
}),
)
it.effect("converges overlapping session failures and ignores a late success on the old provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const arrived = yield* Deferred.make<void>()
const failures = yield* Deferred.make<void>()
const lateStarted = yield* Deferred.make<void>()
const lateRelease = yield* Deferred.make<void>()
const calls: string[] = []
yield* websearch.transform((editor) =>
editor.add({
id: WebSearch.ID.make("exa"),
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
if (input.query === "seed") return []
if (input.query === "late") {
yield* Deferred.succeed(lateStarted, undefined)
yield* Deferred.await(lateRelease)
return []
}
calls.push(input.query)
if (calls.length === 3) yield* Deferred.succeed(arrived, undefined)
yield* Deferred.await(failures)
return yield* TestWebSearch.httpError()
}),
}),
)
yield* websearch.select("random")
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
const late = yield* websearch.query({ query: "late" }, { sessionID: firstSession }).pipe(Effect.forkChild)
yield* Deferred.await(lateStarted)
yield* register("parallel")
yield* register("tavily")
const pending = yield* Effect.all(
Array.from({ length: 3 }, (_, index) =>
websearch.query({ query: `fail-${index}` }, { sessionID: firstSession }),
),
{ concurrency: "unbounded" },
).pipe(Effect.forkChild)
yield* Deferred.await(arrived)
yield* Deferred.succeed(failures, undefined)
const results = yield* Fiber.join(pending)
expect(new Set(results.map((result) => result.providerID)).size).toBe(1)
expect(results[0]?.providerID).not.toBe(WebSearch.ID.make("exa"))
yield* Deferred.succeed(lateRelease, undefined)
expect((yield* Fiber.join(late)).providerID).toBe(WebSearch.ID.make("exa"))
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
results[0]?.providerID,
)
}),
)
it.effect("keeps fixed and explicit providers pinned with session context", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
exa.failure.cause = TestWebSearch.httpError()
yield* websearch.select(exa.providerID)
expect(yield* websearch.query({ query: "fixed" }, { sessionID: firstSession }).pipe(Effect.flip)).toMatchObject({
providerID: exa.providerID,
})
yield* websearch.select("random")
expect(
yield* websearch
.query({ query: "explicit", providerID: exa.providerID }, { sessionID: firstSession })
.pipe(Effect.flip),
).toMatchObject({ providerID: exa.providerID })
expect(parallel.calls).toEqual([])
}),
)
;["delete", "move"].forEach((operation) => {
it.effect(`forgets affinity on session ${operation} without retaining it through an in-flight query`, () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const websearch = yield* WebSearch.Service
const bus = yield* Bus.Service
yield* websearch.select("random")
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const pending = yield* websearch
.query(
{ query: "pending" },
{
sessionID: firstSession,
onProvider: (provider) =>
provider.id === exa.providerID
? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Effect.void,
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* operation === "delete"
? bus.publish(SessionEvent.Deleted, { sessionID: firstSession })
: bus.publish(SessionEvent.Moved, {
sessionID: firstSession,
location: { directory: AbsolutePath.make("/moved") },
projectID: Project.ID.global,
})
yield* Effect.yieldNow
yield* exa.dispose
const parallel = yield* register("parallel")
expect((yield* websearch.query({ query: "new affinity" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
yield* parallel.dispose
const tavily = yield* register("tavily")
exa.failure.cause = TestWebSearch.httpError()
yield* Deferred.succeed(release, undefined)
expect((yield* Fiber.join(pending)).providerID).toBe(tavily.providerID)
yield* register("parallel")
expect((yield* websearch.query({ query: "still new affinity" }, { sessionID: firstSession })).providerID).toBe(
parallel.providerID,
)
}),
)
})
it.effect("fails when web search is explicitly disabled", () =>
Effect.gen(function* () {
yield* register("exa")
-1
View File
@@ -3,7 +3,6 @@
- Follow Solid best practices, leave a comment when violating this: https://www.brenelz.com/posts/solid-js-best-practices/
- Renderer process should only call `window.api` from `src/preload`.
- Main process should register IPC handlers in `src/main/ipc.ts`.
- Avoid FS operations where possible. For any desktop persistence prefer sqlite in most cases, as performance and EPERM and many other things, especially on windows can be quite painful. Using anything other than sqlite should come with strong reasons.
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "sqlite",
schema: "./src/main/storage/schema.ts",
out: "./src/main/storage/migration",
})
+1 -4
View File
@@ -78,10 +78,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
input: { index: "src/preload/index.ts" },
output: {
format: "cjs",
// The package is "type": "module". Under --no-sandbox Electron loads the preload
// through Node's module loader, which treats a .js file as ESM and fails on
// require("electron"). The sandboxed path ignores the extension.
entryFileNames: "[name].cjs",
entryFileNames: "[name].js",
},
},
},
-2
View File
@@ -13,7 +13,6 @@
"typecheck": "tsgo -b",
"test": "bun test --timeout 30000",
"dev": "bun ./scripts/dev.ts",
"migration": "bun ./scripts/migration.ts",
"prebuild": "bun ./scripts/prebuild.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
@@ -47,7 +46,6 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bun
// Renders the drizzle-kit output under src/main/storage/migration into migration.gen.ts so the
// main bundle and Bun tests import plain TypeScript instead of reading SQL files at runtime.
//
// bun run migration --name <change> generate a migration from schema.ts, then render
// bun run migration render only
// bun run migration --check fail when the rendered registry is stale
import { $ } from "bun"
import path from "node:path"
import { parseArgs } from "node:util"
const root = path.resolve(import.meta.dirname, "..")
const directory = path.join(root, "src/main/storage/migration")
const output = path.join(root, "src/main/storage/migration.gen.ts")
const args = parseArgs({
args: process.argv.slice(2),
options: {
check: { type: "boolean" },
name: { type: "string" },
},
})
if (args.values.name) await $`bun drizzle-kit generate --name ${args.values.name}`.cwd(root)
const rendered = await render()
if (!args.values.check) {
await Bun.write(output, rendered)
process.exit(0)
}
if ((await Bun.file(output).text()) !== rendered) {
throw new Error("Desktop storage migrations are stale. Run `bun run migration` from packages/desktop.")
}
async function render() {
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => path.dirname(file))
.sort()
const migrations = await Promise.all(
ids.map(async (id) => ({
id,
// Normalize so a CRLF checkout renders the same registry as an LF one.
statements: (await Bun.file(path.join(directory, id, "migration.sql")).text())
.replaceAll("\r\n", "\n")
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0),
})),
)
const source = `// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
export const migrations = ${JSON.stringify(migrations, null, 2)}
`
const prettier = await import("prettier")
return prettier.format(source, { parser: "typescript", semi: false, printWidth: 120 })
}
@@ -1,35 +1,29 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import { StorageChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
export const storageHandlers = StorageRpcs.toLayer(
Effect.gen(function* () {
const storage = yield* DesktopStorage.Service
const handoff = yield* IpcPortHandoff
return StorageRpcs.of({
StorageItems: ({ name }) => Effect.sync(() => storage.state.items(name)),
StorageUpdate: ({ name, insert, remove }, context) =>
Effect.sync(() => {
const revision = storage.state.update(name, insert, remove)
// Other windows hold their own copy of this namespace; tell them what moved.
const origin = sender(handoff, context)
const event = new StorageChanged({ name, insert, remove, revision })
for (const win of BrowserWindow.getAllWindows()) {
if (win.webContents !== origin) emitIpcEvent(win.webContents, event)
}
return revision
}),
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) => Effect.sync(() => storage.drafts.putBlob(data)),
DraftsGetBlob: ({ id }) => Effect.sync(() => storage.drafts.getBlob(id)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
})
}),
)
+2 -1
View File
@@ -19,11 +19,12 @@ import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -10,6 +10,7 @@ import {
prepareApplicationEnvironment,
prepareDesktop,
} from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
export interface Interface {
readonly version: string
@@ -22,6 +23,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* loadProxyEnvironment
@@ -8,6 +8,7 @@ import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { cleanupStoreFiles } from "../storage/cleanup"
import { registerRendererProtocol, setDockIcon } from "../windows"
const appNames: Record<string, string> = {
@@ -73,6 +74,14 @@ export const preferApplicationEnvironment = Effect.gen(function* () {
export const prepareDesktop = Effect.gen(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
yield* cleanupStoreFiles(app.getPath("userData")).pipe(
Effect.tap((result) =>
result.deleted.length === 0
? Effect.void
: Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }),
),
Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
yield* registerRendererProtocol()
+1 -12
View File
@@ -6,11 +6,9 @@ import { Context, Effect, Layer } from "effect"
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
export interface Interface {
@@ -145,22 +143,13 @@ const runtime = Layer.effect(
}),
)
// Storage opens after configureApplication has set userData and before windows exist, so window
// teardown can clear a window's persisted state and every renderer request finds it ready.
const platform = Layer.mergeAll(
DesktopLogging.layer,
Shutdown.layer,
DesktopStorage.layer.pipe(Layer.provide(DesktopLogging.layer)),
)
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
export const layer = Layer.unwrap(
Effect.gen(function* () {
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
// 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"))
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+7 -18
View File
@@ -2,7 +2,7 @@ export * as DesktopLogging from "./logging"
import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect"
import { homedir } from "node:os"
import { VERSION } from "../constants"
@@ -134,15 +134,11 @@ function exportDebugLogsEffect(fs: FileSystem.FileSystem, path: Path.Path) {
const output = path.join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
return yield* Effect.gen(function* () {
yield* Effect.logInfo("exporting debug logs", { output })
const files = [
yield* writeZip(fs, output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(path), null, 2)) },
...(yield* collect(fs, path, root, "desktop")),
...(yield* Effect.forEach(serverLogRoots(path), (dir, i) => collect(fs, path, dir, `server-${i + 1}`))).flat(),
...(yield* collect(fs, path, app.getPath("crashDumps"), "crashpad")),
]
const truncated = files.filter((file) => file.offset > 0).map((file) => file.name)
yield* writeZip(fs, output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify({ ...manifest(path), truncated }, null, 2)) },
...files,
])
yield* Effect.sync(() => shell.showItemInFolder(output))
return output
@@ -233,7 +229,7 @@ function serverLogRoots(path: Path.Path) {
]
}
type Entry = { name: string; path: string; offset: number } | { name: string; data: Uint8Array }
type Entry = { name: string; path: string } | { name: string; data: Uint8Array }
function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix: string) {
return Effect.gen(function* () {
@@ -246,11 +242,9 @@ function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix
const info = yield* fs.stat(file)
if (info.type === "Directory") return null
if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) return null
if (info.size > FileSystem.Size(MAX_EXPORT_FILE_SIZE)) return null
if (file.endsWith(".heapsnapshot")) return null
// Server logs append forever without rotation, so the active log is often the largest
// file. Export its tail rather than dropping the most relevant file from the bundle.
const offset = Math.max(0, Number(info.size) - MAX_EXPORT_FILE_SIZE)
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file, offset }
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file }
}),
)).filter((entry) => entry !== null)
})
@@ -264,12 +258,7 @@ function writeZip(fs: FileSystem.FileSystem, output: string, entries: Entry[]) {
entries,
(entry) =>
Effect.gen(function* () {
const data =
"data" in entry
? entry.data
: entry.offset === 0
? yield* fs.readFile(entry.path)
: Buffer.concat(yield* Stream.runCollect(fs.stream(entry.path, { offset: entry.offset })))
const data = "data" in entry ? entry.data : yield* fs.readFile(entry.path)
yield* Effect.tryPromise(() => writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))))
}),
{ concurrency: 1, discard: true },
+1 -1
View File
@@ -13,7 +13,7 @@ export const resolve = Effect.gen(function* () {
const root = path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))
return {
developmentResourcesRoot: path.join(root, "../../resources"),
preloadPath: path.join(root, "../preload/index.cjs"),
preloadPath: path.join(root, "../preload/index.js"),
rendererRoot: path.join(root, "../renderer"),
} satisfies Resolved
}).pipe(Effect.orDie)
@@ -0,0 +1,141 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import { Effect, FileSystem, Layer, Path } from "effect"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" })
roots.push(root)
return root
})
const writeStore = Effect.fn("StorageTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified: Date,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("store cleanup", () => {
test("removes empty scoped stores and leaves global stores alone", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(root, "opencode.draft.empty.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
yield* writeStore(root, "opencode.global.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.global.dat",
"opencode.workspace.empty.dat.json",
])
}),
),
)
test("removes stale drafts by age without removing non-empty workspace stores", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(
root,
"opencode.draft.old.dat",
'{"draft:prompt":"hello"}',
new Date("2026-05-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
yield* writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
}),
),
)
test("caps scoped stores by recency", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
{ concurrency: "unbounded", discard: true },
)
const result = yield* cleanupStoreFiles(root, now.getTime())
const remaining = yield* fs.readDirectory(root)
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
}),
),
)
test("removes a scoped store immediately when it becomes empty", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
yield* writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
yield* writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
expect(yield* deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(yield* deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(yield* fs.readDirectory(root)).toEqual(["opencode.global.dat"])
}),
),
)
})
@@ -0,0 +1,104 @@
import { Effect, FileSystem, Option, Path } from "effect"
const EMPTY_STORE_MAX_BYTES = 128
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
type StoreKind = "draft" | "workspace"
type StoreCandidate = {
name: string
path: string
kind: StoreKind
modified: number
empty: boolean
}
export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function* (
userDataPath: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.orElseSucceed(() => []))
const candidates = (yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const kind = storeKind(entry)
if (!kind) return
const file = path.join(userDataPath, entry)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
return {
name: entry,
path: file,
kind,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
empty: yield* isEmptyStore(file, stats.size),
}
}),
{ concurrency: 5 },
)).filter((candidate) => !!candidate)
const stale = new Set<StoreCandidate>()
for (const candidate of candidates) {
if (candidate.empty) stale.add(candidate)
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
}
candidates
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
.sort((a, b) => b.modified - a.modified)
.slice(DRAFT_KEEP_RECENT)
.forEach((candidate) => stale.add(candidate))
const deleted = yield* Effect.forEach(
stale,
Effect.fnUntraced(function* (candidate) {
yield* fs.remove(candidate.path, { force: true })
return candidate.name
}),
{ concurrency: "unbounded" },
)
return { scanned: candidates.length, deleted }
})
export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* (
userDataPath: string,
name: string,
) {
if (!storeKind(name)) return false
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const file = path.join(userDataPath, name)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return false
if (!(yield* isEmptyStore(file, stats.size))) return false
yield* fs.remove(file, { force: true })
return true
})
function storeKind(name: string): StoreKind | undefined {
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
}
const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) {
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
const fs = yield* FileSystem.FileSystem
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return false
if (raw.trim() === "") return true
try {
const parsed = JSON.parse(raw) as unknown
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
} catch {
return false
}
})
@@ -1,48 +0,0 @@
import { describe, expect, test } from "bun:test"
import { DatabaseSync } from "node:sqlite"
import path from "node:path"
import { sql } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { migrate, openDatabase } from "./database"
import { migrations } from "./migration.gen"
const tables = (db: ReturnType<typeof drizzle>) =>
db
.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
.map((row) => row.name)
describe("database", () => {
test("bootstraps every table on a fresh database and is idempotent", () => {
const database = openDatabase(":memory:")
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
expect(migrate(database.db)).toEqual([])
database.close()
})
test("adopts a drafts.sqlite created before the journal existed", () => {
const native = new DatabaseSync(":memory:")
native.exec(
"CREATE TABLE document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE blob (id TEXT PRIMARY KEY, data BLOB NOT NULL); INSERT INTO document VALUES ('k', 'v')",
)
const db = drizzle({ client: native })
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
expect(migrate(db)).toEqual([])
})
test("rendered registry matches the drizzle-kit output on disk", async () => {
const directory = path.join(import.meta.dirname, "migration")
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
.map((file) => path.dirname(file))
.sort()
expect(migrations.map((migration) => migration.id)).toEqual(ids)
for (const migration of migrations) {
const source = (await Bun.file(path.join(directory, migration.id, "migration.sql")).text()).replaceAll(
"\r\n",
"\n",
)
for (const statement of migration.statements) expect(source).toContain(statement)
}
})
})
@@ -1,48 +0,0 @@
import { DatabaseSync } from "node:sqlite"
import { sql } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { migrations } from "./migration.gen"
export type Database = ReturnType<typeof drizzle>
// Owned by the runner rather than schema.ts so drizzle-kit never tries to migrate the journal itself.
const journal = sqliteTable("migration", {
id: text().primaryKey(),
time_completed: integer().notNull(),
})
export function openDatabase(filename: string) {
const native = new DatabaseSync(filename)
// WAL keeps readers off the writer. NORMAL fsyncs at checkpoints only, which survives an app
// crash but not power loss; the right trade for UI state and far cheaper on Windows.
native.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY")
const db = drizzle({ client: native })
migrate(db)
return { db, close: () => native.close() }
}
export function migrate(db: Database) {
db.run(sql`CREATE TABLE IF NOT EXISTS ${journal} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
const applied = new Set(
db
.select({ id: journal.id })
.from(journal)
.all()
.map((row) => row.id),
)
// drafts.sqlite predates the journal: its tables were created by hand, so the migration that
// would create them is recorded as applied instead of run.
const legacy =
applied.size === 0 &&
db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'document'`) !== undefined
const pending = migrations.filter((migration) => !applied.has(migration.id))
if (pending.length === 0) return []
db.transaction((tx) => {
pending.forEach((migration, index) => {
if (!(legacy && index === 0)) migration.statements.forEach((statement) => tx.run(sql.raw(statement)))
tx.insert(journal).values({ id: migration.id, time_completed: Date.now() }).run()
})
})
return pending.map((migration) => migration.id)
}
@@ -1,32 +1,29 @@
import { describe, expect, test } from "bun:test"
import { sql } from "drizzle-orm"
import { openDatabase } from "./database"
import { createDraftStore } from "./drafts"
import { expect, test } from "bun:test"
import { createDesktopDraftStore } from "./drafts"
describe("draft store", () => {
test("queues documents and reads them back before and after flush", () => {
const database = openDatabase(":memory:")
const drafts = createDraftStore(database.db, { delay: 1_000 })
drafts.set("a:draft:prompt", "{}")
expect(drafts.get("a:draft:prompt")).toBe("{}")
drafts.flush()
expect(drafts.get("a:draft:prompt")).toBe("{}")
drafts.set("a:draft:prompt", null)
expect(drafts.get("a:draft:prompt")).toBeNull()
drafts.flush()
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
})
test("flushes the latest buffered draft and stores blobs", () => {
const store = createDesktopDraftStore(":memory:")
store.set("prompt", "first")
store.set("prompt", "latest")
expect(store.get("prompt")).toBe("latest")
store.flush()
expect(store.get("prompt")).toBe("latest")
test("stores blobs by content hash and collects unreferenced ones on open", () => {
const database = openDatabase(":memory:")
const first = createDraftStore(database.db, { delay: 1_000 })
const used = first.putBlob(new Uint8Array([1, 2, 3]))
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
first.flush()
const second = createDraftStore(database.db, { delay: 1_000 })
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(second.getBlob(unused)).toBeNull()
})
const bytes = new TextEncoder().encode("image")
const id = store.putBlob(bytes)
expect(store.getBlob(id)).toEqual(bytes)
store.close()
})
test("allows repeated flushes until closing", () => {
const store = createDesktopDraftStore(":memory:")
store.set("prompt", "first")
store.flush()
store.set("prompt", "draft")
store.flush()
expect(store.get("prompt")).toBe("draft")
store.close()
expect(() => store.flush()).not.toThrow()
expect(() => store.close()).not.toThrow()
})
+70 -46
View File
@@ -1,39 +1,72 @@
import { createHash } from "node:crypto"
import { eq, sql } from "drizzle-orm"
import type { Database } from "./database"
import { blobs, document } from "./schema"
import { createWriteBehind } from "./write-behind"
import { DatabaseSync } from "node:sqlite"
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/node-sqlite"
import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core"
export type DraftStore = ReturnType<typeof createDraftStore>
const documents = sqliteTable("document", {
key: text().primaryKey(),
value: text().notNull(),
})
const blobs = sqliteTable("blob", {
id: text().primaryKey(),
data: blob({ mode: "buffer" }).notNull(),
})
export function createDraftStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
collectBlobs(db)
const byKey = eq(document.key, sql.placeholder("key"))
const read = db.select({ value: document.value }).from(document).where(byKey).prepare()
const remove = db.delete(document).where(byKey).prepare()
const upsert = db
.insert(document)
.values({ key: sql.placeholder("key"), value: sql.placeholder("value") })
.onConflictDoUpdate({ target: document.key, set: { value: sql.placeholder("value") } })
.prepare()
const writer = createWriteBehind<string | null>({
delay: input.delay ?? 500,
onError: input.onError,
write: (batch) =>
db.transaction(() => {
for (const [key, value] of batch) {
if (value === null) remove.run({ key })
else upsert.run({ key, value })
}
export function createDesktopDraftStore(filename: string) {
const native = new DatabaseSync(filename)
native.exec(
"PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);",
)
const db = drizzle({ client: native })
const used = new Set<string>()
db.select({ value: documents.value })
.from(documents)
.all()
.forEach(({ value }) =>
JSON.parse(value, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
}),
})
)
db.select({ id: blobs.id })
.from(blobs)
.all()
.filter(({ id }) => !used.has(id))
.forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run())
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let closed = false
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (closed) return
const writes = [...pending]
pending.clear()
if (!writes.length) return
db.transaction((tx) => {
writes.forEach(([key, value]) => {
if (value === null) tx.delete(documents).where(eq(documents.key, key)).run()
else
tx.insert(documents)
.values({ key, value })
.onConflictDoUpdate({ target: documents.key, set: { value } })
.run()
})
})
}
const schedule = () => {
if (!timer) timer = setTimeout(flush, 500)
}
return {
get(key: string) {
if (writer.has(key)) return writer.get(key) ?? null
return read.get({ key })?.value ?? null
get: (key: string) =>
pending.has(key)
? (pending.get(key) ?? null)
: (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null),
set(key: string, value: string | null) {
pending.set(key, value)
schedule()
},
set: (key: string, value: string | null) => writer.set(key, value),
putBlob(data: Uint8Array) {
const id = createHash("sha256").update(data).digest("hex")
db.insert(blobs)
@@ -42,22 +75,13 @@ export function createDraftStore(db: Database, input: { delay?: number; onError?
.run()
return id
},
getBlob(id: string): Uint8Array | null {
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null,
flush,
close() {
if (closed) return
flush()
closed = true
native.close()
},
flush: writer.flush,
close: writer.close,
}
}
// Blobs are content-addressed and shared; drop the ones no document references anymore. SQLite
// walks the JSON itself, so startup does not parse every draft and history entry in JavaScript.
function collectBlobs(db: Database) {
db.run(sql`
DELETE FROM ${blobs} WHERE ${blobs.id} NOT IN (
SELECT json_extract(node.value, '$.id')
FROM ${document}, json_tree(${document.value}) AS node
WHERE json_valid(${document.value}) AND node.key = 'blob' AND node.type = 'object'
)
`)
}
+43 -37
View File
@@ -2,10 +2,8 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { openDatabase } from "./database"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
import { createDesktopDraftStore } from "./drafts"
import { getStore, removeStoreFileIfEmpty } from "./store"
export type Interface = ReturnType<typeof make>
@@ -15,52 +13,60 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const runFork = Effect.runForkWith(yield* Effect.context())
const userData = app.getPath("userData")
const storage = make(path.join(userData, "drafts.sqlite"), (error) =>
runFork(Effect.logError("storage flush failed", { error })),
)
yield* importLegacyStores(storage.db, userData).pipe(
Effect.tap((result) =>
result.removed.length === 0
? Effect.void
: Effect.logInfo("imported legacy store files", { imported: result.imported, files: result.removed }),
),
Effect.catch((error) => Effect.logWarning("failed to import legacy store files", { error })),
)
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", storage.flush)
app.on("before-quit", storage.flush)
const storage = make(path.join(app.getPath("userData"), "drafts.sqlite"))
const flush = () => storage.drafts.flush()
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", flush)
app.on("before-quit", flush)
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
app.off("before-quit", storage.flush)
app.off("before-quit", flush)
app.off("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", storage.flush))
storage.close()
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", flush))
storage.drafts.close()
}),
)
return Service.of(storage)
}),
)
// The file keeps its historical name; renaming it would mean moving the drafts it already holds.
export function make(filename: string, onError?: (error: unknown) => void) {
const database = openDatabase(filename)
const state = createStateStore(database.db, { onError })
const drafts = createDraftStore(database.db, { onError })
function make(draftFile: string) {
const drafts = createDesktopDraftStore(draftFile)
const deleteValue = Effect.fn("DesktopStorage.delete")(function* (name: string, key: string) {
getStore(name).delete(key)
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
const clear = Effect.fn("DesktopStorage.clear")(function* (name: string) {
getStore(name).clear()
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
return {
db: database.db,
state,
drafts,
flush() {
state.flush()
drafts.flush()
get(name: string, key: string) {
try {
const value = getStore(name).get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
},
close() {
state.close()
drafts.close()
database.close()
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
deleteValue,
clear,
keys: (name: string) => Object.keys(getStore(name).store),
length: (name: string) => Object.keys(getStore(name).store).length,
drafts: {
get: (key: string) => drafts.get(key),
set: (key: string, value: string | null) => drafts.set(key, value),
putBlob: (data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)),
getBlob(id: string) {
const data = drafts.getBlob(id)
return data ? new Uint8Array(data).buffer : null
},
flush: drafts.flush,
close: drafts.close,
},
}
}
@@ -13,7 +13,6 @@ describe("hasExistingAppState", () => {
test("recognizes state written by an earlier OpenCode launch", () => {
expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
expect(hasExistingAppState([file("drafts.sqlite")])).toBe(true)
expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
expect(hasExistingAppState([directory("opencode")])).toBe(true)
})
@@ -2,7 +2,6 @@ export function hasExistingAppState(entries: Array<{ name: string; directory: bo
return entries.some((entry) => {
if (entry.name === "opencode.settings") return true
if (entry.name.endsWith(".dat")) return true
if (entry.name === "drafts.sqlite") return true
if (/^window-state-.+\.json$/.test(entry.name)) return true
return entry.directory && entry.name === "opencode"
})
@@ -1,148 +0,0 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import { sql } from "drizzle-orm"
import { Effect, FileSystem, Layer, Path } from "effect"
import { openDatabase } from "./database"
import { importLegacyStores } from "./legacy"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
const now = new Date("2026-07-01T00:00:00.000Z")
const day = 24 * 60 * 60 * 1000
const tempRoot = Effect.fn("LegacyTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-legacy-store-" })
roots.push(root)
return root
})
const writeStore = Effect.fn("LegacyTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified = now,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
const listing = Effect.fn("LegacyTest.listing")(function* (root: string) {
const fs = yield* FileSystem.FileSystem
return (yield* fs.readDirectory(root)).sort()
})
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("legacy store import", () => {
test("copies every namespace into state and removes the files", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "m", layout: { sidebar: 1 } }))
yield* writeStore(root, "opencode.window.w1.dat", JSON.stringify({ tabs: "[]" }))
yield* writeStore(root, "default.dat", JSON.stringify({ "settings.v3": "{}" }))
yield* writeStore(root, "opencode.settings", JSON.stringify({ keep: true }))
yield* writeStore(root, "unrelated.txt", "x")
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(4)
expect(rows(database.db)).toEqual([
{ name: "default.dat", key: "settings.v3", value: "{}" },
{ name: "opencode.global.dat", key: "layout", value: '{"sidebar":1}' },
{ name: "opencode.global.dat", key: "model", value: "m" },
{ name: "opencode.window.w1.dat", key: "tabs", value: "[]" },
])
expect(yield* listing(root)).toEqual(["opencode.settings", "unrelated.txt"])
}),
))
test("does not overwrite state that already exists", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
database.db.run(sql`INSERT INTO state VALUES ('opencode.global.dat', 'model', 'new', 0)`)
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "old" }))
yield* importLegacyStores(database.db, root, now.getTime())
expect(rows(database.db)).toEqual([{ name: "opencode.global.dat", key: "model", value: "new" }])
expect(yield* listing(root)).toEqual([])
}),
))
test("leaves unreadable files in place", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.global.dat", "{not json")
yield* writeStore(root, "opencode.workspace.x.dat", JSON.stringify({ terminal: "{}" }))
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(1)
expect(yield* listing(root)).toEqual(["opencode.global.dat"])
}),
))
test("applies draft retention: skips empty, stale, and excess draft files", () =>
run(
Effect.gen(function* () {
const root = yield* tempRoot()
const database = openDatabase(":memory:")
yield* writeStore(root, "opencode.draft.empty.dat", "{}")
yield* writeStore(
root,
"opencode.draft.stale.dat",
JSON.stringify({ "draft:prompt": "old" }),
new Date(now.getTime() - 31 * day),
)
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
JSON.stringify({ "draft:prompt": `${index}` }),
new Date(now.getTime() - index * 1_000),
),
{ concurrency: 5 },
)
const result = yield* importLegacyStores(database.db, root, now.getTime())
expect(result.imported).toBe(100)
const names = rows(database.db).map((row) => row.name)
expect(names).toContain("opencode.draft.0.dat")
expect(names).toContain("opencode.draft.99.dat")
expect(names).not.toContain("opencode.draft.100.dat")
expect(names).not.toContain("opencode.draft.101.dat")
expect(names).not.toContain("opencode.draft.stale.dat")
expect(names).not.toContain("opencode.draft.empty.dat")
expect(yield* listing(root)).toEqual([])
}),
))
})
@@ -1,84 +0,0 @@
import { Effect, FileSystem, Option, Path, Schema } from "effect"
import type { Database } from "./database"
import { state } from "./schema"
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
const Entries = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decode = Schema.decodeUnknownOption(Entries)
type Candidate = { name: string; path: string; modified: number; entries: Record<string, unknown> }
// Before the state table existed, every namespace the renderer persisted was an electron-store
// JSON file in userData. Copy them into SQLite once and remove them; this is the only place the
// storage layer touches the filesystem, and it runs before the first renderer request.
export const importLegacyStores = Effect.fn("DesktopStorage.importLegacyStores")(function* (
db: Database,
userData: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.readDirectory(userData).pipe(Effect.orElseSucceed(() => []))).filter(isStoreFile)
const files = yield* Effect.forEach(
names,
Effect.fnUntraced(function* (name) {
const file = path.join(userData, name)
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
if (stats?.type !== "File") return
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (raw === undefined) return
const entries = decode(raw)
if (Option.isNone(entries)) {
yield* Effect.logWarning("legacy store is not readable, leaving it in place", { name })
return
}
return {
name,
path: file,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
entries: entries.value,
} satisfies Candidate
}),
{ concurrency: 5 },
)
const candidates = files.filter((file) => !!file)
const kept = new Set(
candidates
.filter((file) => isDraft(file.name) && Object.keys(file.entries).length > 0)
.filter((file) => now - file.modified <= DRAFT_RETENTION_MS)
.sort((a, b) => b.modified - a.modified)
.slice(0, DRAFT_KEEP_RECENT)
.map((file) => file.name),
)
const rows = candidates
.filter((file) => !isDraft(file.name) || kept.has(file.name))
.flatMap((file) =>
Object.entries(file.entries).map(([key, value]) => ({
name: file.name,
key,
value: typeof value === "string" ? value : JSON.stringify(value),
updated_at: file.modified,
})),
)
// Existing rows win: a file left behind by an interrupted import must not overwrite newer state.
if (rows.length > 0) {
db.transaction((tx) => {
rows.forEach((row) => tx.insert(state).values(row).onConflictDoNothing().run())
})
}
yield* Effect.forEach(candidates, (file) => fs.remove(file.path, { force: true }), {
concurrency: "unbounded",
discard: true,
})
return { imported: rows.length, removed: candidates.map((file) => file.name) }
})
function isStoreFile(name: string) {
return name === "default.dat" || /^opencode\..+\.dat$/.test(name)
}
function isDraft(name: string) {
return name.startsWith("opencode.draft.")
}
@@ -1,17 +0,0 @@
// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
export const migrations = [
{
id: "20260906234153_drafts",
statements: [
"CREATE TABLE `blob` (\n\t`id` text PRIMARY KEY,\n\t`data` blob NOT NULL\n);",
"CREATE TABLE `document` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);",
],
},
{
id: "20260906234220_state",
statements: [
"CREATE TABLE `state` (\n\t`name` text NOT NULL,\n\t`key` text NOT NULL,\n\t`value` text NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)\n);",
],
},
]
@@ -1,9 +0,0 @@
CREATE TABLE `blob` (
`id` text PRIMARY KEY,
`data` blob NOT NULL
);
--> statement-breakpoint
CREATE TABLE `document` (
`key` text PRIMARY KEY,
`value` text NOT NULL
);
@@ -1,77 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"id": "463be56f-5f29-4831-9d00-1b9c6bd957e3",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
}
],
"renames": []
}
@@ -1,7 +0,0 @@
CREATE TABLE `state` (
`name` text NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)
);
@@ -1,131 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5a2f8b7e-2765-4a0b-8877-a74ee2f29b20",
"prevIds": [
"463be56f-5f29-4831-9d00-1b9c6bd957e3"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"name": "state",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "state"
},
{
"columns": [
"name",
"key"
],
"nameExplicit": false,
"name": "state_pk",
"entityType": "pks",
"table": "state"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
}
],
"renames": []
}
@@ -1,27 +0,0 @@
import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
// Prompt drafts and history. `key` is `${storage}:${key}` as the renderer draft store writes it.
export const document = sqliteTable("document", {
key: text().primaryKey(),
value: text().notNull(),
})
// Images referenced from documents by content hash.
export const blobs = sqliteTable("blob", {
id: text().primaryKey(),
data: blob({ mode: "buffer" }).notNull(),
})
// Everything the renderer persists through `platform.storage(name)`. `name` is the storage
// namespace the app chooses (still spelled like the `.dat` file it used to be) and `key` the
// entry within it, so the app's relocation and alias logic keeps working unchanged.
export const state = sqliteTable(
"state",
{
name: text().notNull(),
key: text().notNull(),
value: text().notNull(),
updated_at: integer().notNull(),
},
(table) => [primaryKey({ columns: [table.name, table.key] })],
)
@@ -1,114 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { sql } from "drizzle-orm"
import { openDatabase } from "./database"
import { createStateStore } from "./state"
const roots: string[] = []
// Bun's node:sqlite shim keeps prepared statements alive after close(), which pins the WAL files
// on Windows. Node (and so Electron) finalizes them; tolerate the leftover here only.
afterEach(() =>
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined))),
)
const open = () => {
const database = openDatabase(":memory:")
return { db: database.db, store: createStateStore(database.db, { delay: 1_000 }) }
}
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
describe("state store", () => {
test("reads its own queued writes before they reach the database", () => {
const { db, store } = open()
store.set("global", "model", "a")
expect(store.get("global", "model")).toBe("a")
expect(rows(db)).toEqual([])
store.flush()
expect(rows(db)).toEqual([{ name: "global", key: "model", value: "a" }])
expect(store.get("global", "model")).toBe("a")
})
test("delete is visible immediately and removes the row on flush", () => {
const { db, store } = open()
store.set("global", "model", "a")
store.flush()
store.delete("global", "model")
expect(store.get("global", "model")).toBeNull()
expect(store.items("global").items).toEqual({})
store.flush()
expect(rows(db)).toEqual([])
})
test("items merges stored rows with queued changes and update returns a rising revision", () => {
const { db, store } = open()
expect(store.items("w")).toEqual({ items: {}, revision: 0 })
expect(store.update("w", { tabs: "[]", recent: "{}" }, [])).toBe(1)
store.flush()
expect(store.update("w", { info: "{}" }, ["recent"])).toBe(2)
expect(store.items("w")).toEqual({ items: { tabs: "[]", info: "{}" }, revision: 2 })
expect(store.items("other")).toEqual({ items: {}, revision: 2 })
store.flush()
expect(rows(db)).toEqual([
{ name: "w", key: "info", value: "{}" },
{ name: "w", key: "tabs", value: "[]" },
])
})
test("clear drops a namespace including queued writes and leaves others alone", () => {
const { db, store } = open()
store.set("w1", "tabs", "[]")
store.set("w2", "tabs", "[]")
store.flush()
store.set("w1", "recent", "{}")
store.clear("w1")
expect(store.get("w1", "recent")).toBeNull()
store.flush()
expect(rows(db)).toEqual([{ name: "w2", key: "tabs", value: "[]" }])
})
test("a failed flush keeps every acknowledged write until a later flush succeeds", () => {
const database = openDatabase(":memory:")
const errors: unknown[] = []
const store = createStateStore(database.db, { delay: 1_000, onError: (error) => errors.push(error) })
store.set("w", "tabs", "[1]")
store.set("w", "recent", "{}")
database.db.run(sql`DROP TABLE state`)
store.flush()
expect(errors).toHaveLength(1)
expect(store.get("w", "tabs")).toBe("[1]")
database.db.run(
sql`CREATE TABLE state (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (name, key))`,
)
store.set("w", "info", "{}")
store.flush()
expect(rows(database.db)).toEqual([
{ name: "w", key: "info", value: "{}" },
{ name: "w", key: "recent", value: "{}" },
{ name: "w", key: "tabs", value: "[1]" },
])
})
test("survives close and reopen on disk", async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-state-"))
roots.push(root)
const file = path.join(root, "drafts.sqlite")
const first = openDatabase(file)
const store = createStateStore(first.db, { delay: 1_000 })
store.set("global", "model", "a")
store.close()
first.close()
const second = openDatabase(file)
expect(createStateStore(second.db).get("global", "model")).toBe("a")
second.close()
})
test("a burst of writes to one key lands as a single upsert", () => {
const { db, store } = open()
for (let index = 0; index < 100; index++) store.set("global", "layout", `${index}`)
store.flush()
expect(rows(db)).toEqual([{ name: "global", key: "layout", value: "99" }])
})
})
@@ -1,89 +0,0 @@
import { and, eq, sql } from "drizzle-orm"
import type { Database } from "./database"
import { state } from "./schema"
import { createWriteBehind } from "./write-behind"
export type StateStore = ReturnType<typeof createStateStore>
type Row = { name: string; key: string; value: string | null }
// Reads hit SQLite directly: they happen at mount time and a point lookup on the primary key
// costs microseconds, so a second in-memory copy would only duplicate the renderer's cache.
export function createStateStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
// Prepared once; the flush loop then only binds values instead of rebuilding SQL per row.
const byKey = and(eq(state.name, sql.placeholder("name")), eq(state.key, sql.placeholder("key")))
const read = db.select({ value: state.value }).from(state).where(byKey).prepare()
const remove = db.delete(state).where(byKey).prepare()
const upsert = db
.insert(state)
.values({
name: sql.placeholder("name"),
key: sql.placeholder("key"),
value: sql.placeholder("value"),
updated_at: sql.placeholder("updated_at"),
})
.onConflictDoUpdate({
target: [state.name, state.key],
set: { value: sql.placeholder("value"), updated_at: sql.placeholder("updated_at") },
})
.prepare()
const writer = createWriteBehind<Row>({
delay: input.delay ?? 250,
onError: input.onError,
write: (batch) =>
db.transaction(() => {
const updated_at = Date.now()
for (const row of batch.values()) {
if (row.value === null) remove.run({ name: row.name, key: row.key })
else upsert.run({ name: row.name, key: row.key, value: row.value, updated_at })
}
}),
})
const id = (name: string, key: string) => `${name}\0${key}`
const set = (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value })
const unset = (name: string, key: string) => writer.set(id(name, key), { name, key, value: null })
// Orders updates for renderer caches: acks and change events reach a window on different
// paths, so a window compares revisions rather than arrival order. Process-local is enough
// because every renderer cache dies with the process too.
let revision = 0
return {
get(name: string, key: string) {
const queued = writer.get(id(name, key))
if (queued) return queued.value
return read.get({ name, key })?.value ?? null
},
set,
delete: unset,
// A renderer loads a namespace once, so queued rows must be folded in for it to see its own
// writes from a previous window session that have not flushed yet.
items(name: string) {
const items = Object.fromEntries(
db
.select({ key: state.key, value: state.value })
.from(state)
.where(eq(state.name, name))
.all()
.map((row) => [row.key, row.value]),
)
for (const row of writer.entries()) {
if (row.name !== name) continue
if (row.value === null) delete items[row.key]
else items[row.key] = row.value
}
return { items, revision }
},
update(name: string, insert: Record<string, string>, removed: readonly string[]) {
for (const [key, value] of Object.entries(insert)) set(name, key, value)
for (const key of removed) unset(name, key)
return ++revision
},
// Rare (window closed for good, explicit clear) so it goes straight to the database.
clear(name: string) {
writer.drop((row) => row.name === name)
db.delete(state).where(eq(state.name, name)).run()
},
flush: writer.flush,
close: writer.close,
}
}
+10 -5
View File
@@ -1,15 +1,12 @@
import Store from "electron-store"
import electron from "electron"
import { Effect } from "effect"
import { deleteStoreFileIfEmpty } from "./cleanup"
import { SETTINGS_STORE } from "./keys"
const cache = new Map<string, Store>()
// Main-process settings only (onboarding, default server, window list, appearance, updater).
// These are read synchronously before the storage layer exists and written on user action, so
// electron-store's synchronous file write is acceptable here. Renderer state goes through
// DesktopStorage instead.
//
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
@@ -26,3 +23,11 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) {
if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
})
export function forgetStore(name: string) {
cache.delete(name)
}
@@ -1,73 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createWriteBehind } from "./write-behind"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe("write-behind", () => {
test("coalesces a burst into one batch with the latest value per key", async () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 10, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.set("b", 1)
writer.set("a", 2)
expect(writer.get("a")).toBe(2)
expect(batches).toHaveLength(0)
await wait(30)
expect(batches).toHaveLength(1)
expect([...batches[0]!]).toEqual([
["a", 2],
["b", 1],
])
expect(writer.has("a")).toBe(false)
})
test("flush writes immediately and close stops accepting writes", () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.flush()
expect(batches).toHaveLength(1)
writer.set("b", 2)
writer.close()
expect(batches).toHaveLength(2)
writer.set("c", 3)
writer.flush()
expect(batches).toHaveLength(2)
})
test("drop removes queued entries matching a predicate", () => {
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
writer.set("a", 1)
writer.set("b", 2)
writer.drop((value) => value === 1)
writer.flush()
expect([...batches[0]!]).toEqual([["b", 2]])
})
test("keeps a failed batch queued and retries it on the next flush", () => {
const errors: unknown[] = []
let fail = true
const batches: Map<string, number>[] = []
const writer = createWriteBehind<number>({
delay: 1_000,
onError: (error) => errors.push(error),
write: (batch) => {
if (fail) throw new Error("disk full")
batches.push(batch)
},
})
writer.set("a", 1)
writer.set("b", 1)
writer.flush()
expect(errors).toHaveLength(1)
expect(writer.get("a")).toBe(1)
fail = false
writer.set("a", 2)
writer.flush()
expect([...batches[0]!].sort()).toEqual([
["a", 2],
["b", 1],
])
})
})
@@ -1,47 +0,0 @@
// Coalesces a burst of writes into one batch. The latest value per key wins, so a store that
// re-serializes on every mutation costs one row write per flush instead of one per change.
export function createWriteBehind<T>(input: {
delay: number
write: (batch: Map<string, T>) => void
onError?: (error: unknown) => void
}) {
const pending = new Map<string, T>()
let timer: ReturnType<typeof setTimeout> | undefined
let closed = false
const flush = () => {
clearTimeout(timer)
timer = undefined
if (closed || pending.size === 0) return
const batch = new Map(pending)
pending.clear()
try {
input.write(batch)
} catch (error) {
// The renderer already saw these writes succeed. Keep them queued so the next flush retries
// them; anything written for the same key since then takes precedence.
for (const [key, value] of batch) if (!pending.has(key)) pending.set(key, value)
if (!input.onError) throw error
input.onError(error)
}
}
return {
get: (key: string) => pending.get(key),
has: (key: string) => pending.has(key),
entries: () => pending.values(),
set(key: string, value: T) {
if (closed) return
pending.set(key, value)
timer ??= setTimeout(flush, input.delay)
},
drop(predicate: (value: T) => boolean) {
for (const [key, value] of pending) if (predicate(value)) pending.delete(key)
},
flush,
close() {
flush()
closed = true
},
}
}
+6 -7
View File
@@ -5,8 +5,7 @@ import { Effect, FileSystem, Path } from "effect"
import { openExternalURL } from "../files"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { forgetStore, getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
@@ -78,7 +77,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const storage = yield* DesktopStorage.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const wireWindowRecovery = yield* makeWindowRecovery
@@ -143,12 +141,14 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => {
if (!registry.closed(id)) return
const data = windowDataFile(id)
runFork(
Effect.gen(function* () {
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
yield* fs.remove(path.join(app.getPath("userData"), data), { force: true })
}).pipe(
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
Effect.tap(() => Effect.sync(() => forgetStore(data))),
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window files", { id, error }))),
),
)
})
@@ -161,8 +161,7 @@ function windowStateFile(id: string) {
return `window-state-${safeWindowID(id)}.json`
}
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
// Mirrors windowStorage() in packages/app/src/utils/persist.ts.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
+5 -5
View File
@@ -32,12 +32,12 @@ export type ElectronAPI = {
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
checkAppExists(appName: string): Promise<boolean>
resolveAppPath(appName: string): Promise<string | null>
storeItems(name: string): Promise<{ items: Record<string, string>; revision: number }>
storeUpdate(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
storeGet(name: string, key: string): Promise<string | null>
storeSet(name: string, key: string, value: string): Promise<void>
storeDelete(name: string, key: string): Promise<void>
storeClear(name: string): Promise<void>
onStoreChanged(
cb: (name: string, insert: Record<string, string>, remove: string[], revision: number) => void,
): () => void
storeKeys(name: string): Promise<string[]>
storeLength(name: string): Promise<number>
draftGet(key: string): Promise<string | null>
draftSet(key: string, value: string): Promise<void>
draftDelete(key: string): Promise<void>
+5 -4
View File
@@ -75,11 +75,12 @@ export const api: ElectronAPI = {
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
storeGet: (name, key) => invoke("StorageGet", { name, key }),
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
storeClear: (name) => invoke("StorageClear", { name }),
onStoreChanged: (cb) =>
listen("StorageChanged", (event) => cb(event.name, mutable(event.insert), mutable(event.remove), event.revision)),
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
storeLength: (name) => invoke("StorageLength", { name }),
draftGet: (key) => invoke("DraftsGet", { key }),
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
draftDelete: (key) => invoke("DraftsDelete", { key }),
+1 -12
View File
@@ -28,18 +28,7 @@ const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.m
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(() => runtime.dispose()),
{ once: true },
)
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
window.addEventListener("pagehide", () => void runtime.dispose(), { once: true })
runtime.runFork(
Effect.gen(function* () {

Some files were not shown because too many files have changed in this diff Show More