diff --git a/docs/diagnostics/flags.md b/docs/diagnostics/flags.md index 1904248257d8..14d4fee2ec38 100644 --- a/docs/diagnostics/flags.md +++ b/docs/diagnostics/flags.md @@ -143,6 +143,9 @@ When `timeline` is enabled only from config, the earliest config-loading spans are missing because OpenClaw has not read config yet; subsequent startup spans are captured normally. +Gateway client commands read timeline flags from source config without opening the shared +state database. This also works when the Gateway is offline. + `OPENCLAW_DIAGNOSTICS=1`, `=all`, and `=*` also enable the timeline, since they enable every flag. Prefer the scoped `timeline` flag when you only want the JSONL artifact and not every other diagnostics flag. diff --git a/src/cli/gateway-backed-exit.test-helpers.ts b/src/cli/gateway-backed-exit.test-helpers.ts index 192c8e6dcd74..0c3201f1f17a 100644 --- a/src/cli/gateway-backed-exit.test-helpers.ts +++ b/src/cli/gateway-backed-exit.test-helpers.ts @@ -31,7 +31,7 @@ export const EMPTY_STABILITY_SNAPSHOT = { summary: { byType: {} }, }; -export async function startCronListGateway(token: string): Promise<{ url: string }> { +export async function startCronListGateway(token?: string): Promise<{ url: string }> { const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); activeServers.add(wss); wss.on("connection", (ws) => { @@ -47,7 +47,7 @@ export async function startCronListGateway(token: string): Promise<{ url: string ws, frame.id, buildMinimalGatewayHelloOkPayload({ - methods: ["cron.list"], + methods: ["cron.list", "cron.status"], auth: { role: "operator", scopes: ["operator.admin"] }, }), ); @@ -65,6 +65,9 @@ export async function startCronListGateway(token: string): Promise<{ url: string deliveryPreviews: {}, }); } + if (frame.method === "cron.status") { + sendMinimalGatewayResponse(ws, frame.id, { enabled: true, jobs: 0 }); + } }); }); await once(wss, "listening"); diff --git a/src/cli/gateway-diagnostics-opens.process.test.ts b/src/cli/gateway-diagnostics-opens.process.test.ts new file mode 100644 index 000000000000..741fe439bcb5 --- /dev/null +++ b/src/cli/gateway-diagnostics-opens.process.test.ts @@ -0,0 +1,120 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { acquireGatewayLock } from "../infra/gateway-lock.js"; +import { sqliteWorkerPreloadEnv } from "../infra/sqlite-worker-preload.test-support.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { + prepareGatewayCliFixture, + runIsolatedGatewayCli, + tempDirs, +} from "./gateway-backed-exit.process.test-support.js"; +import { + closeActiveGatewayServers, + startCronListGateway, +} from "./gateway-backed-exit.test-helpers.js"; + +it("keeps timeline diagnostics off shared state for online and offline local RPCs", async () => { + const root = tempDirs.make("openclaw-cli-diagnostics-opens-"); + const gateway = await startCronListGateway(); + const port = Number(new URL(gateway.url).port); + const { stateDir, configPath } = await prepareGatewayCliFixture(root, { + mode: "local", + port, + auth: { mode: "none" }, + }); + const config = JSON.parse(await fs.readFile(configPath, "utf8")); + await fs.writeFile( + configPath, + JSON.stringify({ ...config, diagnostics: { flags: ["timeline"] } }), + ); + const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + openOpenClawStateDatabase({ path: databasePath }); + closeOpenClawStateDatabaseForTest(); + const lock = await acquireGatewayLock({ + allowInTests: true, + env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }, + port, + }); + expect(lock).not.toBeNull(); + const opensPath = path.join(root, "opens.jsonl"); + const preloadPath = path.join(root, "count-opens.cjs"); + const timelinePath = path.join(root, "timeline.jsonl"); + await fs.writeFile( + preloadPath, + `const fs = require("node:fs"); +const sqlite = require("node:sqlite"); +const record = (event) => fs.appendFileSync(${JSON.stringify(opensPath)}, JSON.stringify(event) + "\\n"); +const open = fs.openSync; +fs.openSync = function(file, flags, ...rest) { + if (String(file) === ${JSON.stringify(databasePath)}) { + record({ kind: "source", flags, stack: new Error().stack }); + } + return open.call(this, file, flags, ...rest); +}; +sqlite.DatabaseSync = new Proxy(sqlite.DatabaseSync, { + construct(target, args, newTarget) { + record({ kind: "sqlite", path: String(args[0]) }); + return Reflect.construct(target, args, newTarget); + } +}); +require("node:module").syncBuiltinESMExports(); +record({ kind: "preload" }); +`, + ); + const counts: Record = {}; + try { + for (const online of [true, false]) { + if (!online) { + await closeActiveGatewayServers(); + await lock?.release(); + } + for (const args of [ + ["cron", "list"], + ["gateway", "call", "cron.status"], + ]) { + await fs.writeFile(opensPath, ""); + await fs.writeFile(timelinePath, ""); + const result = await runIsolatedGatewayCli({ + args: [...args, "--json", ...(!online ? ["--timeout", "250"] : [])], + root, + stateDir, + configPath, + env: { + ...sqliteWorkerPreloadEnv(preloadPath), + OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath, + OPENCLAW_DIAGNOSTICS: undefined, + }, + }); + expect(result, result.stderr).toMatchObject({ code: online ? 0 : 1, signal: null }); + if (online) { + expect(JSON.parse(result.stdout)).toMatchObject( + args[0] === "cron" ? { jobs: [] } : { enabled: true }, + ); + } else { + expect(result.stdout + result.stderr).toContain("Gateway not reachable"); + } + expect(await fs.readFile(timelinePath, "utf8")).toContain("cli.main.argv"); + const events = (await fs.readFile(opensPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { kind: string }); + expect(events).toContainEqual({ kind: "preload" }); + counts[`${online ? "online" : "offline"} ${args.join(" ")}`] = events.filter( + (event) => event.kind !== "preload", + ); + } + } + expect(counts).toEqual({ + "online cron list": [], + "online gateway call cron.status": [], + "offline cron list": [], + "offline gateway call cron.status": [], + }); + } finally { + await lock?.release(); + } +}); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 33f16895d69b..ee2636563381 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -9,7 +9,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import type { ConfigSnapshotReadOptions } from "../config/io.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; -import { flushDiagnosticsTimeline } from "../infra/diagnostics-timeline.js"; import { createNewerSqliteSchemaVersionError } from "../infra/sqlite-user-version.js"; import { setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; @@ -22,6 +21,7 @@ import { captureEnv, withEnvAsync } from "../test-utils/env.js"; import { ExpectedCliError } from "./failure-output.js"; import { getGatewayRunRuntimeHooks } from "./gateway-cli/runtime-hooks.js"; import type { RootHelpRenderOptions } from "./program/root-help.js"; +import { registerRunMainTimelineTests } from "./run-main.timeline.test-support.js"; import { getPendingCliDisposers } from "./runtime-cleanup.js"; import { registerSignalExitBarrier, waitForSignalExitBarriers } from "./signal-exit-barrier.js"; @@ -2398,32 +2398,12 @@ describe("runCli exit behavior", () => { }, ); - it.each([ - ["worker", { observe: false, pluginValidation: "core-only" }], - ["run", { observe: false, skipPluginValidation: true }], - ])( - "preserves node %s config ownership when startup tracing is enabled", - async (subcommand, readOptions) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-timeline-")); - const timelinePath = path.join(root, "timeline.jsonl"); - tryRouteCliMock.mockResolvedValueOnce(true); - loadConfigMock.mockResolvedValueOnce({ diagnostics: { flags: ["timeline"] } }); - try { - await withEnvAsync( - { OPENCLAW_DIAGNOSTICS: "", OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath }, - async () => { - await runCli(["node", "openclaw", "node", subcommand]); - }, - ); - expect(loadConfigMock).toHaveBeenCalledWith(readOptions); - flushDiagnosticsTimeline(); - expect(await fs.readFile(timelinePath, "utf8")).toContain("cli.main.argv"); - } finally { - flushDiagnosticsTimeline(); - await fs.rm(root, { recursive: true, force: true }); - } - }, - ); + registerRunMainTimelineTests({ + runCli: (argv) => runCli(argv), + loadConfigMock, + readSourceConfigBestEffortMock, + tryRouteCliMock, + }); it.each([ ["root command", ["node", "openclaw", "update", "--dry-run", "--json"]], diff --git a/src/cli/run-main.timeline.test-support.ts b/src/cli/run-main.timeline.test-support.ts new file mode 100644 index 000000000000..eaa0a8e42133 --- /dev/null +++ b/src/cli/run-main.timeline.test-support.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, it, type Mock } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { flushDiagnosticsTimeline } from "../infra/diagnostics-timeline.js"; +import { withEnvAsync } from "../test-utils/env.js"; + +export function registerRunMainTimelineTests({ + runCli, + loadConfigMock, + readSourceConfigBestEffortMock, + tryRouteCliMock, +}: { + runCli: (argv: string[]) => Promise; + loadConfigMock: Mock; + readSourceConfigBestEffortMock: Mock; + tryRouteCliMock: Mock; +}): void { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + it.each([ + ["node", "worker"], + ["node", "run"], + ["gateway", "run"], + ])( + "preserves %s %s config ownership when startup tracing is enabled", + async (command, subcommand) => { + const root = tempDirs.make("openclaw-node-timeline-"); + const timelinePath = path.join(root, "timeline.jsonl"); + if (command === "node") { + tryRouteCliMock.mockResolvedValueOnce(true); + } + const readTimelineConfig = + command === "gateway" ? loadConfigMock : readSourceConfigBestEffortMock; + readTimelineConfig.mockResolvedValueOnce({ + diagnostics: { flags: ["timeline"] }, + }); + try { + await withEnvAsync( + { OPENCLAW_DIAGNOSTICS: "", OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath }, + async () => { + await runCli(["node", "openclaw", command, subcommand]); + }, + ); + if (command === "gateway") { + expect(loadConfigMock).toHaveBeenCalledWith({ + observe: false, + isolateEnv: true, + pluginValidation: "core-only", + }); + expect(readSourceConfigBestEffortMock).not.toHaveBeenCalled(); + } else if (subcommand === "run") { + expect(loadConfigMock).toHaveBeenCalledWith({ + observe: false, + skipPluginValidation: true, + }); + } else { + expect(loadConfigMock).not.toHaveBeenCalled(); + } + flushDiagnosticsTimeline(); + expect(await fs.readFile(timelinePath, "utf8")).toContain("cli.main.argv"); + } finally { + flushDiagnosticsTimeline(); + } + }, + ); +} diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index d3f817537892..310f51476897 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -71,6 +71,7 @@ import type { CliHarnessCleanup } from "./runtime-cleanup-scope.js"; import { closeCliResources, runCliDisposer } from "./runtime-cleanup.js"; import { registerSignalExitBarrier, waitForSignalExitBarriers } from "./signal-exit-barrier.js"; import { + configureCliStartupDiagnostics, configureGatewayStartupTraceConsoleFormatting, createGatewayDispatchStartupTrace, } from "./startup-trace.js"; @@ -1283,17 +1284,11 @@ async function runCliWithPreparedOutputMode( let unhandledRejectionHandlerInstalled = false; try { - const startupTraces = [startupTrace, options.additionalStartupTrace].filter( - (trace): trace is ReturnType => Boolean(trace), - ); - if ( - !isDatabaseInvocation && - (await Promise.all(startupTraces.map((trace) => trace.requiresDiagnosticsConfig()))).some( - Boolean, - ) - ) { - const config = await withConsoleLogsRoutedToStderr(readBestEffortCliConfig); - await Promise.all(startupTraces.map((trace) => trace.configureDiagnosticsTimeline(config))); + if (!isDatabaseInvocation) { + await configureCliStartupDiagnostics( + [startupTrace, options.additionalStartupTrace], + isGatewayRunInvocation ? readBestEffortCliConfig : undefined, + ); } if ( !isHelpOrVersionInvocation && diff --git a/src/cli/startup-trace.ts b/src/cli/startup-trace.ts index 7f06739c2fb8..17eb46caf75f 100644 --- a/src/cli/startup-trace.ts +++ b/src/cli/startup-trace.ts @@ -325,6 +325,27 @@ export function createGatewayDispatchStartupTrace( }; } +export async function configureCliStartupDiagnostics( + traces: Array | undefined>, + readConfig?: () => Promise, +): Promise { + const activeTraces = traces.filter( + (trace): trace is ReturnType => Boolean(trace), + ); + if ( + !(await Promise.all(activeTraces.map((trace) => trace.requiresDiagnosticsConfig()))).some( + Boolean, + ) + ) { + return; + } + const { withConsoleLogsRoutedToStderr } = await import("./json-output-mode.js"); + const { readSourceConfigBestEffort } = await import("../config/io.js"); + // Clients need authored flags; Gateway admission supplies its isolated config reader. + const config = await withConsoleLogsRoutedToStderr(readConfig ?? readSourceConfigBestEffort); + await Promise.all(activeTraces.map((trace) => trace.configureDiagnosticsTimeline(config))); +} + export async function prepareGatewayStartupTraceConsoleFormatting( trace: ReturnType, ): Promise<() => void> {