Skip to content

Commit 3adfe2f

Browse files
committed
Preview doctor config writes during dry run
1 parent 32762da commit 3adfe2f

3 files changed

Lines changed: 168 additions & 14 deletions

File tree

src/commands/doctor.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type DoctorOptions = {
55
nonInteractive?: boolean;
66
deep?: boolean;
77
repair?: boolean;
8+
dryRun?: boolean;
89
force?: boolean;
910
generateGatewayToken?: boolean;
1011
allowExec?: boolean;

src/flows/doctor-health-contributions.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,6 +2079,75 @@ describe("doctor health contributions", () => {
20792079
);
20802080
});
20812081

2082+
it("previews config writes during doctor dry-run without persisting", async () => {
2083+
const ctx = buildWriteConfigCtx({});
2084+
ctx.options = { dryRun: true };
2085+
ctx.configResult.preservedLegacyRootKeys = ["defaultModel"];
2086+
2087+
await writeConfigContribution.run(ctx);
2088+
2089+
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
2090+
expect(ctx.runtime.log).toHaveBeenCalledWith(
2091+
[
2092+
"Would write Doctor config changes to /tmp/fake-openclaw.json.",
2093+
"- reason: doctor config repair requested a write; doctor config changed during health checks",
2094+
"- changed top-level keys: gateway",
2095+
"- backup: standard config backup may be written",
2096+
"- write options: allowConfigSizeDrop=true, skipPluginValidation=false",
2097+
"- preserved legacy root keys: defaultModel",
2098+
].join("\n"),
2099+
);
2100+
expect(ctx.repairEffects).toEqual([
2101+
{
2102+
kind: "config",
2103+
action: "would-write-config",
2104+
target: "/tmp/fake-openclaw.json",
2105+
dryRunSafe: true,
2106+
},
2107+
]);
2108+
});
2109+
2110+
it("previews update write options during doctor dry-run", async () => {
2111+
const ctx = buildWriteConfigCtx({
2112+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
2113+
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
2114+
});
2115+
ctx.options = { dryRun: true };
2116+
ctx.configResult.sourceLastTouchedVersion = "2026.5.16-beta.4";
2117+
2118+
await writeConfigContribution.run(ctx);
2119+
2120+
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
2121+
expect(ctx.runtime.log).toHaveBeenCalledWith(
2122+
expect.stringContaining(
2123+
"- write options: allowConfigSizeDrop=true, skipPluginValidation=true",
2124+
),
2125+
);
2126+
expect(ctx.runtime.log).toHaveBeenCalledWith(
2127+
expect.stringContaining(
2128+
"- update backup: /tmp/fake-openclaw.json.pre-update may be referenced",
2129+
),
2130+
);
2131+
expect(ctx.runtime.log).toHaveBeenCalledWith(
2132+
expect.stringContaining("- lastTouchedVersion: preserved from source config during update"),
2133+
);
2134+
});
2135+
2136+
it("keeps legacy update handoff skips during doctor dry-run", async () => {
2137+
const ctx = buildWriteConfigCtx({
2138+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
2139+
});
2140+
ctx.options = { dryRun: true };
2141+
2142+
await writeConfigContribution.run(ctx);
2143+
2144+
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
2145+
expect(ctx.runtime.log).toHaveBeenCalledWith(
2146+
"Skipping doctor config write during legacy update handoff.",
2147+
);
2148+
expect(ctx.repairEffects).toBeUndefined();
2149+
});
2150+
20822151
it("skips plugin schema validation for final validation during update doctor runs", async () => {
20832152
const contribution = requireDoctorContribution("doctor:final-config-validation");
20842153

src/flows/doctor-health-contributions.ts

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-resul
1212
import type { RuntimeEnv } from "../runtime.js";
1313
import { normalizeHealthCheck } from "./health-check-adapter.js";
1414
import type { HealthCheckInput, RunnableHealthCheck } from "./health-check-runner-types.js";
15-
import type { HealthCheck, HealthFinding } from "./health-checks.js";
15+
import type { HealthCheck, HealthFinding, HealthRepairEffect } from "./health-checks.js";
1616
import type { FlowContribution } from "./types.js";
1717

1818
type DoctorFlowMode = "local" | "remote";
@@ -46,6 +46,7 @@ export type DoctorHealthFlowContext = {
4646
gatewayStatus?: import("../commands/status.types.js").StatusSummary;
4747
gatewayMemoryProbe?: Awaited<ReturnType<typeof probeGatewayMemoryStatus>>;
4848
postInstallDoctorResult?: UpdatePostInstallDoctorResult;
49+
repairEffects?: HealthRepairEffect[];
4950
};
5051

5152
type DoctorHealthContribution = FlowContribution & {
@@ -1229,27 +1230,38 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void>
12291230
command: "doctor",
12301231
mode: resolveDoctorMode(ctx.cfg),
12311232
});
1232-
if (shouldSkipLegacyUpdateDoctorConfigWrite({ env: ctx.env ?? process.env })) {
1233-
ctx.runtime.log("Skipping doctor config write during legacy update handoff.");
1234-
return;
1235-
}
12361233
const legacyParentVersionOverride = isLegacyParentWritableUpdateDoctorPass(
12371234
ctx.env ?? process.env,
12381235
)
12391236
? ctx.configResult.sourceLastTouchedVersion?.trim() || ctx.cfg.meta?.lastTouchedVersion
12401237
: undefined;
1238+
const writeOptions = {
1239+
allowConfigSizeDrop: ctx.configResult.shouldWriteConfig === true || updateDoctorRun,
1240+
skipPluginValidation:
1241+
ctx.configResult.skipPluginValidationOnWrite === true || updateDoctorRun,
1242+
preservedLegacyRootKeys: ctx.configResult.preservedLegacyRootKeys,
1243+
...(legacyParentVersionOverride
1244+
? { lastTouchedVersionOverride: legacyParentVersionOverride }
1245+
: {}),
1246+
};
1247+
if (shouldSkipLegacyUpdateDoctorConfigWrite({ env: ctx.env ?? process.env })) {
1248+
ctx.runtime.log("Skipping doctor config write during legacy update handoff.");
1249+
return;
1250+
}
1251+
if (ctx.options.dryRun === true) {
1252+
const preview = buildConfigWriteDryRunPreview({
1253+
ctx,
1254+
updateDoctorRun,
1255+
writeOptions,
1256+
});
1257+
ctx.runtime.log(preview.message);
1258+
ctx.repairEffects = [...(ctx.repairEffects ?? []), preview.effect];
1259+
return;
1260+
}
12411261
await replaceConfigFile({
12421262
nextConfig: ctx.cfg,
12431263
afterWrite: { mode: "auto" },
1244-
writeOptions: {
1245-
allowConfigSizeDrop: ctx.configResult.shouldWriteConfig === true || updateDoctorRun,
1246-
skipPluginValidation:
1247-
ctx.configResult.skipPluginValidationOnWrite === true || updateDoctorRun,
1248-
preservedLegacyRootKeys: ctx.configResult.preservedLegacyRootKeys,
1249-
...(legacyParentVersionOverride
1250-
? { lastTouchedVersionOverride: legacyParentVersionOverride }
1251-
: {}),
1252-
},
1264+
writeOptions,
12531265
});
12541266
logConfigUpdated(ctx.runtime);
12551267
const preUpdateSnapshotPath = `${ctx.configPath}.pre-update`;
@@ -1269,6 +1281,78 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void>
12691281
}
12701282
}
12711283

1284+
function buildConfigWriteDryRunPreview(params: {
1285+
ctx: DoctorHealthFlowContext;
1286+
updateDoctorRun: boolean;
1287+
writeOptions: {
1288+
allowConfigSizeDrop: boolean;
1289+
skipPluginValidation: boolean;
1290+
preservedLegacyRootKeys?: readonly string[];
1291+
lastTouchedVersionOverride?: string;
1292+
};
1293+
}): { message: string; effect: HealthRepairEffect } {
1294+
const changedTopLevelKeys = collectChangedTopLevelConfigKeys(
1295+
params.ctx.cfgForPersistence,
1296+
params.ctx.cfg,
1297+
);
1298+
const reasons: string[] = [];
1299+
if (params.ctx.configResult.shouldWriteConfig === true) {
1300+
reasons.push("doctor config repair requested a write");
1301+
}
1302+
if (JSON.stringify(params.ctx.cfg) !== JSON.stringify(params.ctx.cfgForPersistence)) {
1303+
reasons.push("doctor config changed during health checks");
1304+
}
1305+
if (reasons.length === 0) {
1306+
reasons.push("doctor config write requested");
1307+
}
1308+
1309+
const lines = [
1310+
`Would write Doctor config changes to ${params.ctx.configPath}.`,
1311+
`- reason: ${reasons.join("; ")}`,
1312+
];
1313+
if (changedTopLevelKeys.length > 0) {
1314+
lines.push(`- changed top-level keys: ${changedTopLevelKeys.join(", ")}`);
1315+
}
1316+
lines.push("- backup: standard config backup may be written");
1317+
if (params.updateDoctorRun) {
1318+
lines.push(`- update backup: ${params.ctx.configPath}.pre-update may be referenced`);
1319+
}
1320+
lines.push(
1321+
`- write options: allowConfigSizeDrop=${String(
1322+
params.writeOptions.allowConfigSizeDrop,
1323+
)}, skipPluginValidation=${String(params.writeOptions.skipPluginValidation)}`,
1324+
);
1325+
if ((params.writeOptions.preservedLegacyRootKeys?.length ?? 0) > 0) {
1326+
lines.push(
1327+
`- preserved legacy root keys: ${params.writeOptions.preservedLegacyRootKeys?.join(", ")}`,
1328+
);
1329+
}
1330+
if (params.writeOptions.lastTouchedVersionOverride) {
1331+
lines.push("- lastTouchedVersion: preserved from source config during update");
1332+
}
1333+
return {
1334+
message: lines.join("\n"),
1335+
effect: {
1336+
kind: "config",
1337+
action: "would-write-config",
1338+
target: params.ctx.configPath,
1339+
dryRunSafe: true,
1340+
},
1341+
};
1342+
}
1343+
1344+
function collectChangedTopLevelConfigKeys(before: unknown, after: unknown): string[] {
1345+
if (!isPlainObject(before) || !isPlainObject(after)) {
1346+
return [];
1347+
}
1348+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
1349+
return [...keys].filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
1350+
}
1351+
1352+
function isPlainObject(value: unknown): value is Record<string, unknown> {
1353+
return typeof value === "object" && value !== null && !Array.isArray(value);
1354+
}
1355+
12721356
async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
12731357
if (ctx.options.workspaceSuggestions === false) {
12741358
return;

0 commit comments

Comments
 (0)