mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-27 01:02:01 +00:00
fix(desktop): persist OpenCode selection before shutdown
Normalize the legacy opencode system command to opencode2 in both server persistence and UI settings so the selected PATH runtime remains visible and authoritative after restart. Track config, state, and instance-data writes in the shared UI storage adapter and wait for them during native shutdown before acknowledging Tauri's flush request. Remove duplicate binary-selection writes from the selector so its parent remains the single persistence owner. Validated with UI and server typechecks, targeted storage, preference, shutdown-capture, settings, and binary resolver tests, plus a Tauri release build.
This commit is contained in:
parent
df5adf7405
commit
7e53fc0ee3
9 changed files with 86 additions and 24 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
serverSettings,
|
||||
updateLastUsedBinary,
|
||||
} = useConfig()
|
||||
const [customPath, setCustomPath] = createSignal("")
|
||||
const [validating, setValidating] = createSignal(false)
|
||||
|
|
@ -157,7 +156,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||
if (validation.valid) {
|
||||
addOpenCodeBinary(path, validation.version)
|
||||
props.onBinaryChange(path)
|
||||
updateLastUsedBinary(path)
|
||||
setCustomPath("")
|
||||
setValidationError(null)
|
||||
} else {
|
||||
|
|
@ -182,7 +180,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (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<OpenCodeBinarySelectorProps> = (props) =
|
|||
|
||||
if (props.selectedBinary === path) {
|
||||
props.onBinaryChange("opencode2")
|
||||
updateLastUsedBinary("opencode2")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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\(\)\] : \[\]/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
packages/ui/src/lib/storage.test.ts
Normal file
29
packages/ui/src/lib/storage.test.ts
Normal file
|
|
@ -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<string, unknown>) => void
|
||||
serverApi.patchConfigOwner = <T extends Record<string, unknown>>() => new Promise<T>((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" })
|
||||
})
|
||||
|
|
@ -39,6 +39,7 @@ export class ServerStorage {
|
|||
private instanceDataCache = new Map<string, InstanceData>()
|
||||
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
||||
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
||||
private pendingWrites = new Set<Promise<unknown>>()
|
||||
|
||||
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<OwnerBucket> {
|
||||
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
|
||||
this.setOwnerCache("config", owner, updated)
|
||||
return updated
|
||||
patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||
return this.trackWrite(serverApi.patchConfigOwner<OwnerBucket>(owner, patch).then((updated) => {
|
||||
this.setOwnerCache("config", owner, updated)
|
||||
return updated
|
||||
}))
|
||||
}
|
||||
|
||||
async loadStateOwner(owner: string): Promise<OwnerBucket> {
|
||||
|
|
@ -103,10 +105,11 @@ export class ServerStorage {
|
|||
return this.stateOwnerLoadPromises.get(owner)!
|
||||
}
|
||||
|
||||
async patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||
const updated = await serverApi.patchStateOwner<OwnerBucket>(owner, patch)
|
||||
this.setOwnerCache("state", owner, updated)
|
||||
return updated
|
||||
patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||
return this.trackWrite(serverApi.patchStateOwner<OwnerBucket>(owner, patch).then((updated) => {
|
||||
this.setOwnerCache("state", owner, updated)
|
||||
return updated
|
||||
}))
|
||||
}
|
||||
|
||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||
|
|
@ -133,15 +136,23 @@ export class ServerStorage {
|
|||
return this.instanceLoadPromises.get(instanceId)!
|
||||
}
|
||||
|
||||
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||
saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||
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<void> {
|
||||
await serverApi.deleteInstanceData(instanceId)
|
||||
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
deleteInstanceData(instanceId: string): Promise<void> {
|
||||
return this.trackWrite(serverApi.deleteInstanceData(instanceId).then(() => {
|
||||
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
}))
|
||||
}
|
||||
|
||||
async flushWrites(): Promise<void> {
|
||||
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<T>(write: Promise<T>): Promise<T> {
|
||||
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] : []
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeServerConfig(
|
||||
export function normalizeServerConfig(
|
||||
input?: ServerConfigBucket | null,
|
||||
): Required<Pick<ServerConfigBucket, "listeningMode" | "logLevel" | "environmentVariables" | "opencodeBinary" | "secureEnvVars">> & { 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue