mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
feat(update): add core auto-updater and dry-run preview
This commit is contained in:
@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
|
||||
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
|
||||
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
|
||||
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
|
||||
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
|
||||
|
||||
@@ -21,6 +21,7 @@ openclaw update wizard
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
openclaw update --tag beta
|
||||
openclaw update --dry-run
|
||||
openclaw update --no-restart
|
||||
openclaw update --json
|
||||
openclaw --update
|
||||
@@ -31,6 +32,7 @@ openclaw --update
|
||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
@@ -66,6 +68,8 @@ install method aligned:
|
||||
updates it, and installs the global CLI from that checkout.
|
||||
- `stable`/`beta` → installs from npm using the matching dist-tag.
|
||||
|
||||
The Gateway core auto-updater (when enabled via config) reuses this same update path.
|
||||
|
||||
## Git checkout flow
|
||||
|
||||
Channels:
|
||||
|
||||
@@ -71,6 +71,32 @@ See [Development channels](/install/development-channels) for channel semantics
|
||||
|
||||
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||
|
||||
### Core auto-updater (optional)
|
||||
|
||||
Auto-updater is **off by default** and is a core Gateway feature (not a plugin).
|
||||
|
||||
```json
|
||||
{
|
||||
"update": {
|
||||
"channel": "stable",
|
||||
"auto": {
|
||||
"enabled": true,
|
||||
"stableDelayHours": 6,
|
||||
"stableJitterHours": 12,
|
||||
"betaCheckIntervalHours": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `stable`: when a new version is seen, OpenClaw waits `stableDelayHours` and then applies a deterministic per-install jitter in `stableJitterHours` (spread rollout).
|
||||
- `beta`: checks on `betaCheckIntervalHours` cadence (default: hourly) and applies when an update is available.
|
||||
- `dev`: no automatic apply; use manual `openclaw update`.
|
||||
|
||||
Use `openclaw update --dry-run` to preview update actions before enabling automation.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -374,6 +374,23 @@ describe("update-cli", () => {
|
||||
expect(defaultRuntime.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateCommand --dry-run previews without mutating", async () => {
|
||||
vi.mocked(defaultRuntime.log).mockClear();
|
||||
serviceLoaded.mockResolvedValue(true);
|
||||
|
||||
await updateCommand({ dryRun: true, channel: "beta" });
|
||||
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(runDaemonInstall).not.toHaveBeenCalled();
|
||||
expect(runRestartScript).not.toHaveBeenCalled();
|
||||
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||
|
||||
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
|
||||
expect(logs.join("\n")).toContain("Update dry-run");
|
||||
expect(logs.join("\n")).toContain("No changes were applied.");
|
||||
});
|
||||
|
||||
it("updateStatusCommand prints table output", async () => {
|
||||
await updateStatusCommand({ json: false });
|
||||
|
||||
@@ -704,6 +721,16 @@ describe("update-cli", () => {
|
||||
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
|
||||
});
|
||||
|
||||
it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => {
|
||||
await setupNonInteractiveDowngrade();
|
||||
vi.mocked(defaultRuntime.exit).mockClear();
|
||||
|
||||
await updateCommand({ dryRun: true });
|
||||
|
||||
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false);
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateWizardCommand requires a TTY", async () => {
|
||||
setTty(false);
|
||||
vi.mocked(defaultRuntime.error).mockClear();
|
||||
|
||||
@@ -37,6 +37,7 @@ export function registerUpdateCli(program: Command) {
|
||||
.description("Update OpenClaw and inspect update channel status")
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--no-restart", "Skip restarting the gateway service after a successful update")
|
||||
.option("--dry-run", "Preview update actions without making changes", false)
|
||||
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
|
||||
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
|
||||
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||
@@ -47,6 +48,7 @@ export function registerUpdateCli(program: Command) {
|
||||
["openclaw update --channel beta", "Switch to beta channel (git + npm)"],
|
||||
["openclaw update --channel dev", "Switch to dev channel (git + npm)"],
|
||||
["openclaw update --tag beta", "One-off update to a dist-tag or version"],
|
||||
["openclaw update --dry-run", "Preview actions without changing anything"],
|
||||
["openclaw update --no-restart", "Update without restarting the service"],
|
||||
["openclaw update --json", "Output result as JSON"],
|
||||
["openclaw update --yes", "Non-interactive (accept downgrade prompts)"],
|
||||
@@ -69,6 +71,7 @@ ${theme.heading("Switch channels:")}
|
||||
${theme.heading("Non-interactive:")}
|
||||
- Use --yes to accept downgrade prompts
|
||||
- Combine with --channel/--tag/--restart/--json/--timeout as needed
|
||||
- Use --dry-run to preview actions without writing config/installing/restarting
|
||||
|
||||
${theme.heading("Examples:")}
|
||||
${fmtExamples}
|
||||
@@ -86,6 +89,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
|
||||
await updateCommand({
|
||||
json: Boolean(opts.json),
|
||||
restart: Boolean(opts.restart),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
channel: opts.channel as string | undefined,
|
||||
tag: opts.tag as string | undefined,
|
||||
timeout: opts.timeout as string | undefined,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { pathExists } from "../../utils.js";
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
restart?: boolean;
|
||||
dryRun?: boolean;
|
||||
channel?: string;
|
||||
tag?: string;
|
||||
timeout?: string;
|
||||
|
||||
@@ -114,6 +114,65 @@ function formatCommandFailure(stdout: string, stderr: string): string {
|
||||
return detail.split("\n").slice(-3).join("\n");
|
||||
}
|
||||
|
||||
type UpdateDryRunPreview = {
|
||||
dryRun: true;
|
||||
root: string;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
mode: UpdateRunResult["mode"];
|
||||
updateInstallKind: "git" | "package" | "unknown";
|
||||
switchToGit: boolean;
|
||||
switchToPackage: boolean;
|
||||
restart: boolean;
|
||||
requestedChannel: "stable" | "beta" | "dev" | null;
|
||||
storedChannel: "stable" | "beta" | "dev" | null;
|
||||
effectiveChannel: "stable" | "beta" | "dev";
|
||||
tag: string;
|
||||
currentVersion: string | null;
|
||||
targetVersion: string | null;
|
||||
downgradeRisk: boolean;
|
||||
actions: string[];
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): void {
|
||||
if (jsonMode) {
|
||||
defaultRuntime.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(theme.heading("Update dry-run"));
|
||||
defaultRuntime.log(theme.muted("No changes were applied."));
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(` Root: ${theme.muted(preview.root)}`);
|
||||
defaultRuntime.log(` Install kind: ${theme.muted(preview.installKind)}`);
|
||||
defaultRuntime.log(` Mode: ${theme.muted(preview.mode)}`);
|
||||
defaultRuntime.log(` Channel: ${theme.muted(preview.effectiveChannel)}`);
|
||||
defaultRuntime.log(` Tag/spec: ${theme.muted(preview.tag)}`);
|
||||
if (preview.currentVersion) {
|
||||
defaultRuntime.log(` Current version: ${theme.muted(preview.currentVersion)}`);
|
||||
}
|
||||
if (preview.targetVersion) {
|
||||
defaultRuntime.log(` Target version: ${theme.muted(preview.targetVersion)}`);
|
||||
}
|
||||
if (preview.downgradeRisk) {
|
||||
defaultRuntime.log(theme.warn(" Downgrade confirmation would be required in a real run."));
|
||||
}
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Planned actions:"));
|
||||
for (const action of preview.actions) {
|
||||
defaultRuntime.log(` - ${action}`);
|
||||
}
|
||||
|
||||
if (preview.notes.length > 0) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Notes:"));
|
||||
for (const note of preview.notes) {
|
||||
defaultRuntime.log(` - ${theme.muted(note)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGatewayServiceEnv(params: {
|
||||
result: UpdateRunResult;
|
||||
jsonMode: boolean;
|
||||
@@ -613,11 +672,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
|
||||
const explicitTag = normalizeTag(opts.tag);
|
||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||
let currentVersion: string | null = null;
|
||||
let targetVersion: string | null = null;
|
||||
let downgradeRisk = false;
|
||||
let fallbackToLatest = false;
|
||||
|
||||
if (updateInstallKind !== "git") {
|
||||
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
let fallbackToLatest = false;
|
||||
const targetVersion = explicitTag
|
||||
currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
targetVersion = explicitTag
|
||||
? await resolveTargetVersion(tag, timeoutMs)
|
||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
tag = resolved.tag;
|
||||
@@ -626,38 +688,106 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
});
|
||||
const cmp =
|
||||
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
|
||||
const needsConfirm =
|
||||
downgradeRisk =
|
||||
!fallbackToLatest &&
|
||||
currentVersion != null &&
|
||||
(targetVersion == null || (cmp != null && cmp > 0));
|
||||
}
|
||||
|
||||
if (needsConfirm && !opts.yes) {
|
||||
if (!process.stdin.isTTY || opts.json) {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"Downgrade confirmation required.",
|
||||
"Downgrading can break configuration. Re-run in a TTY to confirm.",
|
||||
].join("\n"),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLabel = targetVersion ?? `${tag} (unknown)`;
|
||||
const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`;
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage(message),
|
||||
initialValue: false,
|
||||
if (opts.dryRun) {
|
||||
let mode: UpdateRunResult["mode"] = "unknown";
|
||||
if (updateInstallKind === "git") {
|
||||
mode = "git";
|
||||
} else if (updateInstallKind === "package") {
|
||||
mode = await resolveGlobalManager({
|
||||
root,
|
||||
installKind,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
}
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (opts.tag && !opts.json) {
|
||||
|
||||
const actions: string[] = [];
|
||||
if (requestedChannel && requestedChannel !== storedChannel) {
|
||||
actions.push(`Persist update.channel=${requestedChannel} in config`);
|
||||
}
|
||||
if (switchToGit) {
|
||||
actions.push("Switch install mode from package to git checkout (dev channel)");
|
||||
} else if (switchToPackage) {
|
||||
actions.push(`Switch install mode from git to package manager (${mode})`);
|
||||
} else if (updateInstallKind === "git") {
|
||||
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
|
||||
} else {
|
||||
actions.push(`Run global package manager update with spec openclaw@${tag}`);
|
||||
}
|
||||
actions.push("Run plugin update sync after core update");
|
||||
actions.push("Refresh shell completion cache (if needed)");
|
||||
actions.push(
|
||||
shouldRestart
|
||||
? "Restart gateway service and run doctor checks"
|
||||
: "Skip restart (because --no-restart is set)",
|
||||
);
|
||||
|
||||
const notes: string[] = [];
|
||||
if (opts.tag && updateInstallKind === "git") {
|
||||
notes.push("--tag applies to npm installs only; git updates ignore it.");
|
||||
}
|
||||
if (fallbackToLatest) {
|
||||
notes.push("Beta channel resolves to latest for this run (fallback).");
|
||||
}
|
||||
|
||||
printDryRunPreview(
|
||||
{
|
||||
dryRun: true,
|
||||
root,
|
||||
installKind,
|
||||
mode,
|
||||
updateInstallKind,
|
||||
switchToGit,
|
||||
switchToPackage,
|
||||
restart: shouldRestart,
|
||||
requestedChannel,
|
||||
storedChannel,
|
||||
effectiveChannel: channel,
|
||||
tag,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
downgradeRisk,
|
||||
actions,
|
||||
notes,
|
||||
},
|
||||
Boolean(opts.json),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (downgradeRisk && !opts.yes) {
|
||||
if (!process.stdin.isTTY || opts.json) {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"Downgrade confirmation required.",
|
||||
"Downgrading can break configuration. Re-run in a TTY to confirm.",
|
||||
].join("\n"),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLabel = targetVersion ?? `${tag} (unknown)`;
|
||||
const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`;
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage(message),
|
||||
initialValue: false,
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
}
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInstallKind === "git" && opts.tag && !opts.json) {
|
||||
defaultRuntime.log(
|
||||
theme.muted("Note: --tag applies to npm installs only; git updates ignore it."),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,12 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
|
||||
"update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").',
|
||||
"update.checkOnStart": "Check for npm updates when the gateway starts (default: true).",
|
||||
"update.auto.enabled": "Enable background auto-update for package installs (default: false).",
|
||||
"update.auto.stableDelayHours":
|
||||
"Minimum delay before stable-channel auto-apply starts (default: 6).",
|
||||
"update.auto.stableJitterHours":
|
||||
"Extra stable-channel rollout spread window in hours (default: 12).",
|
||||
"update.auto.betaCheckIntervalHours": "How often beta-channel checks run in hours (default: 1).",
|
||||
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
|
||||
"gateway.remote.tlsFingerprint":
|
||||
"Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).",
|
||||
|
||||
@@ -5,6 +5,10 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"meta.lastTouchedAt": "Config Last Touched At",
|
||||
"update.channel": "Update Channel",
|
||||
"update.checkOnStart": "Update Check on Start",
|
||||
"update.auto.enabled": "Auto Update Enabled",
|
||||
"update.auto.stableDelayHours": "Auto Update Stable Delay (hours)",
|
||||
"update.auto.stableJitterHours": "Auto Update Stable Jitter (hours)",
|
||||
"update.auto.betaCheckIntervalHours": "Auto Update Beta Check Interval (hours)",
|
||||
"diagnostics.enabled": "Diagnostics Enabled",
|
||||
"diagnostics.flags": "Diagnostics Flags",
|
||||
"diagnostics.otel.enabled": "OpenTelemetry Enabled",
|
||||
|
||||
@@ -63,6 +63,17 @@ export type OpenClawConfig = {
|
||||
channel?: "stable" | "beta" | "dev";
|
||||
/** Check for updates on gateway start (npm installs only). */
|
||||
checkOnStart?: boolean;
|
||||
/** Core auto-update policy for package installs. */
|
||||
auto?: {
|
||||
/** Enable background auto-update checks and apply logic. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Stable channel minimum delay before auto-apply. Default: 6. */
|
||||
stableDelayHours?: number;
|
||||
/** Additional stable-channel jitter window. Default: 12. */
|
||||
stableJitterHours?: number;
|
||||
/** Beta channel check cadence. Default: 1 hour. */
|
||||
betaCheckIntervalHours?: number;
|
||||
};
|
||||
};
|
||||
browser?: BrowserConfig;
|
||||
ui?: {
|
||||
|
||||
@@ -213,6 +213,15 @@ export const OpenClawSchema = z
|
||||
.object({
|
||||
channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(),
|
||||
checkOnStart: z.boolean().optional(),
|
||||
auto: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
stableDelayHours: z.number().nonnegative().max(168).optional(),
|
||||
stableJitterHours: z.number().nonnegative().max(168).optional(),
|
||||
betaCheckIntervalHours: z.number().positive().max(24).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createGatewayCloseHandler(params: {
|
||||
pluginServices: PluginServicesHandle | null;
|
||||
cron: { stop: () => void };
|
||||
heartbeatRunner: HeartbeatRunner;
|
||||
updateCheckStop?: (() => void) | null;
|
||||
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
||||
tickInterval: ReturnType<typeof setInterval>;
|
||||
@@ -70,6 +71,11 @@ export function createGatewayCloseHandler(params: {
|
||||
await stopGmailWatcher();
|
||||
params.cron.stop();
|
||||
params.heartbeatRunner.stop();
|
||||
try {
|
||||
params.updateCheckStop?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const timer of params.nodePresenceTimers.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
|
||||
@@ -632,17 +632,17 @@ export async function startGatewayServer(
|
||||
log,
|
||||
isNixMode,
|
||||
});
|
||||
if (!minimalTestGateway) {
|
||||
scheduleGatewayUpdateCheck({
|
||||
cfg: cfgAtStart,
|
||||
log,
|
||||
isNixMode,
|
||||
onUpdateAvailableChange: (updateAvailable) => {
|
||||
const payload: GatewayUpdateAvailableEventPayload = { updateAvailable };
|
||||
broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
const stopGatewayUpdateCheck = minimalTestGateway
|
||||
? () => {}
|
||||
: scheduleGatewayUpdateCheck({
|
||||
cfg: cfgAtStart,
|
||||
log,
|
||||
isNixMode,
|
||||
onUpdateAvailableChange: (updateAvailable) => {
|
||||
const payload: GatewayUpdateAvailableEventPayload = { updateAvailable };
|
||||
broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true });
|
||||
},
|
||||
});
|
||||
const tailscaleCleanup = minimalTestGateway
|
||||
? null
|
||||
: await startGatewayTailscaleExposure({
|
||||
@@ -730,6 +730,7 @@ export async function startGatewayServer(
|
||||
pluginServices,
|
||||
cron,
|
||||
heartbeatRunner,
|
||||
updateCheckStop: stopGatewayUpdateCheck,
|
||||
nodePresenceTimers,
|
||||
broadcast,
|
||||
tickInterval,
|
||||
|
||||
@@ -35,6 +35,10 @@ vi.mock("../version.js", () => ({
|
||||
VERSION: "1.0.0",
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("update-startup", () => {
|
||||
let suiteRoot = "";
|
||||
let suiteCase = 0;
|
||||
@@ -45,6 +49,7 @@ describe("update-startup", () => {
|
||||
let checkUpdateStatus: (typeof import("./update-check.js"))["checkUpdateStatus"];
|
||||
let resolveNpmChannelTag: (typeof import("./update-check.js"))["resolveNpmChannelTag"];
|
||||
let runGatewayUpdateCheck: (typeof import("./update-startup.js"))["runGatewayUpdateCheck"];
|
||||
let scheduleGatewayUpdateCheck: (typeof import("./update-startup.js"))["scheduleGatewayUpdateCheck"];
|
||||
let getUpdateAvailable: (typeof import("./update-startup.js"))["getUpdateAvailable"];
|
||||
let resetUpdateAvailableStateForTest: (typeof import("./update-startup.js"))["resetUpdateAvailableStateForTest"];
|
||||
let loaded = false;
|
||||
@@ -70,8 +75,12 @@ describe("update-startup", () => {
|
||||
if (!loaded) {
|
||||
({ resolveOpenClawPackageRoot } = await import("./openclaw-root.js"));
|
||||
({ checkUpdateStatus, resolveNpmChannelTag } = await import("./update-check.js"));
|
||||
({ runGatewayUpdateCheck, getUpdateAvailable, resetUpdateAvailableStateForTest } =
|
||||
await import("./update-startup.js"));
|
||||
({
|
||||
runGatewayUpdateCheck,
|
||||
scheduleGatewayUpdateCheck,
|
||||
getUpdateAvailable,
|
||||
resetUpdateAvailableStateForTest,
|
||||
} = await import("./update-startup.js"));
|
||||
loaded = true;
|
||||
}
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockClear();
|
||||
@@ -238,4 +247,125 @@ describe("update-startup", () => {
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
await expect(fs.stat(path.join(tempDir, "update-check.json"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("defers stable auto-update until rollout window is due", async () => {
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/opt/openclaw",
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
} satisfies UpdateCheckResult);
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const runAutoUpdate = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: {
|
||||
update: {
|
||||
channel: "stable",
|
||||
auto: {
|
||||
enabled: true,
|
||||
stableDelayHours: 6,
|
||||
stableJitterHours: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
runAutoUpdate,
|
||||
});
|
||||
expect(runAutoUpdate).not.toHaveBeenCalled();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-18T07:00:00Z"));
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: {
|
||||
update: {
|
||||
channel: "stable",
|
||||
auto: {
|
||||
enabled: true,
|
||||
stableDelayHours: 6,
|
||||
stableJitterHours: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
runAutoUpdate,
|
||||
});
|
||||
|
||||
expect(runAutoUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(runAutoUpdate).toHaveBeenCalledWith({
|
||||
channel: "stable",
|
||||
timeoutMs: 45 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs beta auto-update checks hourly when enabled", async () => {
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/opt/openclaw",
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
} satisfies UpdateCheckResult);
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "beta",
|
||||
version: "2.0.0-beta.1",
|
||||
});
|
||||
|
||||
const runAutoUpdate = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: {
|
||||
update: {
|
||||
channel: "beta",
|
||||
auto: {
|
||||
enabled: true,
|
||||
betaCheckIntervalHours: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
runAutoUpdate,
|
||||
});
|
||||
|
||||
expect(runAutoUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(runAutoUpdate).toHaveBeenCalledWith({
|
||||
channel: "beta",
|
||||
timeoutMs: 45 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("scheduleGatewayUpdateCheck returns a cleanup function", async () => {
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/opt/openclaw",
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
} satisfies UpdateCheckResult);
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const stop = scheduleGatewayUpdateCheck({
|
||||
cfg: { update: { channel: "stable" } },
|
||||
log: { info: vi.fn() },
|
||||
isNixMode: false,
|
||||
});
|
||||
expect(typeof stop).toBe("function");
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { loadConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
|
||||
import { normalizeUpdateChannel, DEFAULT_PACKAGE_CHANNEL } from "./update-channels.js";
|
||||
@@ -14,6 +16,29 @@ type UpdateCheckState = {
|
||||
lastNotifiedTag?: string;
|
||||
lastAvailableVersion?: string;
|
||||
lastAvailableTag?: string;
|
||||
autoInstallId?: string;
|
||||
autoFirstSeenVersion?: string;
|
||||
autoFirstSeenTag?: string;
|
||||
autoFirstSeenAt?: string;
|
||||
autoLastAttemptVersion?: string;
|
||||
autoLastAttemptAt?: string;
|
||||
autoLastSuccessVersion?: string;
|
||||
autoLastSuccessAt?: string;
|
||||
};
|
||||
|
||||
type AutoUpdatePolicy = {
|
||||
enabled: boolean;
|
||||
stableDelayHours: number;
|
||||
stableJitterHours: number;
|
||||
betaCheckIntervalHours: number;
|
||||
};
|
||||
|
||||
type AutoUpdateRunResult = {
|
||||
ok: boolean;
|
||||
code: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type UpdateAvailable = {
|
||||
@@ -34,6 +59,11 @@ export function resetUpdateAvailableStateForTest(): void {
|
||||
|
||||
const UPDATE_CHECK_FILENAME = "update-check.json";
|
||||
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const AUTO_UPDATE_COMMAND_TIMEOUT_MS = 45 * 60 * 1000;
|
||||
const AUTO_STABLE_DELAY_HOURS_DEFAULT = 6;
|
||||
const AUTO_STABLE_JITTER_HOURS_DEFAULT = 12;
|
||||
const AUTO_BETA_CHECK_INTERVAL_HOURS_DEFAULT = 1;
|
||||
|
||||
function shouldSkipCheck(allowInTests: boolean): boolean {
|
||||
if (allowInTests) {
|
||||
@@ -45,6 +75,44 @@ function shouldSkipCheck(allowInTests: boolean): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveAutoUpdatePolicy(cfg: ReturnType<typeof loadConfig>): AutoUpdatePolicy {
|
||||
const auto = cfg.update?.auto;
|
||||
const stableDelayHours =
|
||||
typeof auto?.stableDelayHours === "number" && Number.isFinite(auto.stableDelayHours)
|
||||
? Math.max(0, auto.stableDelayHours)
|
||||
: AUTO_STABLE_DELAY_HOURS_DEFAULT;
|
||||
const stableJitterHours =
|
||||
typeof auto?.stableJitterHours === "number" && Number.isFinite(auto.stableJitterHours)
|
||||
? Math.max(0, auto.stableJitterHours)
|
||||
: AUTO_STABLE_JITTER_HOURS_DEFAULT;
|
||||
const betaCheckIntervalHours =
|
||||
typeof auto?.betaCheckIntervalHours === "number" && Number.isFinite(auto.betaCheckIntervalHours)
|
||||
? Math.max(0.25, auto.betaCheckIntervalHours)
|
||||
: AUTO_BETA_CHECK_INTERVAL_HOURS_DEFAULT;
|
||||
|
||||
return {
|
||||
enabled: Boolean(auto?.enabled),
|
||||
stableDelayHours,
|
||||
stableJitterHours,
|
||||
betaCheckIntervalHours,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCheckIntervalMs(cfg: ReturnType<typeof loadConfig>): number {
|
||||
const channel = normalizeUpdateChannel(cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL;
|
||||
const auto = resolveAutoUpdatePolicy(cfg);
|
||||
if (!auto.enabled) {
|
||||
return UPDATE_CHECK_INTERVAL_MS;
|
||||
}
|
||||
if (channel === "beta") {
|
||||
return Math.max(ONE_HOUR_MS / 4, Math.floor(auto.betaCheckIntervalHours * ONE_HOUR_MS));
|
||||
}
|
||||
if (channel === "stable") {
|
||||
return ONE_HOUR_MS;
|
||||
}
|
||||
return UPDATE_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
async function readState(statePath: string): Promise<UpdateCheckState> {
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, "utf-8");
|
||||
@@ -102,12 +170,110 @@ function resolvePersistedUpdateAvailable(state: UpdateCheckState): UpdateAvailab
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStableJitterMs(params: {
|
||||
installId: string;
|
||||
version: string;
|
||||
tag: string;
|
||||
jitterWindowMs: number;
|
||||
}): number {
|
||||
if (params.jitterWindowMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const hash = createHash("sha256")
|
||||
.update(`${params.installId}:${params.version}:${params.tag}`)
|
||||
.digest();
|
||||
const bucket = hash.readUInt32BE(0);
|
||||
return bucket % (Math.floor(params.jitterWindowMs) + 1);
|
||||
}
|
||||
|
||||
function resolveStableAutoApplyAtMs(params: {
|
||||
state: UpdateCheckState;
|
||||
nextState: UpdateCheckState;
|
||||
nowMs: number;
|
||||
version: string;
|
||||
tag: string;
|
||||
stableDelayHours: number;
|
||||
stableJitterHours: number;
|
||||
}): number {
|
||||
if (!params.nextState.autoInstallId) {
|
||||
params.nextState.autoInstallId = params.state.autoInstallId?.trim() || randomUUID();
|
||||
}
|
||||
const installId = params.nextState.autoInstallId;
|
||||
const matchesExisting =
|
||||
params.state.autoFirstSeenVersion === params.version &&
|
||||
params.state.autoFirstSeenTag === params.tag;
|
||||
|
||||
if (!matchesExisting) {
|
||||
params.nextState.autoFirstSeenVersion = params.version;
|
||||
params.nextState.autoFirstSeenTag = params.tag;
|
||||
params.nextState.autoFirstSeenAt = new Date(params.nowMs).toISOString();
|
||||
} else {
|
||||
params.nextState.autoFirstSeenVersion = params.state.autoFirstSeenVersion;
|
||||
params.nextState.autoFirstSeenTag = params.state.autoFirstSeenTag;
|
||||
params.nextState.autoFirstSeenAt = params.state.autoFirstSeenAt;
|
||||
}
|
||||
|
||||
const firstSeenMs = params.nextState.autoFirstSeenAt
|
||||
? Date.parse(params.nextState.autoFirstSeenAt)
|
||||
: params.nowMs;
|
||||
const baseDelayMs = Math.max(0, params.stableDelayHours) * ONE_HOUR_MS;
|
||||
const jitterWindowMs = Math.max(0, params.stableJitterHours) * ONE_HOUR_MS;
|
||||
const jitterMs = resolveStableJitterMs({
|
||||
installId,
|
||||
version: params.version,
|
||||
tag: params.tag,
|
||||
jitterWindowMs,
|
||||
});
|
||||
|
||||
return firstSeenMs + baseDelayMs + jitterMs;
|
||||
}
|
||||
|
||||
async function runAutoUpdateCommand(params: {
|
||||
channel: "stable" | "beta";
|
||||
timeoutMs: number;
|
||||
}): Promise<AutoUpdateRunResult> {
|
||||
try {
|
||||
const res = await runCommandWithTimeout(
|
||||
["openclaw", "update", "--yes", "--channel", params.channel, "--json"],
|
||||
{
|
||||
timeoutMs: params.timeoutMs,
|
||||
env: {
|
||||
OPENCLAW_AUTO_UPDATE: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
ok: res.code === 0,
|
||||
code: res.code,
|
||||
stdout: res.stdout,
|
||||
stderr: res.stderr,
|
||||
reason: res.code === 0 ? undefined : "non-zero-exit",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: null,
|
||||
reason: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clearAutoState(nextState: UpdateCheckState): void {
|
||||
delete nextState.autoFirstSeenVersion;
|
||||
delete nextState.autoFirstSeenTag;
|
||||
delete nextState.autoFirstSeenAt;
|
||||
}
|
||||
|
||||
export async function runGatewayUpdateCheck(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
isNixMode: boolean;
|
||||
allowInTests?: boolean;
|
||||
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
|
||||
runAutoUpdate?: (params: {
|
||||
channel: "stable" | "beta";
|
||||
timeoutMs: number;
|
||||
}) => Promise<AutoUpdateRunResult>;
|
||||
}): Promise<void> {
|
||||
if (shouldSkipCheck(Boolean(params.allowInTests))) {
|
||||
return;
|
||||
@@ -128,8 +294,9 @@ export async function runGatewayUpdateCheck(params: {
|
||||
next: persistedAvailable,
|
||||
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
||||
});
|
||||
const checkIntervalMs = resolveCheckIntervalMs(params.cfg);
|
||||
if (lastCheckedAt && Number.isFinite(lastCheckedAt)) {
|
||||
if (now - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) {
|
||||
if (now - lastCheckedAt < checkIntervalMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -154,6 +321,7 @@ export async function runGatewayUpdateCheck(params: {
|
||||
if (status.installKind !== "package") {
|
||||
delete nextState.lastAvailableVersion;
|
||||
delete nextState.lastAvailableTag;
|
||||
clearAutoState(nextState);
|
||||
setUpdateAvailableCache({
|
||||
next: null,
|
||||
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
||||
@@ -192,9 +360,76 @@ export async function runGatewayUpdateCheck(params: {
|
||||
nextState.lastNotifiedVersion = resolved.version;
|
||||
nextState.lastNotifiedTag = tag;
|
||||
}
|
||||
|
||||
const auto = resolveAutoUpdatePolicy(params.cfg);
|
||||
if (auto.enabled && (channel === "stable" || channel === "beta")) {
|
||||
const runAuto = params.runAutoUpdate ?? runAutoUpdateCommand;
|
||||
const attemptIntervalMs =
|
||||
channel === "beta"
|
||||
? Math.max(ONE_HOUR_MS / 4, Math.floor(auto.betaCheckIntervalHours * ONE_HOUR_MS))
|
||||
: ONE_HOUR_MS;
|
||||
const lastAttemptAt = state.autoLastAttemptAt ? Date.parse(state.autoLastAttemptAt) : null;
|
||||
const recentAttemptForSameVersion =
|
||||
state.autoLastAttemptVersion === resolved.version &&
|
||||
lastAttemptAt != null &&
|
||||
Number.isFinite(lastAttemptAt) &&
|
||||
now - lastAttemptAt < attemptIntervalMs;
|
||||
|
||||
let dueNow = channel === "beta";
|
||||
let applyAfterMs: number | null = null;
|
||||
if (channel === "stable") {
|
||||
applyAfterMs = resolveStableAutoApplyAtMs({
|
||||
state,
|
||||
nextState,
|
||||
nowMs: now,
|
||||
version: resolved.version,
|
||||
tag,
|
||||
stableDelayHours: auto.stableDelayHours,
|
||||
stableJitterHours: auto.stableJitterHours,
|
||||
});
|
||||
dueNow = now >= applyAfterMs;
|
||||
}
|
||||
|
||||
if (!dueNow) {
|
||||
params.log.info("auto-update deferred (stable rollout window active)", {
|
||||
version: resolved.version,
|
||||
tag,
|
||||
applyAfter: applyAfterMs ? new Date(applyAfterMs).toISOString() : undefined,
|
||||
});
|
||||
} else if (recentAttemptForSameVersion) {
|
||||
params.log.info("auto-update deferred (recent attempt exists)", {
|
||||
version: resolved.version,
|
||||
tag,
|
||||
});
|
||||
} else {
|
||||
nextState.autoLastAttemptVersion = resolved.version;
|
||||
nextState.autoLastAttemptAt = new Date(now).toISOString();
|
||||
const outcome = await runAuto({
|
||||
channel,
|
||||
timeoutMs: AUTO_UPDATE_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
if (outcome.ok) {
|
||||
nextState.autoLastSuccessVersion = resolved.version;
|
||||
nextState.autoLastSuccessAt = new Date(now).toISOString();
|
||||
params.log.info("auto-update applied", {
|
||||
channel,
|
||||
version: resolved.version,
|
||||
tag,
|
||||
});
|
||||
} else {
|
||||
params.log.info("auto-update attempt failed", {
|
||||
channel,
|
||||
version: resolved.version,
|
||||
tag,
|
||||
reason: outcome.reason ?? `exit:${outcome.code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete nextState.lastAvailableVersion;
|
||||
delete nextState.lastAvailableTag;
|
||||
clearAutoState(nextState);
|
||||
setUpdateAvailableCache({
|
||||
next: null,
|
||||
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
||||
@@ -209,6 +444,38 @@ export function scheduleGatewayUpdateCheck(params: {
|
||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
isNixMode: boolean;
|
||||
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
|
||||
}): void {
|
||||
void runGatewayUpdateCheck(params).catch(() => {});
|
||||
}): () => void {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let running = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped || running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
await runGatewayUpdateCheck(params);
|
||||
} catch {
|
||||
// Intentionally ignored: update checks should never crash the gateway loop.
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
const intervalMs = resolveCheckIntervalMs(params.cfg);
|
||||
timer = setTimeout(() => {
|
||||
void tick();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user