diff --git a/packages/server/src/settings/service.test.ts b/packages/server/src/settings/service.test.ts index 505a5cca..d169c090 100644 --- a/packages/server/src/settings/service.test.ts +++ b/packages/server/src/settings/service.test.ts @@ -28,7 +28,7 @@ describe("SettingsService config persistence", () => { it("normalizes and persists a server-owner patch once", () => { let writes = 0 const service = serviceWithStore({ - getOwner: () => ({ logLevel: "info", sidecars: [] }), + getOwner: () => ({ logLevel: "info", opencodeBinary: "opencode", sidecars: [] }), replaceOwner: (_owner: string, value: unknown) => { writes += 1 return value @@ -38,7 +38,7 @@ describe("SettingsService config persistence", () => { const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] }) assert.equal(writes, 1) - assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] }) + assert.deepEqual(result, { logLevel: "INFO", opencodeBinary: "opencode2", sidecars: [{ id: "one" }] }) }) it("does not report a persisted patch as failed when an event listener throws", () => { diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index 7bb10e72..4e52dd44 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -40,6 +40,9 @@ function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { } else if (next.logLevel !== undefined) { next.logLevel = "DEBUG" } + if (next.opencodeBinary === "opencode") { + next.opencodeBinary = "opencode2" + } return next } diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index e5455016..92e3581d 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -30,7 +30,6 @@ const OpenCodeBinarySelector: Component = (props) = addOpenCodeBinary, removeOpenCodeBinary, serverSettings, - updateLastUsedBinary, } = useConfig() const [customPath, setCustomPath] = createSignal("") const [validating, setValidating] = createSignal(false) @@ -157,7 +156,6 @@ const OpenCodeBinarySelector: Component = (props) = if (validation.valid) { addOpenCodeBinary(path, validation.version) props.onBinaryChange(path) - updateLastUsedBinary(path) setCustomPath("") setValidationError(null) } else { @@ -182,7 +180,6 @@ const OpenCodeBinarySelector: Component = (props) = if (props.disabled) return if (path === props.selectedBinary) return props.onBinaryChange(path) - updateLastUsedBinary(path) } function handleRemoveBinary(path: string, event: Event) { @@ -192,7 +189,6 @@ const OpenCodeBinarySelector: Component = (props) = if (props.selectedBinary === path) { props.onBinaryChange("opencode2") - updateLastUsedBinary("opencode2") } } diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts index eaafcb59..7d4e9488 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -24,3 +24,7 @@ it("resumes capture only after native shutdown cancellation", () => { assert.match(capture, /listen<\{ generation: number \}>\("client-state:flush-cancelled"/) assert.match(capture, /if \(nativeShutdownGeneration !== payload\.generation\) return[\s\S]*nativeShutdownGeneration = null[\s\S]*schedule\(\)/) }) + +it("waits for server storage writes during native shutdown", () => { + assert.match(capture, /nativeShutdown \? \[storage\.flushWrites\(\)\] : \[\]/) +}) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index 7c7e5bd4..5c24d16d 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -7,6 +7,7 @@ import { clientStateIsPrimary, flushClientState, restorePreviousStateEnabled, updateRestorableSession, type RestorableSessionState, type RestorableTabState, type RestorableWorkspaceTabState, } from "../../stores/client-state" +import { storage } from "../storage" import { normalizeWorkspacePath } from "../../stores/app-session-reconciliation" import { createRestorableSessionPreservation, createRestoredTabCommitGuard, markPreservedWorkspaceRemoved, @@ -178,7 +179,10 @@ export function useAppSessionCapture() { : current updateRestorableSession(state) } - await flushClientState() + await Promise.all([ + flushClientState(), + ...(nativeShutdown ? [storage.flushWrites()] : []), + ]) } const nativeUnlisteners: Array<() => void> = [] let nativeDisposed = false diff --git a/packages/ui/src/lib/storage.test.ts b/packages/ui/src/lib/storage.test.ts new file mode 100644 index 00000000..6ec387ee --- /dev/null +++ b/packages/ui/src/lib/storage.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { afterEach, it } from "node:test" +import { serverApi } from "./api-client" +import { ServerStorage } from "./storage" + +const originalPatchConfigOwner = serverApi.patchConfigOwner + +afterEach(() => { + serverApi.patchConfigOwner = originalPatchConfigOwner +}) + +it("flushes an in-flight server write", async () => { + let resolveWrite!: (value: Record) => void + serverApi.patchConfigOwner = >() => new Promise((resolve) => { + resolveWrite = (value) => resolve(value as T) + }) + const storage = new ServerStorage() + const write = storage.patchConfigOwner("server", { opencodeBinary: "opencode2" }) + let flushed = false + const flush = storage.flushWrites().then(() => { + flushed = true + }) + + await Promise.resolve() + assert.equal(flushed, false) + resolveWrite({ opencodeBinary: "opencode2" }) + await flush + assert.deepEqual(await write, { opencodeBinary: "opencode2" }) +}) diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index 4616bc94..ec4de3b8 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -39,6 +39,7 @@ export class ServerStorage { private instanceDataCache = new Map() private instanceDataListeners = new Map void>>() private instanceLoadPromises = new Map>() + private pendingWrites = new Set>() constructor() { serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => { @@ -77,10 +78,11 @@ export class ServerStorage { return this.configOwnerLoadPromises.get(owner)! } - async patchConfigOwner(owner: string, patch: unknown): Promise { - const updated = await serverApi.patchConfigOwner(owner, patch) - this.setOwnerCache("config", owner, updated) - return updated + patchConfigOwner(owner: string, patch: unknown): Promise { + return this.trackWrite(serverApi.patchConfigOwner(owner, patch).then((updated) => { + this.setOwnerCache("config", owner, updated) + return updated + })) } async loadStateOwner(owner: string): Promise { @@ -103,10 +105,11 @@ export class ServerStorage { return this.stateOwnerLoadPromises.get(owner)! } - async patchStateOwner(owner: string, patch: unknown): Promise { - const updated = await serverApi.patchStateOwner(owner, patch) - this.setOwnerCache("state", owner, updated) - return updated + patchStateOwner(owner: string, patch: unknown): Promise { + return this.trackWrite(serverApi.patchStateOwner(owner, patch).then((updated) => { + this.setOwnerCache("state", owner, updated) + return updated + })) } async loadInstanceData(instanceId: string): Promise { @@ -133,15 +136,23 @@ export class ServerStorage { return this.instanceLoadPromises.get(instanceId)! } - async saveInstanceData(instanceId: string, data: InstanceData): Promise { + saveInstanceData(instanceId: string, data: InstanceData): Promise { const normalized = this.normalizeInstanceData(data) - await serverApi.writeInstanceData(instanceId, normalized) - this.setInstanceDataCache(instanceId, normalized) + return this.trackWrite(serverApi.writeInstanceData(instanceId, normalized).then(() => { + this.setInstanceDataCache(instanceId, normalized) + })) } - async deleteInstanceData(instanceId: string): Promise { - await serverApi.deleteInstanceData(instanceId) - this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) + deleteInstanceData(instanceId: string): Promise { + return this.trackWrite(serverApi.deleteInstanceData(instanceId).then(() => { + this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) + })) + } + + async flushWrites(): Promise { + while (this.pendingWrites.size > 0) { + await Promise.allSettled(this.pendingWrites) + } } onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { @@ -225,6 +236,12 @@ export class ServerStorage { } } + private trackWrite(write: Promise): Promise { + this.pendingWrites.add(write) + void write.finally(() => this.pendingWrites.delete(write)).catch(() => undefined) + return write + } + private normalizeInstanceData(data?: InstanceData | null): InstanceData { const source = data ?? DEFAULT_INSTANCE_DATA const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : [] diff --git a/packages/ui/src/stores/preferences.test.ts b/packages/ui/src/stores/preferences.test.ts index 64dfddb8..5fc7aefa 100644 --- a/packages/ui/src/stores/preferences.test.ts +++ b/packages/ui/src/stores/preferences.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { buildSpeechPatch } from "../lib/speech-patch" -import { buildBinaryList, type SpeechSettingsUpdate } from "./preferences" +import { buildBinaryList, normalizeServerConfig, type SpeechSettingsUpdate } from "./preferences" describe("buildBinaryList", () => { it("does not add built-in commands to custom binary history", () => { @@ -13,6 +13,13 @@ describe("buildBinaryList", () => { }) }) +describe("normalizeServerConfig", () => { + it("maps the legacy system command to opencode2", () => { + assert.equal(normalizeServerConfig({ opencodeBinary: "opencode" }).opencodeBinary, "opencode2") + assert.equal(normalizeServerConfig({ opencodeBinary: "C:/tools/opencode2.exe" }).opencodeBinary, "C:/tools/opencode2.exe") + }) +}) + describe("buildSpeechPatch", () => { it("only includes fields that are explicitly provided", () => { const patch = buildSpeechPatch({ ttsVoice: "alloy" }) diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 94222522..ebef51de 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -454,7 +454,7 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { } } -function normalizeServerConfig( +export function normalizeServerConfig( input?: ServerConfigBucket | null, ): Required> & { speech: SpeechSettings } { const source = input ?? {} @@ -463,7 +463,9 @@ function normalizeServerConfig( source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" ? source.logLevel : "DEBUG" - const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode2" + const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() && source.opencodeBinary !== "opencode" + ? source.opencodeBinary + : "opencode2" const environmentVariables = normalizeRecord(source.environmentVariables) const secureEnvVars = normalizeSecureEnvVars(source.secureEnvVars) const speech = normalizeSpeechSettings(source.speech)