Files
openclaw/src/gateway/server-methods/system.ts
T
Peter Steinberger 1f0a3ecc68 feat(ui): session observer HUD, sidebar subtitles, and settings (#112260)
* feat(ui): session observer HUD, subtitle integration, and settings

* test(ui): observer demo fixtures for the mock control-ui harness

* fix(ui): satisfy lint and deadcode gates for observer surfaces

* test(ui): adopt renamed pull-request summary api after rebase

* fix(ui): clean rebase artifacts in observer test files
2026-07-21 17:05:14 -07:00

313 lines
12 KiB
TypeScript

// System gateway methods expose device and host identity, heartbeat controls,
// presence snapshots, and normalized system events.
import os from "node:os";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
type SystemInfoResult,
validateSystemInfoParams,
} from "../../../packages/gateway-protocol/src/index.js";
import {
SYSTEM_PRESENCE_CLEAR_LAST_INPUT_TAG,
validateSystemEventParams,
} from "../../../packages/gateway-protocol/src/schema.js";
import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
readUtilityModelSetting,
resolveUtilityModelRefForAgent,
} from "../../agents/utility-model.js";
import { resolveGatewayPort, resolveStateDir } from "../../config/paths.js";
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
import { resolveAdvertisedLanHost } from "../../infra/advertised-lan-host.js";
import {
loadOrCreateProcessDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../../infra/device-identity.js";
import { tryReadDiskSpace } from "../../infra/disk-space.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import { requestHeartbeat } from "../../infra/heartbeat-wake.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { resolveRuntimeOsLabel } from "../../infra/os-summary.js";
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
import { listSystemPresence, updateSystemPresence } from "../../infra/system-presence.js";
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { getGatewayProcessInstanceId } from "../process-instance.js";
import { broadcastPresenceSnapshot } from "../server/presence-events.js";
import { loadGatewaySessionRow } from "../session-utils.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
let advertisedLanHostPromise: Promise<string | null> | null = null;
function resolveCachedAdvertisedLanHost(): Promise<string | null> {
// Route discovery may spawn a platform command. Keep the result process-stable
// so each visible Settings page does not repeat that work every ten seconds.
advertisedLanHostPromise ??= resolveAdvertisedLanHost().catch(() => null);
return advertisedLanHostPromise;
}
async function collectSystemInfo(context: GatewayRequestContext): Promise<SystemInfoResult> {
const cpus = os.cpus();
const cpuModel = cpus[0]?.model.trim() || undefined;
const [oneMinute = 0, fiveMinutes = 0, fifteenMinutes = 0] = os.loadavg();
const loadAverage: [number, number, number] = [oneMinute, fiveMinutes, fifteenMinutes];
const stateDir = resolveStateDir();
const disk = tryReadDiskSpace(stateDir);
const config = context.getRuntimeConfig();
const port = resolveGatewayPort(config);
const lanAddress = (await resolveCachedAdvertisedLanHost()) ?? undefined;
const defaultAgentId = resolveDefaultAgentId(config);
const utilitySetting = readUtilityModelSetting(config, defaultAgentId);
const utilityModel = resolveUtilityModelRefForAgent({ cfg: config, agentId: defaultAgentId });
const defaultAgentUtilityModel =
utilitySetting.kind === "disabled"
? ({ status: "disabled" } as const)
: utilitySetting.kind === "explicit"
? ({ status: "configured", model: utilitySetting.modelRef } as const)
: utilityModel
? ({ status: "auto", model: utilityModel } as const)
: ({ status: "unavailable" } as const);
return {
machineName: await getMachineDisplayName(),
hostname: os.hostname(),
platform: os.platform(),
release: os.release(),
arch: os.arch(),
osLabel: resolveRuntimeOsLabel(),
...(lanAddress ? { lanAddress } : {}),
port,
nodeVersion: process.version,
pid: process.pid,
processInstanceId: getGatewayProcessInstanceId(),
uptimeMs: Math.round(process.uptime() * 1000),
cpuCount: cpus.length,
...(cpuModel ? { cpuModel } : {}),
...(loadAverage.some((value) => value !== 0) ? { loadAverage } : {}),
memoryTotalBytes: os.totalmem(),
memoryFreeBytes: os.freemem(),
...(disk?.totalBytes != null
? {
diskTotalBytes: disk.totalBytes,
diskAvailableBytes: disk.availableBytes,
diskPath: stateDir,
}
: {}),
defaultAgentUtilityModel,
};
}
/** Gateway handlers for identity, host information, heartbeat toggles, and presence events. */
export const systemHandlers: GatewayRequestHandlers = {
"gateway.identity.get": ({ respond }) => {
const identity = loadOrCreateProcessDeviceIdentity();
respond(
true,
{
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
},
undefined,
);
},
"last-heartbeat": ({ respond }) => {
respond(true, getLastHeartbeatEvent(), undefined);
},
"set-heartbeats": ({ params, respond }) => {
const enabled = params.enabled;
if (typeof enabled !== "boolean") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid set-heartbeats params: enabled (boolean) required",
),
);
return;
}
setHeartbeatsEnabled(enabled);
respond(true, { ok: true, enabled }, undefined);
},
"system-presence": ({ respond }) => {
const presence = listSystemPresence();
respond(true, presence, undefined);
},
"system.info": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateSystemInfoParams, "system.info", respond)) {
return;
}
respond(true, await collectSystemInfo(context), undefined);
},
"system-event": ({ params, respond, context }) => {
if (!assertValidParams(params, validateSystemEventParams, "system-event", respond)) {
return;
}
// `system-event` is operator.admin-only; role policy rejects node connections before dispatch.
// Payload classification below selects behavior and is never an authorization boundary.
const text = normalizeOptionalString(params.text) ?? "";
if (!text) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
return;
}
const requestedSessionKey = normalizeOptionalString(params.sessionKey);
const sessionKey = requestedSessionKey ?? resolveMainSessionKeyFromConfig();
const wake = params.wake === true;
const isNodePresenceLine = text.startsWith("Node:");
if (wake && isNodePresenceLine) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wake is not supported for node presence events"),
);
return;
}
if (wake && requestedSessionKey) {
const targetAgentId = normalizeAgentId(resolveAgentIdFromSessionKey(requestedSessionKey));
const configuredAgentIds = listAgentIds(context.getRuntimeConfig()).map(normalizeAgentId);
if (!configuredAgentIds.includes(targetAgentId)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${targetAgentId}"`),
);
return;
}
// A targeted wake starts a model run. Require a live persisted session
// so malformed keys cannot create phantom work under agent defaults.
const targetSession = loadGatewaySessionRow(requestedSessionKey, { agentId: targetAgentId });
if (!targetSession || targetSession.archived) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Unknown or archived session "${requestedSessionKey}"`,
),
);
return;
}
}
const deviceId = readStringValue(params.deviceId);
const instanceId = readStringValue(params.instanceId);
const host = readStringValue(params.host);
const ip = readStringValue(params.ip);
const mode = readStringValue(params.mode);
const version = readStringValue(params.version);
const platform = readStringValue(params.platform);
const deviceFamily = readStringValue(params.deviceFamily);
const modelIdentifier = readStringValue(params.modelIdentifier);
const reason = readStringValue(params.reason);
const roles =
Array.isArray(params.roles) && params.roles.every((t) => typeof t === "string")
? params.roles
: undefined;
const scopes =
Array.isArray(params.scopes) && params.scopes.every((t) => typeof t === "string")
? params.scopes
: undefined;
const tags =
Array.isArray(params.tags) && params.tags.every((t) => typeof t === "string")
? params.tags
: undefined;
const lastInputSeconds = tags?.includes(SYSTEM_PRESENCE_CLEAR_LAST_INPUT_TAG)
? null
: typeof params.lastInputSeconds === "number" && Number.isFinite(params.lastInputSeconds)
? params.lastInputSeconds
: undefined;
const presenceUpdate = updateSystemPresence({
text,
deviceId,
instanceId,
host,
ip,
mode,
version,
platform,
deviceFamily,
modelIdentifier,
lastInputSeconds,
reason,
roles,
scopes,
tags,
});
if (isNodePresenceLine) {
// Node presence heartbeats are noisy; only enqueue user-visible system
// events when routing context or meaningful node metadata changes.
const next = presenceUpdate.next;
const changed = new Set(presenceUpdate.changedKeys);
const reasonValue = next.reason ?? reason;
const normalizedReason = normalizeLowercaseStringOrEmpty(reasonValue);
const ignoreReason =
normalizedReason.startsWith("periodic") ||
normalizedReason === "heartbeat" ||
normalizedReason === "connect" ||
normalizedReason === "launch" ||
normalizedReason === "instances-refresh";
const hostChanged = changed.has("host");
const ipChanged = changed.has("ip");
const versionChanged = changed.has("version");
const modeChanged = changed.has("mode");
const reasonChanged = changed.has("reason") && !ignoreReason;
const hasChanges = hostChanged || ipChanged || versionChanged || modeChanged || reasonChanged;
if (hasChanges) {
const contextChanged = isSystemEventContextChanged(sessionKey, presenceUpdate.key);
const parts: string[] = [];
// Re-state node identity only when the line would otherwise lose
// routing context or the host/IP changed.
if (contextChanged || hostChanged || ipChanged) {
const hostLabel = normalizeOptionalString(next.host) ?? "Unknown";
const ipLabel = normalizeOptionalString(next.ip);
parts.push(`Node: ${hostLabel}${ipLabel ? ` (${ipLabel})` : ""}`);
}
if (versionChanged) {
parts.push(`app ${normalizeOptionalString(next.version) ?? "unknown"}`);
}
if (modeChanged) {
parts.push(`mode ${normalizeOptionalString(next.mode) ?? "unknown"}`);
}
if (reasonChanged) {
parts.push(`reason ${normalizeOptionalString(reasonValue) ?? "event"}`);
}
const deltaText = parts.join(" · ");
if (deltaText) {
enqueueSystemEvent(deltaText, {
sessionKey,
contextKey: presenceUpdate.key,
});
}
}
} else {
enqueueSystemEvent(text, { sessionKey });
if (wake) {
// Targeted admin events may need a proactive response. Carry the exact
// session through the wake so its delivery context, not main, wins.
requestHeartbeat({
source: "notifications-event",
intent: "immediate",
// The dispatcher recognizes "wake" as a payload-bearing run, so an
// empty HEARTBEAT.md cannot suppress this queued system event.
reason: "wake",
sessionKey,
heartbeat: { target: "last" },
});
}
}
// Presence changes are observable even when noisy node heartbeat text is
// suppressed from the transcript-style system event queue.
broadcastPresenceSnapshot({
broadcast: context.broadcast,
incrementPresenceVersion: context.incrementPresenceVersion,
getHealthVersion: context.getHealthVersion,
});
respond(true, { ok: true }, undefined);
},
};