feat(settings): move config/state to owner buckets

Add generic /api/storage config/state endpoints with merge-patch, migrate legacy YAML/JSON layout, and update UI/server to read and write owner-scoped settings. Replace config SSE events and drop /api/config routes.
This commit is contained in:
Shantur Rathore
2026-02-13 14:34:33 +00:00
parent 0c0f397db0
commit e30ff6358d
29 changed files with 1252 additions and 1051 deletions

View File

@@ -1,11 +1,7 @@
import type {
AppConfig,
BackgroundProcess,
BackgroundProcessListResponse,
BackgroundProcessOutputResponse,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
FileSystemCreateFolderResponse,
@@ -214,37 +210,27 @@ export const serverApi = {
)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
},
updateConfig(payload: AppConfig): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PUT",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries")
},
createBinary(payload: BinaryCreateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateBinary(id: string, updates: BinaryUpdateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
patchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`, {
method: "PATCH",
body: JSON.stringify(updates),
body: JSON.stringify(patch ?? {}),
})
},
fetchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`)
},
patchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`, {
method: "PATCH",
body: JSON.stringify(patch ?? {}),
})
},
deleteBinary(id: string): Promise<void> {
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
},
validateBinary(path: string): Promise<BinaryValidationResult> {
return request<BinaryValidationResult>("/api/config/binaries/validate", {
return request<BinaryValidationResult>("/api/storage/binaries/validate", {
method: "POST",
body: JSON.stringify({ path }),
})

View File

@@ -1,11 +1,11 @@
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types"
import { serverApi } from "./api-client"
import { serverEvents } from "./server-events"
import { getLogger } from "./logger"
const log = getLogger("actions")
export type ConfigData = AppConfig
export type OwnerBucket = Record<string, any>
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
@@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
}
export class ServerStorage {
private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
private configCache: ConfigData | null = null
private loadPromise: Promise<ConfigData> | null = null
private configOwnerCache = new Map<string, OwnerBucket>()
private stateOwnerCache = new Map<string, OwnerBucket>()
private configOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
private stateOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
private configOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
private stateOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
private instanceDataCache = new Map<string, InstanceData>()
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
constructor() {
serverEvents.on("config.appChanged", (event) => {
if (event.type !== "config.appChanged") return
this.setConfigCache(event.config)
serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => {
if (event.type !== "storage.configChanged") return
this.setOwnerCache("config", event.owner, event.value)
})
serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => {
if (event.type !== "storage.stateChanged") return
this.setOwnerCache("state", event.owner, event.value)
})
serverEvents.on("instance.dataChanged", (event) => {
@@ -49,30 +57,56 @@ export class ServerStorage {
})
}
async loadConfig(): Promise<ConfigData> {
if (this.configCache) {
return this.configCache
}
async loadConfigOwner(owner: string): Promise<OwnerBucket> {
const cached = this.configOwnerCache.get(owner)
if (cached) return cached
if (!this.loadPromise) {
this.loadPromise = serverApi
.fetchConfig()
.then((config) => {
this.setConfigCache(config)
return config
if (!this.configOwnerLoadPromises.has(owner)) {
const promise = serverApi
.fetchConfigOwner<OwnerBucket>(owner)
.then((value) => {
this.setOwnerCache("config", owner, value)
return value
})
.finally(() => {
this.loadPromise = null
this.configOwnerLoadPromises.delete(owner)
})
this.configOwnerLoadPromises.set(owner, promise)
}
return this.loadPromise
return this.configOwnerLoadPromises.get(owner)!
}
async updateConfig(next: ConfigData): Promise<ConfigData> {
const nextConfig = await serverApi.updateConfig(next)
this.setConfigCache(nextConfig)
return nextConfig
async patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
this.setOwnerCache("config", owner, updated)
return updated
}
async loadStateOwner(owner: string): Promise<OwnerBucket> {
const cached = this.stateOwnerCache.get(owner)
if (cached) return cached
if (!this.stateOwnerLoadPromises.has(owner)) {
const promise = serverApi
.fetchStateOwner<OwnerBucket>(owner)
.then((value) => {
this.setOwnerCache("state", owner, value)
return value
})
.finally(() => {
this.stateOwnerLoadPromises.delete(owner)
})
this.stateOwnerLoadPromises.set(owner, promise)
}
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
}
async loadInstanceData(instanceId: string): Promise<InstanceData> {
@@ -110,12 +144,40 @@ export class ServerStorage {
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
}
onConfigChanged(listener: (config: ConfigData) => void): () => void {
this.configChangeListeners.add(listener)
if (this.configCache) {
listener(this.configCache)
onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
if (!this.configOwnerListeners.has(owner)) {
this.configOwnerListeners.set(owner, new Set())
}
const bucket = this.configOwnerListeners.get(owner)!
bucket.add(listener)
const cached = this.configOwnerCache.get(owner)
if (cached) {
listener(cached)
}
return () => {
bucket.delete(listener)
if (bucket.size === 0) {
this.configOwnerListeners.delete(owner)
}
}
}
onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
if (!this.stateOwnerListeners.has(owner)) {
this.stateOwnerListeners.set(owner, new Set())
}
const bucket = this.stateOwnerListeners.get(owner)!
bucket.add(listener)
const cached = this.stateOwnerCache.get(owner)
if (cached) {
listener(cached)
}
return () => {
bucket.delete(listener)
if (bucket.size === 0) {
this.stateOwnerListeners.delete(owner)
}
}
return () => this.configChangeListeners.delete(listener)
}
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
@@ -136,18 +198,30 @@ export class ServerStorage {
}
}
private setConfigCache(config: ConfigData) {
if (this.configCache && isDeepEqual(this.configCache, config)) {
this.configCache = config
private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) {
if (owner === "*") {
// Full-doc updates are not tracked owner-by-owner; invalidate caches.
if (kind === "config") {
this.configOwnerCache.clear()
} else {
this.stateOwnerCache.clear()
}
return
}
this.configCache = config
this.notifyConfigChanged(config)
}
private notifyConfigChanged(config: ConfigData) {
for (const listener of this.configChangeListeners) {
listener(config)
const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache
const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners
const previous = cache.get(owner)
if (previous && isDeepEqual(previous, value)) {
cache.set(owner, value)
return
}
cache.set(owner, value)
const bucket = listeners.get(owner)
if (!bucket) return
for (const listener of bucket) {
listener(value)
}
}