Skip to content

Commit 4ac5cf8

Browse files
authored
Doctor: expose workspace status findings (#97358)
* doctor: expose workspace status findings * fix(doctor): pass workspace drift into lint * fix(doctor): pass allow-exec into workspace lint drift
1 parent 816038e commit 4ac5cf8

4 files changed

Lines changed: 323 additions & 21 deletions

File tree

src/commands/doctor-workspace-status.test.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
createPluginRecord,
99
createTypedHook,
1010
} from "../plugins/status.test-helpers.js";
11-
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
11+
import {
12+
collectWorkspaceStatusHealthFindings,
13+
noteWorkspaceStatus,
14+
} from "./doctor-workspace-status.js";
1215

1316
const mocks = vi.hoisted(() => ({
1417
resolveAgentWorkspaceDir: vi.fn(),
@@ -158,6 +161,110 @@ describe("noteWorkspaceStatus", () => {
158161
}
159162
});
160163

164+
it("collects plugin version drift as structured findings", async () => {
165+
mocks.resolveDefaultAgentId.mockReturnValue("default");
166+
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
167+
mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
168+
workspaceDir: "/workspace",
169+
...createPluginLoadResult({ plugins: [] }),
170+
});
171+
mocks.buildPluginCompatibilityWarnings.mockReturnValue([]);
172+
mocks.listTaskFlowRecords.mockReturnValue([]);
173+
174+
const findings = collectWorkspaceStatusHealthFindings(
175+
{
176+
plugins: { entries: { codex: { enabled: true } } },
177+
},
178+
{
179+
pluginVersionDrift: {
180+
gatewayVersion: "2026.6.1",
181+
drifts: [
182+
{
183+
pluginId: "codex",
184+
installedVersion: "2026.5.30-beta.1",
185+
gatewayVersion: "2026.6.1",
186+
source: "npm",
187+
},
188+
],
189+
},
190+
},
191+
);
192+
193+
expect(findings).toEqual([
194+
expect.objectContaining({
195+
checkId: "core/doctor/workspace-status",
196+
severity: "warning",
197+
path: "plugins.entries.codex",
198+
target: "codex",
199+
requirement: "plugin-version-drift",
200+
message: expect.stringContaining("2026.5.30-beta.1"),
201+
fixHint: expect.stringContaining("openclaw plugins update codex"),
202+
}),
203+
]);
204+
});
205+
206+
it("collects compatibility warnings, plugin diagnostics, and TaskFlow recovery findings", async () => {
207+
mocks.resolveDefaultAgentId.mockReturnValue("default");
208+
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
209+
mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
210+
workspaceDir: "/workspace",
211+
...createPluginLoadResult({
212+
plugins: [],
213+
diagnostics: [
214+
{
215+
level: "error",
216+
pluginId: "broken-plugin",
217+
message: "channel setup failed",
218+
source: "/tmp/plugin.json",
219+
code: "channel-setup-failure",
220+
},
221+
],
222+
}),
223+
});
224+
mocks.buildPluginCompatibilityWarnings.mockReturnValue([
225+
"legacy-plugin still uses legacy before_agent_start",
226+
]);
227+
mocks.listTaskFlowRecords.mockReturnValue([
228+
{
229+
flowId: "flow-123",
230+
syncMode: "managed",
231+
status: "blocked",
232+
blockedTaskId: "task-missing",
233+
},
234+
]);
235+
mocks.listTasksForFlowId.mockReturnValue([]);
236+
237+
const findings = collectWorkspaceStatusHealthFindings({});
238+
239+
expect(findings).toEqual([
240+
expect.objectContaining({
241+
checkId: "core/doctor/workspace-status",
242+
severity: "warning",
243+
path: "plugins",
244+
requirement: "plugin-compatibility",
245+
message: "legacy-plugin still uses legacy before_agent_start",
246+
}),
247+
expect.objectContaining({
248+
checkId: "core/doctor/workspace-status",
249+
severity: "error",
250+
path: "plugins.entries.broken-plugin",
251+
target: "broken-plugin",
252+
requirement: "channel-setup-failure",
253+
source: "/tmp/plugin.json",
254+
message: "channel setup failed",
255+
}),
256+
expect.objectContaining({
257+
checkId: "core/doctor/workspace-status",
258+
severity: "warning",
259+
path: "tasks.flows",
260+
target: "flow-123",
261+
requirement: "taskflow-recovery",
262+
message: expect.stringContaining("task-missing"),
263+
fixHint: expect.stringContaining("openclaw tasks flow show flow-123"),
264+
}),
265+
]);
266+
});
267+
161268
it("surfaces active official managed plugin version drift", async () => {
162269
const noteSpy = await runNoteWorkspaceStatusForTest(
163270
createPluginLoadResult({
@@ -372,7 +479,10 @@ describe("noteWorkspaceStatus", () => {
372479
}
373480
});
374481

375-
const makeSkill = (skillKey: string, fields: { eligible: boolean; platformIncompatible: boolean }) =>
482+
const makeSkill = (
483+
skillKey: string,
484+
fields: { eligible: boolean; platformIncompatible: boolean },
485+
) =>
376486
({
377487
skillKey,
378488
disabled: false,

src/commands/doctor-workspace-status.ts

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { note } from "../../packages/terminal-core/src/note.js";
33
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
44
import { formatCliCommand } from "../cli/command-format.js";
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
6+
import type { HealthFinding } from "../flows/health-checks.js";
67
import {
78
resolvePluginVersionDriftUpdateCommand,
89
type PluginVersionDriftReport,
@@ -19,37 +20,50 @@ type NoteWorkspaceStatusOptions = {
1920
pluginVersionDrift?: PluginVersionDriftReport;
2021
};
2122

22-
function noteFlowRecoveryHints() {
23-
const suspicious = listTaskFlowRecords().flatMap((flow) => {
23+
const WORKSPACE_STATUS_CHECK_ID = "core/doctor/workspace-status";
24+
25+
type TaskFlowRecoveryFinding = {
26+
flowId: string;
27+
message: string;
28+
};
29+
30+
function collectTaskFlowRecoveryFindings(): TaskFlowRecoveryFinding[] {
31+
return listTaskFlowRecords().flatMap((flow) => {
2432
const tasks = listTasksForFlowId(flow.flowId);
25-
const findings: string[] = [];
33+
const findings: TaskFlowRecoveryFinding[] = [];
2634
if (
2735
flow.syncMode === "managed" &&
2836
flow.status === "running" &&
2937
tasks.length === 0 &&
3038
flow.waitJson === undefined
3139
) {
32-
findings.push(
33-
`${flow.flowId}: running managed TaskFlow has no linked tasks or wait state; inspect or cancel it manually.`,
34-
);
40+
findings.push({
41+
flowId: flow.flowId,
42+
message: `${flow.flowId}: running managed TaskFlow has no linked tasks or wait state; inspect or cancel it manually.`,
43+
});
3544
}
3645
if (
3746
flow.status === "blocked" &&
3847
flow.blockedTaskId &&
3948
!tasks.some((task) => task.taskId === flow.blockedTaskId)
4049
) {
41-
findings.push(
42-
`${flow.flowId}: blocked TaskFlow points at missing task ${flow.blockedTaskId}; inspect before retrying.`,
43-
);
50+
findings.push({
51+
flowId: flow.flowId,
52+
message: `${flow.flowId}: blocked TaskFlow points at missing task ${flow.blockedTaskId}; inspect before retrying.`,
53+
});
4454
}
4555
return findings;
4656
});
57+
}
58+
59+
function noteFlowRecoveryHints() {
60+
const suspicious = collectTaskFlowRecoveryFindings();
4761
if (suspicious.length === 0) {
4862
return;
4963
}
5064
note(
5165
[
52-
...suspicious.slice(0, 5),
66+
...suspicious.slice(0, 5).map((finding) => finding.message),
5367
suspicious.length > 5 ? `...and ${suspicious.length - 5} more.` : null,
5468
`Inspect: ${formatCliCommand("openclaw tasks flow show <flow-id>")}`,
5569
`Cancel: ${formatCliCommand("openclaw tasks flow cancel <flow-id>")}`,
@@ -60,6 +74,89 @@ function noteFlowRecoveryHints() {
6074
);
6175
}
6276

77+
function pluginVersionDriftToHealthFindings(
78+
drift: PluginVersionDriftReport | undefined,
79+
): HealthFinding[] {
80+
if (!drift || drift.drifts.length === 0) {
81+
return [];
82+
}
83+
return drift.drifts.map((entry) => {
84+
const updateCommand = formatCliCommand(resolvePluginVersionDriftUpdateCommand(entry));
85+
return {
86+
checkId: WORKSPACE_STATUS_CHECK_ID,
87+
severity: "warning",
88+
message: `Plugin ${entry.pluginId} is ${entry.installedVersion}, but the Gateway is ${drift.gatewayVersion}.`,
89+
path: `plugins.entries.${entry.pluginId}`,
90+
target: entry.pluginId,
91+
requirement: "plugin-version-drift",
92+
fixHint: `${updateCommand} && ${formatCliCommand("openclaw gateway restart")}`,
93+
};
94+
});
95+
}
96+
97+
function pluginCompatibilityWarningToHealthFinding(message: string): HealthFinding {
98+
return {
99+
checkId: WORKSPACE_STATUS_CHECK_ID,
100+
severity: "warning",
101+
message,
102+
path: "plugins",
103+
requirement: "plugin-compatibility",
104+
fixHint: "Update or replace the plugin so it no longer depends on legacy compatibility paths.",
105+
};
106+
}
107+
108+
function pluginDiagnosticToHealthFinding(
109+
diagnostic: ReturnType<typeof buildPluginRegistrySnapshotReport>["diagnostics"][number],
110+
): HealthFinding {
111+
return {
112+
checkId: WORKSPACE_STATUS_CHECK_ID,
113+
severity: diagnostic.level === "error" ? "error" : "warning",
114+
message: diagnostic.message,
115+
...(diagnostic.pluginId ? { path: `plugins.entries.${diagnostic.pluginId}` } : {}),
116+
...(diagnostic.pluginId ? { target: diagnostic.pluginId } : {}),
117+
...(diagnostic.source ? { source: diagnostic.source } : {}),
118+
...(diagnostic.code ? { requirement: diagnostic.code } : { requirement: "plugin-diagnostic" }),
119+
};
120+
}
121+
122+
function taskFlowRecoveryToHealthFinding(finding: TaskFlowRecoveryFinding): HealthFinding {
123+
return {
124+
checkId: WORKSPACE_STATUS_CHECK_ID,
125+
severity: "warning",
126+
message: finding.message,
127+
path: "tasks.flows",
128+
target: finding.flowId,
129+
requirement: "taskflow-recovery",
130+
fixHint: [
131+
formatCliCommand(`openclaw tasks flow show ${finding.flowId}`),
132+
formatCliCommand(`openclaw tasks flow cancel ${finding.flowId}`),
133+
].join(" or "),
134+
};
135+
}
136+
137+
export function collectWorkspaceStatusHealthFindings(
138+
cfg: OpenClawConfig,
139+
options: NoteWorkspaceStatusOptions = {},
140+
): HealthFinding[] {
141+
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
142+
const pluginRegistry = buildPluginRegistrySnapshotReport({
143+
config: cfg,
144+
workspaceDir,
145+
});
146+
const compatibilityWarnings = buildPluginCompatibilityWarnings({
147+
config: cfg,
148+
workspaceDir,
149+
report: pluginRegistry,
150+
});
151+
152+
return [
153+
...pluginVersionDriftToHealthFindings(options.pluginVersionDrift),
154+
...compatibilityWarnings.map(pluginCompatibilityWarningToHealthFinding),
155+
...pluginRegistry.diagnostics.map(pluginDiagnosticToHealthFinding),
156+
...collectTaskFlowRecoveryFindings().map(taskFlowRecoveryToHealthFinding),
157+
];
158+
}
159+
63160
function notePluginVersionDrift(drift: PluginVersionDriftReport | undefined) {
64161
if (!drift || drift.drifts.length === 0) {
65162
return;

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const mocks = vi.hoisted(() => ({
7575
}),
7676
gatherDaemonStatus: vi.fn(),
7777
noteWorkspaceStatus: vi.fn(),
78+
collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]),
7879
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
7980
logConfigUpdated: vi.fn(),
8081
isRecord: vi.fn(
@@ -268,6 +269,7 @@ vi.mock("../cli/daemon-cli/status.gather.js", () => ({
268269

269270
vi.mock("../commands/doctor-workspace-status.js", () => ({
270271
noteWorkspaceStatus: mocks.noteWorkspaceStatus,
272+
collectWorkspaceStatusHealthFindings: mocks.collectWorkspaceStatusHealthFindings,
271273
}));
272274

273275
vi.mock("../commands/onboard-helpers.js", () => ({
@@ -440,6 +442,8 @@ describe("doctor health contributions", () => {
440442
mocks.gatherDaemonStatus.mockReset();
441443
mocks.gatherDaemonStatus.mockResolvedValue({});
442444
mocks.noteWorkspaceStatus.mockReset();
445+
mocks.collectWorkspaceStatusHealthFindings.mockReset();
446+
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]);
443447
});
444448

445449
afterEach(() => {
@@ -667,6 +671,70 @@ describe("doctor health contributions", () => {
667671
expect(ids.indexOf("doctor:skills")).toBeLessThan(ids.indexOf("doctor:write-config"));
668672
});
669673

674+
it("keeps workspace status opt-in for structured lint selection", async () => {
675+
const contribution = requireDoctorContribution("doctor:workspace-status");
676+
const check = contribution.healthChecks[0] as HealthCheck & { defaultEnabled?: boolean };
677+
expect(contribution.healthCheckIds).toEqual(["core/doctor/workspace-status"]);
678+
expect(check.defaultEnabled).toBe(false);
679+
680+
const pluginVersionDrift = {
681+
gatewayVersion: "2026.6.1",
682+
drifts: [
683+
{
684+
pluginId: "codex",
685+
installedVersion: "2026.5.30-beta.1",
686+
gatewayVersion: "2026.6.1",
687+
source: "npm" as const,
688+
},
689+
],
690+
};
691+
mocks.gatherDaemonStatus.mockResolvedValueOnce({
692+
gateway: { version: "2026.6.1" },
693+
pluginVersionDrift,
694+
});
695+
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValueOnce([
696+
{
697+
checkId: "core/doctor/workspace-status",
698+
severity: "warning",
699+
message: "Plugin codex is stale.",
700+
path: "plugins.entries.codex",
701+
},
702+
]);
703+
const ctx = {
704+
cfg: { plugins: { entries: { codex: { enabled: true } } } },
705+
mode: "lint",
706+
allowExecSecretRefs: true,
707+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
708+
} as const;
709+
710+
await expect(runDoctorLintChecks(ctx, { checks: [check] })).resolves.toMatchObject({
711+
checksRun: 0,
712+
checksSkipped: 1,
713+
});
714+
expect(mocks.collectWorkspaceStatusHealthFindings).not.toHaveBeenCalled();
715+
716+
await expect(
717+
runDoctorLintChecks(ctx, { checks: [check], onlyIds: ["core/doctor/workspace-status"] }),
718+
).resolves.toMatchObject({
719+
checksRun: 1,
720+
checksSkipped: 0,
721+
findings: [expect.objectContaining({ checkId: "core/doctor/workspace-status" })],
722+
});
723+
expect(mocks.collectWorkspaceStatusHealthFindings).toHaveBeenCalledWith(ctx.cfg, {
724+
pluginVersionDrift,
725+
});
726+
expect(mocks.gatherDaemonStatus).toHaveBeenCalledWith({
727+
rpc: {
728+
timeout: "3000",
729+
json: true,
730+
},
731+
probe: true,
732+
requireRpc: false,
733+
deep: false,
734+
allowExecSecretRefs: true,
735+
});
736+
});
737+
670738
it("passes daemon-context plugin drift into the workspace status note", async () => {
671739
const contribution = requireDoctorContribution("doctor:workspace-status");
672740
const pluginVersionDrift = {

0 commit comments

Comments
 (0)