diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 05318b3a1..49f977515 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -110,6 +110,7 @@ jobs: packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts diff --git a/packages/server/src/settings/service.test.ts b/packages/server/src/settings/service.test.ts new file mode 100644 index 000000000..505a5cca9 --- /dev/null +++ b/packages/server/src/settings/service.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { SettingsService } from "./service" + +function serviceWithStore(store: Record) { + const service = Object.create(SettingsService.prototype) as SettingsService + Object.assign(service as any, { configStore: store, eventBus: undefined }) + return service +} + +describe("SettingsService config persistence", () => { + it("normalizes and persists a document patch once", () => { + let writes = 0 + const service = serviceWithStore({ + get: () => ({ server: { logLevel: "info" } }), + replace: (value: unknown) => { + writes += 1 + return value + }, + mergePatch: () => assert.fail("must not persist an intermediate document"), + }) + + const result = service.mergePatchDoc("config", { ui: { theme: "dark" } }) + assert.equal(writes, 1) + assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } }) + }) + + it("normalizes and persists a server-owner patch once", () => { + let writes = 0 + const service = serviceWithStore({ + getOwner: () => ({ logLevel: "info", sidecars: [] }), + replaceOwner: (_owner: string, value: unknown) => { + writes += 1 + return value + }, + mergePatchOwner: () => assert.fail("must not persist an intermediate owner"), + }) + + const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] }) + assert.equal(writes, 1) + assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] }) + }) + + it("does not report a persisted patch as failed when an event listener throws", () => { + let warnings = 0 + const service = serviceWithStore({ + getOwner: () => ({}), + mergePatchOwner: (_owner: string, patch: unknown) => patch, + }) + Object.assign(service as any, { + eventBus: { publish: () => { throw new Error("listener failed") } }, + logger: { warn: () => { warnings += 1 } }, + }) + + assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" }) + assert.equal(warnings, 1) + }) +}) diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index f4f0409c2..7bb10e721 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { migrateSettingsLayout } from "./migrate" import type { WorkspaceEventPayload } from "../api-types" import { sanitizeConfigOwner } from "./public-config" +import { applyMergePatch } from "./merge-patch" export type DocKind = "config" | "state" @@ -67,8 +68,16 @@ export class SettingsService { private readonly logger: Logger, ) { migrateSettingsLayout(location, logger) - this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" })) - this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" })) + this.configStore = new YamlDocStore( + location.configYamlPath, + logger.child({ component: "settings-config" }), + { throwOnPersistError: true }, + ) + this.stateStore = new YamlDocStore( + location.stateYamlPath, + logger.child({ component: "settings-state" }), + { throwOnPersistError: true }, + ) } getDoc(kind: DocKind): SettingsDoc { @@ -85,9 +94,12 @@ export class SettingsService { } mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } const updated = kind === "config" - ? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch))) + ? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc)) : this.stateStore.mergePatch(patch) this.publish(kind, "*") return updated @@ -104,10 +116,16 @@ export class SettingsService { } mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { + if (!isPlainObject(patch)) { + throw new Error("Patch must be a JSON object") + } const updated = kind === "config" ? owner === "server" - ? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch))) + ? this.configStore.replaceOwner( + owner, + normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc), + ) : this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) this.publish(kind, owner, updated) @@ -123,6 +141,10 @@ export class SettingsService { owner, value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue, } as any - this.eventBus.publish(payload) + try { + this.eventBus.publish(payload) + } catch (error) { + this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change") + } } } diff --git a/packages/server/src/settings/yaml-doc-store.test.ts b/packages/server/src/settings/yaml-doc-store.test.ts new file mode 100644 index 000000000..1dc98edcf --- /dev/null +++ b/packages/server/src/settings/yaml-doc-store.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import { YamlDocStore } from "./yaml-doc-store" + +describe("YamlDocStore", () => { + it("reports persistence failures without replacing the cached document", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const parent = path.join(root, "settings") + const file = path.join(parent, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true }) + + try { + store.replace({ version: 1 }) + fs.rmSync(parent, { recursive: true }) + fs.writeFileSync(parent, "blocks directory creation") + + assert.throws(() => store.replace({ version: 2 })) + assert.deepEqual(store.get(), { version: 1 }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("keeps the live document intact when atomic replacement fails", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const file = path.join(root, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true }) + const renameSync = fs.renameSync + + try { + store.replace({ version: 1 }) + ;(fs as any).renameSync = () => { throw new Error("replacement failed") } + + assert.throws(() => store.replace({ version: 2 })) + assert.match(fs.readFileSync(file, "utf8"), /version: 1/) + assert.deepEqual(store.get(), { version: 1 }) + + ;(fs as any).renameSync = renameSync + store.replace({ version: 2 }) + assert.match(fs.readFileSync(file, "utf8"), /version: 2/) + } finally { + ;(fs as any).renameSync = renameSync + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("preserves private file permissions", { skip: process.platform === "win32" }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const file = path.join(root, "config.yaml") + const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true }) + + try { + store.replace({ version: 1 }) + fs.chmodSync(file, 0o664) + store.replace({ version: 2 }) + assert.equal(fs.statSync(file).mode & 0o777, 0o664) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-")) + const target = path.join(root, "target.yaml") + const intermediate = path.join(root, "current.yaml") + const link = path.join(root, "config.yaml") + fs.writeFileSync(target, "version: 1\n") + fs.symlinkSync(target, intermediate) + fs.symlinkSync(intermediate, link) + const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true }) + + try { + store.replace({ version: 2 }) + assert.equal(fs.lstatSync(link).isSymbolicLink(), true) + assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true) + assert.match(fs.readFileSync(target, "utf8"), /version: 2/) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/settings/yaml-doc-store.ts b/packages/server/src/settings/yaml-doc-store.ts index 91c5540c8..95090c10b 100644 --- a/packages/server/src/settings/yaml-doc-store.ts +++ b/packages/server/src/settings/yaml-doc-store.ts @@ -1,5 +1,6 @@ import fs from "fs" import path from "path" +import { randomUUID } from "node:crypto" import { parse as parseYaml, stringify as stringifyYaml } from "yaml" import type { Logger } from "../logger" import { applyMergePatch, isPlainObject } from "./merge-patch" @@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc { return input } +function resolveWriteDestination(filePath: string): string { + let current = path.resolve(filePath) + const seen = new Set() + + while (true) { + let stat: fs.Stats + try { + stat = fs.lstatSync(current) + } catch (error: any) { + if (error?.code === "ENOENT") return current + throw error + } + if (!stat.isSymbolicLink()) return current + if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`) + seen.add(current) + current = path.resolve(path.dirname(current), fs.readlinkSync(current)) + } +} + export class YamlDocStore { private cache: SettingsDoc = {} private loaded = false @@ -25,6 +45,7 @@ export class YamlDocStore { constructor( private readonly filePath: string, private readonly logger: Logger, + private readonly options: { throwOnPersistError?: boolean } = {}, ) {} load(): SettingsDoc { @@ -58,9 +79,17 @@ export class YamlDocStore { replace(next: unknown): SettingsDoc { const normalized = normalizeDoc(next) + const previousCache = this.cache + const previousLoaded = this.loaded this.cache = normalized this.loaded = true - this.persist() + try { + this.persist() + } catch (error) { + this.cache = previousCache + this.loaded = previousLoaded + throw error + } return this.cache } @@ -99,12 +128,27 @@ export class YamlDocStore { } private persist() { + let tempPath: string | undefined try { - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + const destination = resolveWriteDestination(this.filePath) + fs.mkdirSync(path.dirname(destination), { recursive: true }) const yaml = stringifyYaml(this.cache as any) - fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8") + const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600 + tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp` + fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode }) + if (process.platform !== "win32") fs.chmodSync(tempPath, mode) + fs.renameSync(tempPath, destination) } catch (error) { this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc") + if (this.options.throwOnPersistError) throw error + } finally { + if (tempPath) { + try { + fs.rmSync(tempPath, { force: true }) + } catch (error) { + this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc") + } + } } } } diff --git a/packages/server/src/sidecars/manager.test.ts b/packages/server/src/sidecars/manager.test.ts new file mode 100644 index 000000000..65eb1d12f --- /dev/null +++ b/packages/server/src/sidecars/manager.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { SideCarManager } from "./manager" + +const existing = { + id: "existing", + kind: "port", + name: "Existing", + port: 65534, + insecure: true, + prefixMode: "strip", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +} + +function manager(sidecars: unknown[] = []) { + return new SideCarManager({ + settings: { + getOwner: () => ({ sidecars }), + mergePatchOwner: () => { throw new Error("disk full") }, + } as any, + eventBus: { publish() {} } as any, + logger: { warn() {} } as any, + }) +} + +describe("SideCarManager persistence rollback", () => { + it("restores create, update, and delete state when persistence fails", async () => { + const createManager = manager() + await assert.rejects(createManager.create({ + kind: "port", + name: "New", + port: 65533, + insecure: true, + prefixMode: "strip", + })) + assert.deepEqual(await createManager.list(), []) + + const updateManager = manager([existing]) + await assert.rejects(updateManager.update(existing.id, { name: "Changed" })) + assert.equal((await updateManager.get(existing.id))?.name, existing.name) + + const deleteManager = manager([existing]) + await assert.rejects(deleteManager.delete(existing.id)) + assert.equal((await deleteManager.get(existing.id))?.id, existing.id) + }) +}) diff --git a/packages/server/src/sidecars/manager.ts b/packages/server/src/sidecars/manager.ts index 57593c30a..5fce06776 100644 --- a/packages/server/src/sidecars/manager.ts +++ b/packages/server/src/sidecars/manager.ts @@ -82,7 +82,13 @@ export class SideCarManager { this.configs.set(record.id, record) this.runtime.set(record.id, { status: "stopped" }) - this.persistConfigs() + try { + this.persistConfigs() + } catch (error) { + this.configs.delete(record.id) + this.runtime.delete(record.id) + throw error + } await this.refreshPortSideCar(record.id) return this.toSideCar(record) } @@ -98,13 +104,19 @@ export class SideCarManager { ): Promise { const record = this.requireConfig(id) + const previous = { ...record } record.name = typeof input.name === "string" ? input.name.trim() : record.name record.port = typeof input.port === "number" ? input.port : record.port record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode record.updatedAt = new Date().toISOString() - this.persistConfigs() + try { + this.persistConfigs() + } catch (error) { + this.configs.set(id, previous) + throw error + } await this.refreshPortSideCar(id) return this.toSideCar(record) } @@ -113,9 +125,16 @@ export class SideCarManager { const record = this.configs.get(id) if (!record) return false + const runtime = this.runtime.get(id) this.configs.delete(id) this.runtime.delete(id) - this.persistConfigs() + try { + this.persistConfigs() + } catch (error) { + this.configs.set(id, record) + if (runtime) this.runtime.set(id, runtime) + throw error + } this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id }) return true } diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index fba3680db..0ae193718 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -5,8 +5,9 @@ import { ChevronDown, PlugZap, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" -import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" +import { getProviderModelVisibilityPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences" import { ProviderManagerModal } from "./provider-auth/provider-manager-modal" +import { isModelVisible, resolvePickerValue } from "../lib/model-visibility" const log = getLogger("session") interface ModelSelectorProps { @@ -20,6 +21,7 @@ interface FlatModel extends Model { providerName: string key: string searchText: string + unavailable?: boolean } interface ModelGroup { @@ -67,7 +69,7 @@ export default function ModelSelector(props: ModelSelectorProps) { } }) - const allModels = createMemo(() => + const catalogModels = createMemo(() => instanceProviders().flatMap((p) => p.models.map((m) => ({ ...m, @@ -78,6 +80,13 @@ export default function ModelSelector(props: ModelSelectorProps) { ), ) + const allModels = createMemo(() => catalogModels().filter((model) => + isModelVisible( + getProviderModelVisibilityPreference(model.providerId), + model.id, + ), + )) + const sortedModels = createMemo(() => [...allModels()].sort((left, right) => { const providerComparison = compareIds(left.providerId, right.providerId) @@ -106,9 +115,22 @@ export default function ModelSelector(props: ModelSelectorProps) { const hasFavorites = createMemo(() => favoriteModels().length > 0) - const currentModelValue = createMemo(() => - allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId), - ) + const currentModelValue = createMemo(() => { + const current = props.currentModel + const found = catalogModels().find((model) => model.providerId === current.providerId && model.id === current.modelId) + if (found) return found + if (!current.providerId || !current.modelId) return undefined + const providerName = instanceProviders().find((provider) => provider.id === current.providerId)?.name ?? current.providerId + return { + id: current.modelId, + name: t("modelSelector.unavailableModel", { model: current.modelId }), + providerId: current.providerId, + providerName, + key: `${current.providerId}/${current.modelId}`, + searchText: `${current.modelId} ${providerName} ${current.providerId}`, + unavailable: true, + } + }) const currentModelIsFavorite = createMemo(() => { const current = props.currentModel @@ -134,10 +156,9 @@ export default function ModelSelector(props: ModelSelectorProps) { }) const visibleOptions = createMemo(() => { - if (!favoritesOnlyEnabled()) { - return sortedModels() - } - return favoriteModels() + const visible = favoritesOnlyEnabled() ? favoriteModels() : sortedModels() + const current = currentModelValue() + return current?.unavailable ? [current, ...visible] : visible }) const groupedVisibleOptions = createMemo(() => { @@ -169,8 +190,27 @@ export default function ModelSelector(props: ModelSelectorProps) { ]), ) + const comboboxValue = createMemo(() => { + const options = pickerOptions().filter((option): option is FlatModel => !isProviderHeaderOption(option)) + return resolvePickerValue(currentModelValue(), options) + }) + + const currentModelLabel = createMemo(() => + t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") }), + ) + + const currentModelAccessibleLabel = createMemo(() => { + const current = currentModelValue() + if (!current) return currentModelLabel() + return t("modelSelector.trigger.ariaLabel", { + model: current.name, + provider: current.providerName, + id: `${current.providerId}/${current.id}`, + }) + }) + const handleChange = async (value: PickerOption | null) => { - if (!value || isProviderHeaderOption(value)) return + if (!value || isProviderHeaderOption(value) || value.unavailable) return await props.onModelChange({ providerId: value.providerId, modelId: value.id }) } @@ -268,7 +308,7 @@ export default function ModelSelector(props: ModelSelectorProps) {