diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 837ddb949587..7de719ab5db0 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -186,6 +186,12 @@ describe("ensureConfigReady", () => { } }); + it("keeps status config guard reads non-observing", async () => { + await runEnsureConfigReady(["status"]); + + expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({ observe: false }); + }); + it("runs doctor flow when lightweight startup detection finds legacy state", async () => { const root = useTempOpenClawHome(); writeLegacyTaskSidecarMarker(root); @@ -196,6 +202,7 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + observe: false, }); }); @@ -209,6 +216,7 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + observe: false, }); }); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index a3f7617d0066..9ab6b9b9154e 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -168,7 +168,10 @@ function shouldRequireStartupMigrationCheckpoint(commandPath: string[]): boolean ); } -async function getConfigSnapshot() { +async function getConfigSnapshot(options?: { observe: false }) { + if (options?.observe === false) { + return readConfigFileSnapshot(options); + } // Tests often mutate config fixtures; caching can make those flaky. if (process.env.VITEST === "true") { return readConfigFileSnapshot(); @@ -193,6 +196,8 @@ export async function ensureConfigReady(params: { beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise; }): Promise { const commandPath = params.commandPath ?? []; + const commandName = commandPath[0]; + const subcommandName = commandPath[1]; let preflightSnapshot: Awaited> | null = null; const shouldConsiderStateMigration = shouldMigrateStateFromPath(commandPath); const requiresLegacyStateInput = shouldRunStateMigrationOnlyWithLegacyInputs(commandPath); @@ -203,6 +208,7 @@ export async function ensureConfigReady(params: { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + ...(commandName === "status" ? { observe: false } : {}), ...(shouldRequireStartupMigrationCheckpoint(commandPath) ? { requireStartupMigrationCheckpoint: true } : {}), @@ -230,7 +236,11 @@ export async function ensureConfigReady(params: { preflightSnapshot = await runStateMigrationPreflight(); } - let snapshot = preflightSnapshot ?? (await getConfigSnapshot()); + // Status performs a second non-observing read for its materialized/source pair; + // keep the startup guard from recording config health before the command begins. + const configSnapshotOptions = + commandName === "status" ? ({ observe: false } as const) : undefined; + let snapshot = preflightSnapshot ?? (await getConfigSnapshot(configSnapshotOptions)); if ( !preflightSnapshot && !didRunDoctorConfigFlow && @@ -242,8 +252,6 @@ export async function ensureConfigReady(params: { preflightSnapshot = await runStateMigrationPreflight(); snapshot = preflightSnapshot; } - const commandName = commandPath[0]; - const subcommandName = commandPath[1]; const isBareGatewayForegroundRun = commandName === "gateway" && (subcommandName === undefined || subcommandName.trim() === ""); const isReadOnlyTaskStateCommand = diff --git a/src/commands/doctor-config-preflight.test.ts b/src/commands/doctor-config-preflight.test.ts index c95bbd8df949..bb0cb49a6f91 100644 --- a/src/commands/doctor-config-preflight.test.ts +++ b/src/commands/doctor-config-preflight.test.ts @@ -1,14 +1,53 @@ // Doctor config preflight tests cover last-known-good snapshots and config snapshot promotion. import fs from "node:fs/promises"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot } from "../config/config.js"; import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; import { runDoctorConfigPreflight, shouldSkipPluginValidationForDoctorConfigPreflight, } from "./doctor-config-preflight.js"; +type ConfigHealthDatabase = Pick; + +function readConfigHealthRow(env: NodeJS.ProcessEnv, configPath: string) { + const { db } = openOpenClawStateDatabase({ env }); + const healthDb = getNodeSqliteKysely(db); + return executeSqliteQueryTakeFirstSync( + db, + healthDb + .selectFrom("config_health_entries") + .select("config_path") + .where("config_path", "=", configPath), + ); +} + describe("runDoctorConfigPreflight", () => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + }); + + it("supports non-observing config reads", async () => { + await withTempHome(async (home) => { + const configPath = await writeOpenClawConfig(home, { gateway: { mode: "local" } }); + + await runDoctorConfigPreflight({ + migrateState: false, + migrateLegacyConfig: false, + invalidConfigNote: false, + observe: false, + }); + + expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toBeUndefined(); + }); + }); + it("skips plugin schema validation while doctor is running inside update", () => { expect( shouldSkipPluginValidationForDoctorConfigPreflight({ diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 05ca5cc17119..6174b220713e 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -178,6 +178,7 @@ export async function runDoctorConfigPreflight( repairPrefixedConfig?: boolean; recoverCorruptTargetStore?: boolean; invalidConfigNote?: string | false; + observe?: boolean; /** Return false or reject on config drift; the preflight always unwinds owned resources. */ beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise; requireStartupMigrationCheckpoint?: boolean; @@ -254,6 +255,7 @@ export async function runDoctorConfigPreflight( } const readOptions = { + ...(options.observe === false ? { observe: false } : {}), skipPluginValidation: shouldSkipPluginValidationForDoctorConfigPreflight(), }; let snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions)); diff --git a/src/commands/status-all/report-data.test.ts b/src/commands/status-all/report-data.test.ts new file mode 100644 index 000000000000..2c4e592b92be --- /dev/null +++ b/src/commands/status-all/report-data.test.ts @@ -0,0 +1,71 @@ +// Status-all report data tests cover local read-only diagnosis probes. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })), +})); + +vi.mock("../../agents/exec-defaults.js", () => ({ canExecRequestNode: () => false })); +vi.mock("../../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + resolveGatewayPort: () => 18789, +})); +vi.mock("../../daemon/diagnostics.js", () => ({ + readLastGatewayErrorLine: async () => null, +})); +vi.mock("../../infra/ports.js", () => ({ inspectPortUsage: async () => null })); +vi.mock("../../infra/restart-sentinel.js", () => ({ readRestartSentinel: async () => null })); +vi.mock("../../plugins/status.js", () => ({ buildPluginCompatibilityNotices: () => [] })); +vi.mock("../../skills/discovery/status.js", () => ({ buildWorkspaceSkillStatus: () => null })); +vi.mock("../../skills/runtime/remote.js", () => ({ getRemoteSkillEligibility: () => ({}) })); +vi.mock("../status-overview-rows.ts", () => ({ buildStatusAllOverviewRows: () => [] })); +vi.mock("../status-overview-surface.ts", () => ({ + buildStatusOverviewSurfaceFromOverview: () => ({}), +})); +vi.mock("../status-runtime-shared.ts", () => ({ + resolveStatusGatewayDiagnosticsSafe: async () => null, + resolveStatusGatewayHealthSafe: async () => undefined, +})); +vi.mock("../status-update-restart.ts", () => ({ + formatUpdateRestartStatusValue: () => null, +})); +vi.mock("../status.gateway-connection.ts", () => ({ + resolveStatusAllConnectionDetails: () => [], +})); + +import { buildStatusAllReportData } from "./report-data.js"; + +describe("buildStatusAllReportData", () => { + beforeEach(() => { + mocks.readConfigFileSnapshot.mockClear(); + }); + + it("keeps local config diagnosis non-observing", async () => { + await buildStatusAllReportData({ + overview: { + cfg: {}, + gatewaySnapshot: { + gatewayReachable: false, + gatewayProbe: null, + gatewayCallOverrides: undefined, + gatewayConnection: {}, + remoteUrlMissing: false, + }, + secretDiagnostics: [], + tailscaleMode: "off", + tailscaleDns: null, + agentStatus: { agents: [], defaultId: null }, + channels: { rows: [], details: [] }, + channelIssues: [], + osSummary: { label: "test" }, + } as never, + daemon: {} as never, + nodeService: {} as never, + nodeOnlyGateway: {} as never, + progress: { setLabel: vi.fn(), tick: vi.fn() }, + }); + + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledOnce(); + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false }); + }); +}); diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts index 5d16c800101a..4f5583fa632f 100644 --- a/src/commands/status-all/report-data.ts +++ b/src/commands/status-all/report-data.ts @@ -80,7 +80,7 @@ async function resolveStatusAllLocalDiagnosis(params: { }; }> { const { overview } = params; - const snap = await readConfigFileSnapshot().catch(() => null); + const snap = await readConfigFileSnapshot({ observe: false }).catch(() => null); const configPath = resolveStatusAllConfigPath(snap?.path); const health = params.nodeOnlyGateway diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts index df5ca99e0e80..65e237c7a618 100644 --- a/src/commands/status.scan-overview.test.ts +++ b/src/commands/status.scan-overview.test.ts @@ -134,6 +134,10 @@ describe("collectStatusScanOverview", () => { useGatewayCallOverridesForChannelsStatus: true, }); + expect(mocks.readBestEffortConfigSnapshot).toHaveBeenCalledWith({ + observe: false, + skipPluginValidation: undefined, + }); expect(mocks.callGateway).toHaveBeenCalledOnce(); const gatewayRequest = firstGatewayRequest(); expect(gatewayRequest?.method).toBe("channels.status"); diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index 0be8df0ef29f..cbe62ba474bb 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -194,6 +194,7 @@ export async function collectStatusScanOverview(params: { allowMissingConfigFastPath: params.allowMissingConfigFastPath, readConfigSnapshot: async () => (await loadConfigModule()).readBestEffortConfigSnapshot({ + observe: false, skipPluginValidation: params.skipConfigPluginValidation, }), resolveConfig: async (loadedConfig) => diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index a86a321ca5ba..b7e7d07a0e83 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -243,9 +243,9 @@ describe("readBestEffortConfig", () => { }); }); - it("returns source and materialized config from one snapshot", async () => { + it("controls observation while returning source and materialized config", async () => { await withTempHome(async (home) => { - await writeOpenClawConfig(home, { + const configPath = await writeOpenClawConfig(home, { auth: { profiles: { "anthropic:api": { provider: "anthropic", mode: "api_key" }, @@ -257,12 +257,22 @@ describe("readBestEffortConfig", () => { }, }, }); + const configRaw = await fs.readFile(configPath, "utf-8"); - const snapshot = await readBestEffortConfigSnapshot(); + const snapshot = await readBestEffortConfigSnapshot({ observe: false }); expect(snapshot.sourceConfig.agents?.defaults?.contextPruning?.mode).toBeUndefined(); expect(snapshot.config.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl"); expect(snapshot.config.agents?.defaults?.compaction?.mode).toBe("safeguard"); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(configRaw); + expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toBeUndefined(); + + await readBestEffortConfigSnapshot(); + + expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toMatchObject({ + config_path: configPath, + last_known_good_json: expect.any(String), + }); }); }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 0bf2a7df0206..c83a9e0eadd7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -2826,11 +2826,13 @@ export async function readBestEffortConfig(options?: { } export async function readBestEffortConfigSnapshot(options?: { + observe?: boolean; skipPluginValidation?: boolean; }): Promise { - return await createConfigIO( - options?.skipPluginValidation ? { pluginValidation: "skip" } : {}, - ).readBestEffortConfigSnapshot(); + return await createConfigIO({ + ...(options?.observe === false ? { observe: false } : {}), + ...(options?.skipPluginValidation ? { pluginValidation: "skip" } : {}), + }).readBestEffortConfigSnapshot(); } export async function readSourceConfigBestEffort(): Promise {