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();
}
}
+39
View File
@@ -23,6 +23,7 @@ import { createRequire } from "node:module";
import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { dirname, join } from "path";
import YAML from "yaml";
import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log("=== Daemon Commands ===\n");
@@ -276,6 +277,44 @@ try {
assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state");
assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer");
const reloadConfig = JSON.parse(await readFile(configPath, "utf-8"));
reloadConfig.daemon = {
...reloadConfig.daemon,
listen: process.platform === "win32" ? `${listen}-changed` : `${listen}.changed`,
browserTools: { enabled: true },
};
await writeFile(configPath, `${JSON.stringify(reloadConfig, null, 2)}\n`, "utf-8");
const nestedReload = await daemonCommand(["reload", "--host", listen, "--json"]);
assert.strictEqual(nestedReload.exitCode, 0, nestedReload.stderr);
assert.deepStrictEqual(JSON.parse(nestedReload.stdout), {
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.listen"],
});
reloadConfig.daemon.browserTools.enabled = false;
await writeFile(configPath, `${JSON.stringify(reloadConfig, null, 2)}\n`, "utf-8");
const aliasReload = await runLocalPaseo(["reload", "--host", listen, "--json"], {
PASEO_HOME: paseoHome,
});
assert.strictEqual(aliasReload.exitCode, 0, aliasReload.stderr);
assert.deepStrictEqual(JSON.parse(aliasReload.stdout), {
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
const yamlReload = await daemonCommand(["reload", "--host", listen, "--format", "yaml"]);
assert.strictEqual(yamlReload.exitCode, 0, yamlReload.stderr);
assert.deepStrictEqual(YAML.parse(yamlReload.stdout), {
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
const humanReload = await daemonCommand(["reload", "--host", listen]);
assert.match(humanReload.stdout, /Configuration reloaded\./);
const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-"));
try {
await writeFile(