Skip to content

Commit a1063aa

Browse files
authored
Expose channel preview warning doctor findings (#99238)
* Expose startup channel maintenance doctor findings * Name channel preview warning doctor check accurately
1 parent 2ffeedf commit a1063aa

5 files changed

Lines changed: 244 additions & 15 deletions

File tree

src/commands/doctor/shared/preview-warnings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,7 @@ export type DoctorPreviewNotes = {
675675
warningNotes: string[];
676676
};
677677

678-
async function resolveDoctorChannelPreviewConfig(params: {
678+
export async function resolveDoctorChannelPreviewConfig(params: {
679679
cfg: OpenClawConfig;
680680
env: NodeJS.ProcessEnv;
681681
allowExec?: boolean;

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ const mocks = vi.hoisted(() => ({
122122
}),
123123
),
124124
collectStalePluginRuntimeSymlinkHealthFindings: vi.fn(async () => [] as unknown[]),
125+
collectChannelPreviewWarningHealthFindings: vi.fn(
126+
async (): Promise<readonly HealthFinding[]> => [],
127+
),
125128
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
126129
logConfigUpdated: vi.fn(),
127130
isRecord: vi.fn(
@@ -368,6 +371,14 @@ vi.mock("../commands/doctor/shared/channel-plugin-blockers.js", () => ({
368371
channelPluginBlockerHitToHealthFinding: mocks.channelPluginBlockerHitToHealthFinding,
369372
}));
370373

374+
vi.mock("./doctor-startup-channel-maintenance.js", async (importOriginal) => {
375+
const actual = await importOriginal<typeof import("./doctor-startup-channel-maintenance.js")>();
376+
return {
377+
...actual,
378+
collectChannelPreviewWarningHealthFindings: mocks.collectChannelPreviewWarningHealthFindings,
379+
};
380+
});
381+
371382
vi.mock("../commands/onboard-helpers.js", () => ({
372383
applyWizardMetadata: mocks.applyWizardMetadata,
373384
randomToken: vi.fn(() => "generated-gateway-token"),
@@ -583,6 +594,8 @@ describe("doctor health contributions", () => {
583594
mocks.channelPluginBlockerHitToHealthFinding.mockClear();
584595
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockReset();
585596
mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValue([]);
597+
mocks.collectChannelPreviewWarningHealthFindings.mockReset();
598+
mocks.collectChannelPreviewWarningHealthFindings.mockResolvedValue([]);
586599
});
587600

588601
afterEach(() => {
@@ -1399,6 +1412,7 @@ describe("doctor health contributions", () => {
13991412
expect(contributionIds).toContain("core/doctor/whatsapp-responsiveness");
14001413
expect(contributionIds).toContain("core/doctor/device-pairing");
14011414
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
1415+
expect(contributionIds).toContain("core/doctor/channel-preview-warnings");
14021416
expect(contributionIds).toContain("core/doctor/tool-result-cap");
14031417
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
14041418
});
@@ -1966,6 +1980,78 @@ describe("doctor health contributions", () => {
19661980
expect(mocks.scanConfiguredChannelPluginBlockers).toHaveBeenCalledWith(ctx.cfg, process.env);
19671981
});
19681982

1983+
it("keeps channel preview warnings opt-in for default lint selection", async () => {
1984+
const contribution = requireDoctorContribution("doctor:startup-channel-maintenance");
1985+
expect(contribution.healthCheckIds).toEqual([
1986+
"core/doctor/channel-plugin-blockers",
1987+
"core/doctor/channel-preview-warnings",
1988+
]);
1989+
const previewWarningsCheck = contribution.healthChecks.find(
1990+
(check) => check.id === "core/doctor/channel-preview-warnings",
1991+
) as HealthCheck | undefined;
1992+
expect(previewWarningsCheck).toMatchObject({ defaultEnabled: false });
1993+
expect(previewWarningsCheck).toBeDefined();
1994+
mocks.collectChannelPreviewWarningHealthFindings.mockResolvedValue([
1995+
{
1996+
checkId: "core/doctor/channel-preview-warnings",
1997+
severity: "warning",
1998+
message: "channels.matrix has a preview warning",
1999+
path: "channels.matrix",
2000+
},
2001+
]);
2002+
2003+
const ctx = {
2004+
cfg: { channels: { matrix: { enabled: true } } },
2005+
mode: "lint",
2006+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
2007+
} as const;
2008+
const checks = [previewWarningsCheck!];
2009+
2010+
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
2011+
checksRun: 0,
2012+
checksSkipped: 1,
2013+
});
2014+
expect(mocks.collectChannelPreviewWarningHealthFindings).not.toHaveBeenCalled();
2015+
2016+
await expect(
2017+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/channel-preview-warnings"] }),
2018+
).resolves.toMatchObject({
2019+
checksRun: 1,
2020+
checksSkipped: 0,
2021+
findings: [
2022+
expect.objectContaining({
2023+
checkId: "core/doctor/channel-preview-warnings",
2024+
path: "channels.matrix",
2025+
}),
2026+
],
2027+
});
2028+
expect(mocks.collectChannelPreviewWarningHealthFindings).toHaveBeenCalledWith({
2029+
cfg: ctx.cfg,
2030+
allowExec: false,
2031+
});
2032+
});
2033+
2034+
it("forwards allow-exec secret refs into channel preview warnings", async () => {
2035+
const contribution = requireDoctorContribution("doctor:startup-channel-maintenance");
2036+
const previewWarningsCheck = contribution.healthChecks.find(
2037+
(check) => check.id === "core/doctor/channel-preview-warnings",
2038+
) as HealthCheck | undefined;
2039+
expect(previewWarningsCheck).toBeDefined();
2040+
const ctx = {
2041+
cfg: { channels: { matrix: { enabled: true } } },
2042+
mode: "lint",
2043+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
2044+
allowExecSecretRefs: true,
2045+
} as const;
2046+
2047+
await previewWarningsCheck!.detect(ctx);
2048+
2049+
expect(mocks.collectChannelPreviewWarningHealthFindings).toHaveBeenCalledWith({
2050+
cfg: ctx.cfg,
2051+
allowExec: true,
2052+
});
2053+
});
2054+
19692055
it("uses legacy run when a contribution also declares structured health", async () => {
19702056
const legacyRun = vi.fn();
19712057
const healthChecks = {

src/flows/doctor-health-contributions.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,18 +1784,37 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
17841784
createDoctorHealthContribution({
17851785
id: "doctor:startup-channel-maintenance",
17861786
label: "Startup channel maintenance",
1787-
healthChecks: {
1788-
id: "core/doctor/channel-plugin-blockers",
1789-
description: "Configured channels must have loadable backing channel plugins.",
1790-
defaultEnabled: false,
1791-
async detect(ctx) {
1792-
const { channelPluginBlockerHitToHealthFinding, scanConfiguredChannelPluginBlockers } =
1793-
await import("../commands/doctor/shared/channel-plugin-blockers.js");
1794-
return scanConfiguredChannelPluginBlockers(ctx.cfg, process.env).map(
1795-
channelPluginBlockerHitToHealthFinding,
1796-
);
1787+
healthCheckIds: [
1788+
"core/doctor/channel-plugin-blockers",
1789+
"core/doctor/channel-preview-warnings",
1790+
],
1791+
healthChecks: [
1792+
{
1793+
id: "core/doctor/channel-plugin-blockers",
1794+
description: "Configured channels must have loadable backing channel plugins.",
1795+
defaultEnabled: false,
1796+
async detect(ctx) {
1797+
const { channelPluginBlockerHitToHealthFinding, scanConfiguredChannelPluginBlockers } =
1798+
await import("../commands/doctor/shared/channel-plugin-blockers.js");
1799+
return scanConfiguredChannelPluginBlockers(ctx.cfg, process.env).map(
1800+
channelPluginBlockerHitToHealthFinding,
1801+
);
1802+
},
17971803
},
1798-
},
1804+
{
1805+
id: "core/doctor/channel-preview-warnings",
1806+
description: "Channel doctor preview warnings are captured as structured findings.",
1807+
defaultEnabled: false,
1808+
async detect(ctx) {
1809+
const { collectChannelPreviewWarningHealthFindings } =
1810+
await import("./doctor-startup-channel-maintenance.js");
1811+
return collectChannelPreviewWarningHealthFindings({
1812+
cfg: ctx.cfg,
1813+
allowExec: ctx.allowExecSecretRefs === true,
1814+
});
1815+
},
1816+
},
1817+
],
17991818
run: runStartupChannelMaintenanceHealth,
18001819
}),
18011820
createDoctorHealthContribution({

src/flows/doctor-startup-channel-maintenance.test.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,77 @@
1-
// Doctor startup maintenance tests cover channel startup maintenance checks.
2-
import { describe, expect, it } from "vitest";
3-
import { maybeRunDoctorStartupChannelMaintenance } from "./doctor-startup-channel-maintenance.js";
1+
// Doctor startup maintenance tests cover channel preview warnings and startup repair flow.
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
collectChannelPreviewWarningHealthFindings,
5+
maybeRunDoctorStartupChannelMaintenance,
6+
} from "./doctor-startup-channel-maintenance.js";
7+
8+
const mocks = vi.hoisted(() => ({
9+
resolveDoctorChannelPreviewConfig: vi.fn(async (params: { cfg: unknown }) => ({
10+
cfg: params.cfg,
11+
diagnostics: [],
12+
})),
13+
collectChannelDoctorPreviewWarnings: vi.fn(async (): Promise<string[]> => []),
14+
}));
15+
16+
vi.mock("../commands/doctor/shared/preview-warnings.js", () => ({
17+
resolveDoctorChannelPreviewConfig: mocks.resolveDoctorChannelPreviewConfig,
18+
}));
19+
20+
vi.mock("../commands/doctor/shared/channel-doctor.js", () => ({
21+
collectChannelDoctorPreviewWarnings: mocks.collectChannelDoctorPreviewWarnings,
22+
}));
423

524
describe("doctor startup channel maintenance", () => {
25+
beforeEach(() => {
26+
mocks.resolveDoctorChannelPreviewConfig.mockReset().mockImplementation(async (params) => ({
27+
cfg: params.cfg,
28+
diagnostics: [],
29+
}));
30+
mocks.collectChannelDoctorPreviewWarnings.mockReset().mockResolvedValue([]);
31+
});
32+
33+
it("maps channel doctor preview warnings to structured findings", async () => {
34+
const cfg = {
35+
channels: {
36+
matrix: {
37+
enabled: true,
38+
},
39+
},
40+
};
41+
mocks.collectChannelDoctorPreviewWarnings.mockResolvedValue([
42+
"- channels.matrix: stale config needs startup maintenance.",
43+
]);
44+
45+
await expect(
46+
collectChannelPreviewWarningHealthFindings({
47+
cfg,
48+
doctorFixCommand: "openclaw doctor --fix --dry-run",
49+
env: { OPENCLAW_TEST: "1" },
50+
allowExec: true,
51+
}),
52+
).resolves.toEqual([
53+
{
54+
checkId: "core/doctor/channel-preview-warnings",
55+
severity: "warning",
56+
message: "channels.matrix: stale config needs startup maintenance.",
57+
path: "channels.matrix",
58+
requirement: "Configured channels should not emit doctor preview warnings.",
59+
fixHint:
60+
"Run `openclaw doctor --fix --dry-run` if the channel warning recommends repair, or update the affected channel config manually.",
61+
},
62+
]);
63+
expect(mocks.resolveDoctorChannelPreviewConfig).toHaveBeenCalledWith({
64+
cfg,
65+
env: { OPENCLAW_TEST: "1" },
66+
allowExec: true,
67+
});
68+
expect(mocks.collectChannelDoctorPreviewWarnings).toHaveBeenCalledWith({
69+
cfg,
70+
doctorFixCommand: "openclaw doctor --fix --dry-run",
71+
env: { OPENCLAW_TEST: "1" },
72+
});
73+
});
74+
675
it("runs Matrix startup migration during repair flows", async () => {
776
const cfg = {
877
channels: {

src/flows/doctor-startup-channel-maintenance.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Doctor startup channel maintenance runs channel plugin startup repairs.
22
import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js";
3+
import { resolveDoctorChannelPreviewConfig } from "../commands/doctor/shared/preview-warnings.js";
34
import type { OpenClawConfig } from "../config/types.openclaw.js";
5+
import type { HealthFinding } from "./health-checks.js";
6+
7+
const CHANNEL_PREVIEW_WARNINGS_CHECK_ID = "core/doctor/channel-preview-warnings";
48

59
// Doctor wrapper for plugin startup maintenance repairs.
610
type DoctorStartupMaintenanceRuntime = {
@@ -10,6 +14,57 @@ type DoctorStartupMaintenanceRuntime = {
1014

1115
type ChannelPluginStartupMaintenanceRunner = typeof runChannelPluginStartupMaintenance;
1216

17+
function normalizeWarningMessage(warning: string): string {
18+
return warning.replace(/^-\s*/, "").trim();
19+
}
20+
21+
function warningPath(warning: string): string | undefined {
22+
return warning.match(/^\s*-?\s*(channels\.[^\s:]+)/)?.[1];
23+
}
24+
25+
/** Collect read-only channel doctor preview warnings as structured findings. */
26+
export async function collectChannelPreviewWarningHealthFindings(params: {
27+
cfg: OpenClawConfig;
28+
doctorFixCommand?: string;
29+
env?: NodeJS.ProcessEnv;
30+
allowExec?: boolean;
31+
}): Promise<readonly HealthFinding[]> {
32+
const { collectChannelDoctorPreviewWarnings } =
33+
await import("../commands/doctor/shared/channel-doctor.js");
34+
const doctorFixCommand = params.doctorFixCommand ?? "openclaw doctor --fix";
35+
const previewConfig = await resolveDoctorChannelPreviewConfig({
36+
cfg: params.cfg,
37+
env: params.env ?? process.env,
38+
allowExec: params.allowExec,
39+
});
40+
const warnings = await collectChannelDoctorPreviewWarnings({
41+
cfg: previewConfig.cfg,
42+
doctorFixCommand,
43+
env: params.env ?? process.env,
44+
});
45+
return warnings.map((warning): HealthFinding => {
46+
const path = warningPath(warning);
47+
const baseFinding = {
48+
checkId: CHANNEL_PREVIEW_WARNINGS_CHECK_ID,
49+
severity: "warning",
50+
message: normalizeWarningMessage(warning),
51+
requirement: "Configured channels should not emit doctor preview warnings.",
52+
fixHint: `Run \`${doctorFixCommand}\` if the channel warning recommends repair, or update the affected channel config manually.`,
53+
} satisfies HealthFinding;
54+
if (path) {
55+
return {
56+
checkId: baseFinding.checkId,
57+
severity: baseFinding.severity,
58+
message: baseFinding.message,
59+
path,
60+
requirement: baseFinding.requirement,
61+
fixHint: baseFinding.fixHint,
62+
};
63+
}
64+
return baseFinding;
65+
});
66+
}
67+
1368
/** Runs channel plugin startup maintenance when doctor fix mode explicitly permits repairs. */
1469
export async function maybeRunDoctorStartupChannelMaintenance(params: {
1570
cfg: OpenClawConfig;

0 commit comments

Comments
 (0)