feat(config): persist config.patch updates atomically

This commit is contained in:
William Valentin
2026-02-15 22:03:21 -08:00
parent c314e0f067
commit 0220ec10dd
13 changed files with 205 additions and 11 deletions
+27 -2
View File
@@ -4,6 +4,7 @@ import type { Config } from '../../config/index.js';
export interface ConfigHandlerDeps {
config: Config;
persistConfig?: (nextConfig: Config) => Promise<void> | void;
}
/**
@@ -140,6 +141,7 @@ export function createConfigHandlers(deps: ConfigHandlerDeps) {
const applied: string[] = [];
const rejected: string[] = [];
const draft = JSON.parse(JSON.stringify(deps.config)) as Config;
for (const [key, value] of Object.entries(patches as Record<string, unknown>)) {
const patcher = PATCHABLE_KEYS[key];
@@ -147,7 +149,7 @@ export function createConfigHandlers(deps: ConfigHandlerDeps) {
rejected.push(key);
continue;
}
const ok = patcher(deps.config, value);
const ok = patcher(draft, value);
if (ok) {
applied.push(key);
} else {
@@ -155,7 +157,30 @@ export function createConfigHandlers(deps: ConfigHandlerDeps) {
}
}
return makeResponse(request.id, { applied, rejected });
if (applied.length === 0) {
return makeResponse(request.id, { applied, rejected, persisted: false });
}
if (deps.persistConfig) {
try {
await deps.persistConfig(draft);
} catch (err) {
return makeResponse(request.id, {
applied: [],
rejected,
persisted: false,
persistError: err instanceof Error ? err.message : String(err),
});
}
}
// Update in-memory runtime config only after a successful persist (or when persistence is not configured).
for (const key of Object.keys(deps.config)) {
delete (deps.config as Record<string, unknown>)[key];
}
Object.assign(deps.config as Record<string, unknown>, draft as Record<string, unknown>);
return makeResponse(request.id, { applied, rejected, persisted: Boolean(deps.persistConfig) });
},
};
}
+45 -3
View File
@@ -718,9 +718,10 @@ describe('config handlers', () => {
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[] };
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log']);
expect(r.rejected).toEqual([]);
expect(r.persisted).toBe(false);
// Verify the config was actually mutated
expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
expect(config.hooks.log).toEqual(['file.read']);
@@ -741,9 +742,10 @@ describe('config handlers', () => {
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[] };
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
expect(r.applied).toEqual(['hooks.confirm']);
expect(r.rejected).toEqual(['telegram.bot_token']);
expect(r.persisted).toBe(false);
});
it('config.patch rejects invalid value types', async () => {
@@ -760,9 +762,49 @@ describe('config handlers', () => {
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[] };
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
expect(r.applied).toEqual([]);
expect(r.rejected).toEqual(['hooks.confirm']);
expect(r.persisted).toBe(false);
});
it('config.patch persists changes when persistence callback is provided', async () => {
const config = makeConfig();
const persist = vi.fn();
const handlers = createConfigHandlers({ config: config as any, persistConfig: persist as any });
const req: GatewayRequest = {
id: 6,
method: 'config.patch',
params: { patches: { 'hooks.confirm': ['shell.exec', 'file.write'] } },
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
expect(r.applied).toEqual(['hooks.confirm']);
expect(r.rejected).toEqual([]);
expect(r.persisted).toBe(true);
expect(persist).toHaveBeenCalledTimes(1);
expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
});
it('config.patch does not mutate runtime config when persistence fails', async () => {
const config = makeConfig();
const before = [...config.hooks.confirm];
const persist = vi.fn().mockRejectedValue(new Error('disk full'));
const handlers = createConfigHandlers({ config: config as any, persistConfig: persist as any });
const req: GatewayRequest = {
id: 7,
method: 'config.patch',
params: { patches: { 'hooks.confirm': ['file.write'] } },
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean; persistError?: string };
expect(r.applied).toEqual([]);
expect(r.rejected).toEqual([]);
expect(r.persisted).toBe(false);
expect(r.persistError).toContain('disk full');
expect(config.hooks.confirm).toEqual(before);
});
it('config.patch requires patches object', async () => {
+6 -1
View File
@@ -59,6 +59,8 @@ export interface GatewayServerConfig {
authHttp?: boolean;
uiDir?: string;
config?: Config;
/** Optional persistence callback for config.patch updates. */
persistConfig?: (nextConfig: Config) => Promise<void> | void;
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
restart?: () => Promise<void>;
channelRegistry?: ChannelRegistry;
@@ -193,7 +195,10 @@ export class GatewayServer {
// Config handlers (only if config object is provided)
if (this.config.config) {
const configHandlers = createConfigHandlers({ config: this.config.config });
const configHandlers = createConfigHandlers({
config: this.config.config,
persistConfig: this.config.persistConfig,
});
for (const [method, handler] of Object.entries(configHandlers)) {
this.router.register(method, handler);
}
+8
View File
@@ -155,10 +155,18 @@ async function saveHooks() {
const applied = result.applied ?? [];
const rejected = result.rejected ?? [];
const persisted = result.persisted === true;
const persistError = result.persistError;
if (rejected.length > 0) {
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
status.className = 'text-sm text-error';
} else if (persistError) {
status.textContent = `Save failed: ${persistError}`;
status.className = 'text-sm text-error';
} else if (!persisted) {
status.textContent = `Saved in runtime only (${applied.length} updated)`;
status.className = 'text-sm text-muted';
} else {
status.textContent = `Saved (${applied.length} updated)`;
status.className = 'text-sm text-success';