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:
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user