fix: emit delta-only node system events

This commit is contained in:
Peter Steinberger
2025-12-21 00:55:54 +01:00
parent 2b2f13ca79
commit 383097a03a
3 changed files with 107 additions and 15 deletions

View File

@@ -74,7 +74,10 @@ import {
verifyNodeToken,
} from "../infra/node-pairing.js";
import { ensureClawdisCliOnPath } from "../infra/path-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
enqueueSystemEvent,
isSystemEventContextChanged,
} from "../infra/system-events.js";
import {
listSystemPresence,
updateSystemPresence,
@@ -4014,7 +4017,7 @@ export async function startGatewayServer(
params.tags.every((t) => typeof t === "string")
? (params.tags as string[])
: undefined;
updateSystemPresence({
const presenceUpdate = updateSystemPresence({
text,
instanceId,
host,
@@ -4029,17 +4032,55 @@ export async function startGatewayServer(
tags,
});
const isNodePresenceLine = text.startsWith("Node:");
const normalizedReason = (reason ?? "").toLowerCase();
const looksPeriodic =
normalizedReason.startsWith("periodic") ||
normalizedReason === "heartbeat";
if (!(isNodePresenceLine && looksPeriodic)) {
const compactNodeText =
isNodePresenceLine &&
(host || ip || version || mode || reason)
? `Node: ${host?.trim() || "Unknown"}${ip ? ` (${ip})` : ""} · app ${version?.trim() || "unknown"} · mode ${mode?.trim() || "unknown"} · reason ${reason?.trim() || "event"}`
: text;
enqueueSystemEvent(compactNodeText);
if (isNodePresenceLine) {
const next = presenceUpdate.next;
const changed = new Set(presenceUpdate.changedKeys);
const reasonValue = next.reason ?? reason;
const normalizedReason = (reasonValue ?? "").toLowerCase();
const ignoreReason =
normalizedReason.startsWith("periodic") ||
normalizedReason === "heartbeat";
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(
presenceUpdate.key,
);
const parts: string[] = [];
if (contextChanged || hostChanged || ipChanged) {
const hostLabel = next.host?.trim() || "Unknown";
const ipLabel = next.ip?.trim();
parts.push(
`Node: ${hostLabel}${ipLabel ? ` (${ipLabel})` : ""}`,
);
}
if (versionChanged) {
parts.push(`app ${next.version?.trim() || "unknown"}`);
}
if (modeChanged) {
parts.push(`mode ${next.mode?.trim() || "unknown"}`);
}
if (reasonChanged) {
parts.push(`reason ${reasonValue?.trim() || "event"}`);
}
const deltaText = parts.join(" · ");
if (deltaText) {
enqueueSystemEvent(deltaText, {
contextKey: presenceUpdate.key,
});
}
}
} else {
enqueueSystemEvent(text);
}
presenceVersion += 1;
broadcast(

View File

@@ -7,10 +7,30 @@ type SystemEvent = { text: string; ts: number };
const MAX_EVENTS = 20;
const queue: SystemEvent[] = [];
let lastText: string | null = null;
let lastContextKey: string | null = null;
export function enqueueSystemEvent(text: string) {
type SystemEventOptions = {
contextKey?: string | null;
};
function normalizeContextKey(key?: string | null): string | null {
if (!key) return null;
const trimmed = key.trim();
if (!trimmed) return null;
return trimmed.toLowerCase();
}
export function isSystemEventContextChanged(
contextKey?: string | null,
): boolean {
const normalized = normalizeContextKey(contextKey);
return normalized !== lastContextKey;
}
export function enqueueSystemEvent(text: string, options?: SystemEventOptions) {
const cleaned = text.trim();
if (!cleaned) return;
lastContextKey = normalizeContextKey(options?.contextKey);
if (lastText === cleaned) return; // skip consecutive duplicates
lastText = cleaned;
queue.push({ text: cleaned, ts: Date.now() });
@@ -21,6 +41,7 @@ export function drainSystemEvents(): string[] {
const out = queue.map((e) => e.text);
queue.length = 0;
lastText = null;
lastContextKey = null;
return out;
}

View File

@@ -16,6 +16,14 @@ export type SystemPresence = {
ts: number;
};
export type SystemPresenceUpdate = {
key: string;
previous?: SystemPresence;
next: SystemPresence;
changes: Partial<SystemPresence>;
changedKeys: (keyof SystemPresence)[];
};
const entries = new Map<string, SystemPresence>();
const TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_ENTRIES = 200;
@@ -154,7 +162,9 @@ type SystemPresencePayload = {
tags?: string[];
};
export function updateSystemPresence(payload: SystemPresencePayload) {
export function updateSystemPresence(
payload: SystemPresencePayload,
): SystemPresenceUpdate {
ensureSelfPresence();
const parsed = parsePresence(payload.text);
const key =
@@ -164,6 +174,7 @@ export function updateSystemPresence(payload: SystemPresencePayload) {
parsed.ip ||
parsed.text.slice(0, 64) ||
os.hostname().toLowerCase();
const hadExisting = entries.has(key);
const existing = entries.get(key) ?? ({} as SystemPresence);
const merged: SystemPresence = {
...existing,
@@ -185,6 +196,25 @@ export function updateSystemPresence(payload: SystemPresencePayload) {
ts: Date.now(),
};
entries.set(key, merged);
const trackKeys = ["host", "ip", "version", "mode", "reason"] as const;
type TrackKey = (typeof trackKeys)[number];
const changes: Partial<Pick<SystemPresence, TrackKey>> = {};
const changedKeys: TrackKey[] = [];
for (const k of trackKeys) {
const prev = existing[k];
const next = merged[k];
if (prev !== next) {
changes[k] = next;
changedKeys.push(k);
}
}
return {
key,
previous: hadExisting ? existing : undefined,
next: merged,
changes,
changedKeys,
} satisfies SystemPresenceUpdate;
}
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {