mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 09:01:39 -06:00
1f0a3ecc68
* 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
313 lines
12 KiB
TypeScript
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);
|
|
},
|
|
};
|