Reload daemon configuration without restarting (#3365)

* feat(daemon): reload configuration without restarting

Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.

* fix(daemon): preserve reload resource ownership

Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
This commit is contained in:
Mohamed Boudra
2026-08-14 22:46:32 +02:00
committed by GitHub
parent 75bbca4a1b
commit 4748aad103
36 changed files with 2590 additions and 246 deletions
+12
View File
@@ -10,6 +10,18 @@ describe("canonical CLI surface", () => {
expect(help).not.toContain("worktree");
});
it("offers identical top-level and daemon config reload commands", () => {
const cli = createCli();
const reload = cli.commands.find((command) => command.name() === "reload");
const daemon = cli.commands.find((command) => command.name() === "daemon");
const nestedReload = daemon?.commands.find((command) => command.name() === "reload");
expect(reload?.helpInformation()).toContain("--host <host>");
expect(reload?.helpInformation()).toContain("--json");
expect(nestedReload?.helpInformation()).toContain("--host <host>");
expect(nestedReload?.helpInformation()).toContain("--json");
});
it("names explicit workspace creation without exposing older syntax", () => {
const run = createCli().commands.find((command) => command.name() === "run");
const help = run?.helpInformation();
+5
View File
@@ -16,6 +16,7 @@ import { createHooksCommand } from "./commands/hooks.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { runDaemonReloadCommand } from "./commands/daemon/reload.js";
import { addLsOptions, runLsCommand } from "./commands/agent/ls.js";
import { addRunOptions, runRunCommand } from "./commands/agent/run.js";
import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js";
@@ -125,6 +126,10 @@ export function createCli(): Command {
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runDaemonStatusCommand));
addJsonAndDaemonHostOptions(
program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(
program
.command("restart")
+6 -1
View File
@@ -5,8 +5,9 @@ import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js";
import { runSetPasswordCommand } from "./set-password.js";
import { pairCommand } from "./pair.js";
import { runDaemonReloadCommand } from "./reload.js";
import { withOutput } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
if (typeof hostnames === "string") return hostnames;
@@ -20,6 +21,10 @@ export function createDaemonCommand(): Command {
daemon.addCommand(startCommand());
daemon.addCommand(pairCommand());
addJsonAndDaemonHostOptions(
daemon.command("reload").description("Reload config.json without restarting the daemon"),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(daemon.command("status").description("Show local daemon status"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runStatusCommand));
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { render } from "../../output/render.js";
import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js";
function result(data: DaemonReloadResult) {
return { type: "single" as const, data, schema: daemonReloadSchema };
}
describe("daemon reload output", () => {
test("human output reports restart and override warnings", () => {
expect(
render(
result({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
}),
),
).toBe(
[
"Configuration reloaded.",
"",
"Warning: These changes require a daemon restart:",
" daemon.listen",
"",
"Run: paseo daemon restart",
"",
"Warning: These settings are controlled by daemon launch overrides:",
" app.baseUrl",
].join("\n"),
);
});
test("human output omits restart guidance for a live-only reload", () => {
const output = render(
result({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
}),
);
expect(output).toBe("Configuration reloaded.");
expect(output).not.toContain("restart");
});
test("structured output preserves the daemon result", () => {
const data = {
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
};
expect(render(result(data), { format: "json" })).toBe(JSON.stringify(data, null, 2));
expect(render(result(data), { format: "yaml" })).toContain(
"appliedPaths:\n - daemon.browserTools.enabled",
);
});
});
@@ -0,0 +1,56 @@
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
export interface DaemonReloadResult {
appliedPaths: string[];
restartRequiredPaths: string[];
overrideControlledPaths: string[];
}
export const daemonReloadSchema: OutputSchema<DaemonReloadResult> = {
idField: () => "daemon-config",
columns: [],
renderHuman(result) {
if (result.type !== "single") return "";
const lines = ["Configuration reloaded."];
if (result.data.restartRequiredPaths.length > 0) {
lines.push(
"",
"Warning: These changes require a daemon restart:",
...result.data.restartRequiredPaths.map((path) => ` ${path}`),
"",
"Run: paseo daemon restart",
);
}
if (result.data.overrideControlledPaths.length > 0) {
lines.push(
"",
"Warning: These settings are controlled by daemon launch overrides:",
...result.data.overrideControlledPaths.map((path) => ` ${path}`),
);
}
return lines.join("\n");
},
};
export async function runDaemonReloadCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<DaemonReloadResult>> {
const client = await connectToDaemon({ host: options.host });
try {
const payload = await client.reloadDaemonConfig();
return {
type: "single",
data: {
appliedPaths: payload.appliedPaths,
restartRequiredPaths: payload.restartRequiredPaths,
overrideControlledPaths: payload.overrideControlledPaths,
},
schema: daemonReloadSchema,
};
} finally {
await client.close();
}
}