diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cad93157709..3c523829e45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1. - Gateway/performance: add `pnpm test:restart:gateway` benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong. - Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens. +- Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3. ### Fixes diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a26e7814b93c..5f6c3322865b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2757,6 +2757,7 @@ public struct ConfigSchemaResponse: Codable, Sendable { public struct ConfigSchemaLookupResult: Codable, Sendable { public let path: String public let schema: AnyCodable + public let reloadkind: AnyCodable? public let hint: [String: AnyCodable]? public let hintpath: String? public let children: [[String: AnyCodable]] @@ -2764,12 +2765,14 @@ public struct ConfigSchemaLookupResult: Codable, Sendable { public init( path: String, schema: AnyCodable, + reloadkind: AnyCodable?, hint: [String: AnyCodable]?, hintpath: String?, children: [[String: AnyCodable]]) { self.path = path self.schema = schema + self.reloadkind = reloadkind self.hint = hint self.hintpath = hintpath self.children = children @@ -2778,6 +2781,7 @@ public struct ConfigSchemaLookupResult: Codable, Sendable { private enum CodingKeys: String, CodingKey { case path case schema + case reloadkind = "reloadKind" case hint case hintpath = "hintPath" case children diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 0c146fec533e..15cc55ef2549 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -392,7 +392,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - `config.patch` merges a partial config update. - `config.apply` validates + replaces the full config payload. - `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists. - - `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, and immediate child summaries for UI/CLI drill-down. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, plus the matched `hint` / `hintPath`. + - `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`. - `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates from the control plane use a detached managed-service handoff instead of replacing the package tree inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel. - `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available. - `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC. diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 0402e9d816c8..050b42ee7d90 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -663,7 +663,28 @@ describe("config schema", () => { expect(schema?.properties).toBeUndefined(); }); - it("returns a shallow lookup schema with top-level composition for editing", () => { + it("includes reload metadata when a resolver is provided", () => { + const lookup = lookupConfigSchema(baseSchema, "gateway", (path) => { + if (path === "gateway.channelHealthCheckMinutes") { + return { kind: "hot" }; + } + if (path.startsWith("gateway")) { + return { kind: "restart" }; + } + return { kind: "none" }; + }); + + expect(lookup?.reloadKind).toBe("restart"); + expect( + lookup?.children.find((child) => child.path === "gateway.handshakeTimeoutMs")?.reloadKind, + ).toBe("restart"); + expect( + lookup?.children.find((child) => child.path === "gateway.channelHealthCheckMinutes") + ?.reloadKind, + ).toBe("hot"); + }); + + it("returns a shallow lookup schema without nested composition keywords", () => { const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime"); expect(lookup?.path).toBe("agents.list.0.runtime"); expect(lookup?.hintPath).toBe("agents.list[].runtime"); diff --git a/src/config/schema.ts b/src/config/schema.ts index 9461c4234a20..837849997656 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -112,13 +112,25 @@ export type ConfigSchemaLookupChild = { type?: string | string[]; required: boolean; hasChildren: boolean; + reloadKind?: ConfigSchemaReloadKind; hint?: ConfigUiHint; hintPath?: string; }; +export type ConfigSchemaReloadKind = "restart" | "hot" | "none"; + +export type ConfigSchemaReloadMetadata = { + kind: ConfigSchemaReloadKind; +}; + +export type ConfigSchemaReloadMetadataResolver = ( + path: string, +) => ConfigSchemaReloadMetadata | null | undefined; + export type ConfigSchemaLookupResult = { path: string; schema: JsonSchemaNode; + reloadKind?: ConfigSchemaReloadKind; hint?: ConfigUiHint; hintPath?: string; children: ConfigSchemaLookupChild[]; @@ -750,6 +762,7 @@ function buildLookupChildren( schema: JsonSchemaObject, path: string, uiHints: ConfigUiHints, + resolveReloadMetadata?: ConfigSchemaReloadMetadataResolver, ): ConfigSchemaLookupChild[] { const children: ConfigSchemaLookupChild[] = []; const required = new Set(schema.required ?? []); @@ -757,12 +770,14 @@ function buildLookupChildren( const pushChild = (key: string, childSchema: JsonSchemaObject, isRequired: boolean) => { const childPath = path ? `${path}.${key}` : key; const resolvedHint = resolveUiHintMatch(uiHints, childPath); + const reloadMetadata = resolveReloadMetadata?.(childPath); children.push({ key, path: childPath, type: childSchema.type, required: isRequired, hasChildren: schemaHasChildren(childSchema), + reloadKind: reloadMetadata?.kind, hint: resolvedHint?.hint, hintPath: resolvedHint?.path, }); @@ -788,6 +803,7 @@ function buildLookupChildren( export function lookupConfigSchema( response: ConfigSchemaResponse, path: string, + resolveReloadMetadata?: ConfigSchemaReloadMetadataResolver, ): ConfigSchemaLookupResult | null { const wantsRoot = path.trim() === "."; const normalizedPath = normalizeLookupPath(path); @@ -812,11 +828,18 @@ export function lookupConfigSchema( } const resolvedHint = resolveUiHintMatch(response.uiHints, normalizedPath); + const reloadMetadata = resolveReloadMetadata?.(normalizedPath); return { path: wantsRoot ? "." : normalizedPath, schema: stripSchemaForLookup(current), + reloadKind: reloadMetadata?.kind, hint: resolvedHint?.hint, hintPath: resolvedHint?.path, - children: buildLookupChildren(current, wantsRoot ? "" : normalizedPath, response.uiHints), + children: buildLookupChildren( + current, + wantsRoot ? "" : normalizedPath, + response.uiHints, + resolveReloadMetadata, + ), }; } diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 41f3eef210a7..27e885f33d89 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -30,6 +30,10 @@ type ReloadRule = { actions?: ReloadAction[]; }; +export type ConfigReloadMetadata = { + kind: ReloadRule["kind"]; +}; + type ReloadAction = | "reload-hooks" | "restart-gmail-watcher" @@ -222,6 +226,13 @@ function matchRule(path: string): ReloadRule | null { return null; } +export function resolveConfigReloadMetadata(path: string): ConfigReloadMetadata { + if (isPluginInstallTimestampPath(path)) { + return { kind: "none" }; + } + return { kind: matchRule(path)?.kind ?? "restart" }; +} + function isPluginInstallTimestampPath(path: string): boolean { // Legacy compatibility only: new plugin install metadata lives in the // managed plugin index, but old config writes may still touch this path. diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 54791adb7770..13430b177173 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -24,6 +24,7 @@ import { type GatewayReloadPlan, listPluginInstallTimestampMetadataPaths, listPluginInstallWholeRecordPaths, + resolveConfigReloadMetadata, resolveGatewayReloadSettings, shouldInvalidateSkillsSnapshotForPaths, startGatewayConfigReloader, @@ -298,6 +299,12 @@ describe("buildGatewayReloadPlan", () => { "plugins.installs.lossless-claw.resolvedAt", "plugins.installs.lossless-claw.installedAt", ]); + expect(resolveConfigReloadMetadata("plugins.installs.lossless-claw.resolvedAt").kind).toBe( + "none", + ); + expect(resolveConfigReloadMetadata("plugins.installs.lossless-claw.installedAt").kind).toBe( + "none", + ); }); it("restarts for whole-record plugin install changes", () => { diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index f93700b7229c..e844bed9d827 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -14,6 +14,7 @@ import { buildGatewayReloadPlan, listPluginInstallTimestampMetadataPaths, listPluginInstallWholeRecordPaths, + resolveConfigReloadMetadata, type GatewayReloadPlan, } from "./config-reload-plan.js"; import { resolveGatewayReloadSettings } from "./config-reload-settings.js"; @@ -23,6 +24,7 @@ export { diffConfigPaths, listPluginInstallTimestampMetadataPaths, listPluginInstallWholeRecordPaths, + resolveConfigReloadMetadata, resolveGatewayReloadSettings, }; export type { ChannelKind, GatewayReloadPlan } from "./config-reload-plan.js"; diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index 554cb710d696..1fad7df920e3 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -97,6 +97,9 @@ export const ConfigSchemaLookupChildSchema = Type.Object( type: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])), required: Type.Boolean(), hasChildren: Type.Boolean(), + reloadKind: Type.Optional( + Type.Union([Type.Literal("restart"), Type.Literal("hot"), Type.Literal("none")]), + ), hint: Type.Optional(ConfigUiHintSchema), hintPath: Type.Optional(Type.String()), }, @@ -107,6 +110,9 @@ export const ConfigSchemaLookupResultSchema = Type.Object( { path: NonEmptyString, schema: Type.Unknown(), + reloadKind: Type.Optional( + Type.Union([Type.Literal("restart"), Type.Literal("hot"), Type.Literal("none")]), + ), hint: Type.Optional(ConfigUiHintSchema), hintPath: Type.Optional(Type.String()), children: Type.Array(ConfigSchemaLookupChildSchema), diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 7af7324d9613..1af90132f663 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -23,6 +23,7 @@ import { type PreparedSecretsRuntimeSnapshot, } from "../../secrets/runtime.js"; import { diffConfigPaths } from "../config-diff.js"; +import { resolveConfigReloadMetadata } from "../config-reload-plan.js"; import { formatControlPlaneActor, resolveControlPlaneActor, @@ -313,7 +314,7 @@ export const configHandlers: GatewayRequestHandlers = { } const path = (params as { path: string }).path; const schema = loadSchemaWithPlugins(); - const result = lookupConfigSchema(schema, path); + const result = lookupConfigSchema(schema, path, resolveConfigReloadMetadata); if (!result) { respond( false,