Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions packages/server/src/settings/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { SettingsService } from "./service"

function serviceWithStore(store: Record<string, unknown>) {
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)
})
})
32 changes: 27 additions & 5 deletions packages/server/src/settings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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")
}
}
}
84 changes: 84 additions & 0 deletions packages/server/src/settings/yaml-doc-store.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
})
50 changes: 47 additions & 3 deletions packages/server/src/settings/yaml-doc-store.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -18,13 +19,33 @@ function normalizeDoc(input: unknown): SettingsDoc {
return input
}

function resolveWriteDestination(filePath: string): string {
let current = path.resolve(filePath)
const seen = new Set<string>()

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

constructor(
private readonly filePath: string,
private readonly logger: Logger,
private readonly options: { throwOnPersistError?: boolean } = {},
) {}

load(): SettingsDoc {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
}
}
}
}
47 changes: 47 additions & 0 deletions packages/server/src/sidecars/manager.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading