fix(presence): stabilize instance identity
This commit is contained in:
@@ -158,18 +158,19 @@ actor GatewayChannelActor {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientName = InstanceIdentity.displayName
|
||||
|
||||
let hello = Hello(
|
||||
type: "hello",
|
||||
minprotocol: GATEWAY_PROTOCOL_VERSION,
|
||||
maxprotocol: GATEWAY_PROTOCOL_VERSION,
|
||||
client: [
|
||||
"name": ClawdisProtocol.AnyCodable("clawdis-mac"),
|
||||
"name": ClawdisProtocol.AnyCodable(clientName),
|
||||
"version": ClawdisProtocol.AnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ClawdisProtocol.AnyCodable(platform),
|
||||
"mode": ClawdisProtocol.AnyCodable("app"),
|
||||
"instanceId": ClawdisProtocol.AnyCodable(Host.current().localizedName ?? UUID().uuidString),
|
||||
"instanceId": ClawdisProtocol.AnyCodable(InstanceIdentity.instanceId),
|
||||
],
|
||||
caps: [],
|
||||
auth: self.token.map { ["token": ClawdisProtocol.AnyCodable($0)] },
|
||||
|
||||
22
apps/macos/Sources/Clawdis/InstanceIdentity.swift
Normal file
22
apps/macos/Sources/Clawdis/InstanceIdentity.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
enum InstanceIdentity {
|
||||
static let instanceId: String = {
|
||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty
|
||||
{
|
||||
return name
|
||||
}
|
||||
return UUID().uuidString
|
||||
}()
|
||||
|
||||
static let displayName: String = {
|
||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty
|
||||
{
|
||||
return name
|
||||
}
|
||||
return "clawdis-mac"
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ final class InstancesStore: ObservableObject {
|
||||
|
||||
private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] {
|
||||
entries.map { entry -> InstanceInfo in
|
||||
let key = entry.host ?? entry.ip ?? entry.text ?? entry.instanceid ?? "entry-\(entry.ts)"
|
||||
let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)"
|
||||
return InstanceInfo(
|
||||
id: key,
|
||||
host: entry.host,
|
||||
|
||||
@@ -10,7 +10,7 @@ final class PresenceReporter {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "presence")
|
||||
private var task: Task<Void, Never>?
|
||||
private let interval: TimeInterval = 180 // a few minutes
|
||||
private let instanceId: String = Host.current().localizedName ?? UUID().uuidString
|
||||
private let instanceId: String = InstanceIdentity.instanceId
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
@@ -32,7 +32,7 @@ final class PresenceReporter {
|
||||
@Sendable
|
||||
private func push(reason: String) async {
|
||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue }
|
||||
let host = Host.current().localizedName ?? "unknown-host"
|
||||
let host = InstanceIdentity.displayName
|
||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||
let version = Self.appVersionString()
|
||||
let lastInput = Self.lastInputSeconds()
|
||||
@@ -59,7 +59,7 @@ final class PresenceReporter {
|
||||
}
|
||||
|
||||
private static func composePresenceSummary(mode: String, reason: String) -> String {
|
||||
let host = Host.current().localizedName ?? "unknown-host"
|
||||
let host = InstanceIdentity.displayName
|
||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||
let version = Self.appVersionString()
|
||||
let lastInput = Self.lastInputSeconds()
|
||||
|
||||
@@ -78,6 +78,15 @@ export type GatewayServer = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) return false;
|
||||
if (ip === "127.0.0.1") return true;
|
||||
if (ip.startsWith("127.")) return true;
|
||||
if (ip === "::1") return true;
|
||||
if (ip.startsWith("::ffff:127.")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let presenceVersion = 1;
|
||||
let healthVersion = 1;
|
||||
let seq = 0;
|
||||
@@ -648,7 +657,7 @@ export async function startGatewayServer(
|
||||
}
|
||||
upsertPresence(presenceKey, {
|
||||
host: hello.client.name || os.hostname(),
|
||||
ip: remoteAddr,
|
||||
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
|
||||
version: hello.client.version,
|
||||
mode: hello.client.mode,
|
||||
instanceId: hello.client.instanceId,
|
||||
|
||||
40
src/infra/system-presence.test.ts
Normal file
40
src/infra/system-presence.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listSystemPresence,
|
||||
updateSystemPresence,
|
||||
upsertPresence,
|
||||
} from "./system-presence.js";
|
||||
|
||||
describe("system-presence", () => {
|
||||
it("dedupes entries across sources by case-insensitive instanceId key", () => {
|
||||
const instanceIdUpper = `AaBb-${randomUUID()}`.toUpperCase();
|
||||
const instanceIdLower = instanceIdUpper.toLowerCase();
|
||||
|
||||
upsertPresence(instanceIdUpper, {
|
||||
host: "clawdis-mac",
|
||||
mode: "app",
|
||||
instanceId: instanceIdUpper,
|
||||
reason: "connect",
|
||||
});
|
||||
|
||||
updateSystemPresence({
|
||||
text: "Node: Peter-Mac-Studio (10.0.0.1) · app 2.0.0 · last input 5s ago · mode app · reason beacon",
|
||||
instanceId: instanceIdLower,
|
||||
host: "Peter-Mac-Studio",
|
||||
ip: "10.0.0.1",
|
||||
mode: "app",
|
||||
version: "2.0.0",
|
||||
lastInputSeconds: 5,
|
||||
reason: "beacon",
|
||||
});
|
||||
|
||||
const matches = listSystemPresence().filter(
|
||||
(e) => (e.instanceId ?? "").toLowerCase() === instanceIdLower,
|
||||
);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]?.host).toBe("Peter-Mac-Studio");
|
||||
expect(matches[0]?.ip).toBe("10.0.0.1");
|
||||
expect(matches[0]?.lastInputSeconds).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,13 @@ const entries = new Map<string, SystemPresence>();
|
||||
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
function normalizePresenceKey(key: string | undefined): string | undefined {
|
||||
if (!key) return undefined;
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function resolvePrimaryIPv4(): string | undefined {
|
||||
const nets = os.networkInterfaces();
|
||||
const prefer = ["en0", "eth0"];
|
||||
@@ -116,9 +123,9 @@ export function updateSystemPresence(payload: SystemPresencePayload) {
|
||||
ensureSelfPresence();
|
||||
const parsed = parsePresence(payload.text);
|
||||
const key =
|
||||
payload.instanceId?.toLowerCase() ||
|
||||
parsed.instanceId?.toLowerCase() ||
|
||||
parsed.host?.toLowerCase() ||
|
||||
normalizePresenceKey(payload.instanceId) ||
|
||||
normalizePresenceKey(parsed.instanceId) ||
|
||||
normalizePresenceKey(parsed.host) ||
|
||||
parsed.ip ||
|
||||
parsed.text.slice(0, 64) ||
|
||||
os.hostname().toLowerCase();
|
||||
@@ -144,7 +151,9 @@ export function updateSystemPresence(payload: SystemPresencePayload) {
|
||||
|
||||
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
|
||||
ensureSelfPresence();
|
||||
const existing = entries.get(key) ?? ({} as SystemPresence);
|
||||
const normalizedKey =
|
||||
normalizePresenceKey(key) ?? os.hostname().toLowerCase();
|
||||
const existing = entries.get(normalizedKey) ?? ({} as SystemPresence);
|
||||
const merged: SystemPresence = {
|
||||
...existing,
|
||||
...presence,
|
||||
@@ -156,7 +165,7 @@ export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
|
||||
presence.mode ?? existing.mode ?? "unknown"
|
||||
}`,
|
||||
};
|
||||
entries.set(key, merged);
|
||||
entries.set(normalizedKey, merged);
|
||||
}
|
||||
|
||||
export function listSystemPresence(): SystemPresence[] {
|
||||
|
||||
Reference in New Issue
Block a user