From 383097a03a74b2fe8bc4ef2644ccd5443ab87990 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 21 Dec 2025 00:55:54 +0100 Subject: [PATCH] fix: emit delta-only node system events --- src/gateway/server.ts | 67 +++++++++++++++++++++++++++++------- src/infra/system-events.ts | 23 ++++++++++++- src/infra/system-presence.ts | 32 ++++++++++++++++- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 34fe50fd0..54a4dbaf9 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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( diff --git a/src/infra/system-events.ts b/src/infra/system-events.ts index 652333f91..a2eef7d75 100644 --- a/src/infra/system-events.ts +++ b/src/infra/system-events.ts @@ -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; } diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index cb24ad5af..aa1713623 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -16,6 +16,14 @@ export type SystemPresence = { ts: number; }; +export type SystemPresenceUpdate = { + key: string; + previous?: SystemPresence; + next: SystemPresence; + changes: Partial; + changedKeys: (keyof SystemPresence)[]; +}; + const entries = new Map(); 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> = {}; + 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) {