refactor: simplify HQ architecture with active health checking

- Remove heartbeat mechanism from HQClient - now just registers and forgets
- Update RemoteRegistry to actively check remote health via GET /api/sessions
- Remove /api/remotes/:remoteId/heartbeat endpoint as it's no longer needed
- Pass password to RemoteRegistry for authenticated health checks
- Remote servers no longer need to maintain connection to HQ after registration
- HQ checks remote health every 15 seconds with 5 second timeout

This simplifies the architecture - remotes just tell HQ they exist, and HQ
is responsible for monitoring their health status.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-20 11:05:19 +02:00
parent 42c1dff89a
commit c9a08a415a
3 changed files with 65 additions and 138 deletions

View file

@ -5,26 +5,18 @@ export class HQClient {
private readonly hqUrl: string;
private readonly remoteId: string;
private readonly remoteName: string;
private token: string;
private heartbeatInterval: NodeJS.Timeout | null = null;
private readonly token: string;
private readonly password: string;
private registrationRetryTimeout: NodeJS.Timeout | null = null;
constructor(hqUrl: string, password: string) {
this.hqUrl = hqUrl;
this.remoteId = uuidv4();
this.remoteName = `${os.hostname()}-${process.pid}`;
this.token = this.generateToken();
// Store password for future use
this.token = uuidv4();
this.password = password;
}
private password: string;
private generateToken(): string {
return uuidv4();
}
async register(): Promise<void> {
try {
const response = await fetch(`${this.hqUrl}/api/remotes/register`, {
@ -46,7 +38,8 @@ export class HQClient {
}
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
this.startHeartbeat();
console.log(`Remote ID: ${this.remoteId}`);
console.log(`Token: ${this.token}`);
} catch (error) {
console.error('Failed to register with HQ:', error);
// Retry registration after 5 seconds
@ -54,70 +47,7 @@ export class HQClient {
}
}
private async sendHeartbeat(): Promise<void> {
try {
// Get session count from the session manager
const sessionCount = await this.getSessionCount();
const response = await fetch(`${this.hqUrl}/api/remotes/${this.remoteId}/heartbeat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.token}`,
},
body: JSON.stringify({
sessionCount,
}),
});
if (!response.ok) {
console.error('Heartbeat failed:', response.statusText);
// Re-register if heartbeat fails
this.stopHeartbeat();
await this.register();
}
} catch (error) {
console.error('Failed to send heartbeat:', error);
}
}
private startHeartbeat(): void {
// Send heartbeat every 15 seconds
this.heartbeatInterval = setInterval(() => {
this.sendHeartbeat();
}, 15000);
// Send first heartbeat immediately
this.sendHeartbeat();
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private async getSessionCount(): Promise<number> {
try {
const response = await fetch(`http://localhost:${process.env.PORT || 4020}/api/sessions`, {
headers: {
Authorization: `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`,
},
});
if (response.ok) {
const sessions = await response.json();
return Array.isArray(sessions) ? sessions.length : 0;
}
} catch {
// Ignore errors
}
return 0;
}
destroy(): void {
this.stopHeartbeat();
if (this.registrationRetryTimeout) {
clearTimeout(this.registrationRetryTimeout);
}

View file

@ -11,11 +11,14 @@ export interface RemoteServer {
export class RemoteRegistry {
private remotes: Map<string, RemoteServer> = new Map();
private heartbeatInterval: NodeJS.Timeout | null = null;
private readonly HEARTBEAT_TIMEOUT = 30000; // 30 seconds
private healthCheckInterval: NodeJS.Timeout | null = null;
private readonly HEALTH_CHECK_INTERVAL = 15000; // Check every 15 seconds
private readonly HEALTH_CHECK_TIMEOUT = 5000; // 5 second timeout per check
private password: string | null;
constructor() {
this.startHeartbeatChecker();
constructor(password: string | null = null) {
this.password = password;
this.startHealthChecker();
}
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
@ -30,6 +33,9 @@ export class RemoteRegistry {
this.remotes.set(remote.id, registeredRemote);
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
// Immediately check health of new remote
this.checkRemoteHealth(registeredRemote);
return registeredRemote;
}
@ -42,17 +48,6 @@ export class RemoteRegistry {
return false;
}
updateHeartbeat(remoteId: string, sessionCount: number): boolean {
const remote = this.remotes.get(remoteId);
if (remote) {
remote.lastHeartbeat = new Date();
remote.sessionCount = sessionCount;
remote.status = 'online';
return true;
}
return false;
}
getRemote(remoteId: string): RemoteServer | undefined {
return this.remotes.get(remoteId);
}
@ -69,24 +64,60 @@ export class RemoteRegistry {
return this.getAllRemotes().filter((r) => r.status === 'online');
}
private startHeartbeatChecker() {
this.heartbeatInterval = setInterval(() => {
const now = Date.now();
private async checkRemoteHealth(remote: RemoteServer): Promise<void> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.HEALTH_CHECK_TIMEOUT);
for (const remote of this.remotes.values()) {
const timeSinceLastHeartbeat = now - remote.lastHeartbeat.getTime();
if (timeSinceLastHeartbeat > this.HEARTBEAT_TIMEOUT && remote.status === 'online') {
remote.status = 'offline';
console.log(`Remote went offline: ${remote.name} (${remote.id})`);
}
const headers: Record<string, string> = {};
if (this.password) {
headers['Authorization'] =
`Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`;
}
}, 10000); // Check every 10 seconds
const response = await fetch(`${remote.url}/api/sessions`, {
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const sessions = await response.json();
remote.lastHeartbeat = new Date();
remote.sessionCount = Array.isArray(sessions) ? sessions.length : 0;
if (remote.status !== 'online') {
remote.status = 'online';
console.log(`Remote came online: ${remote.name} (${remote.id})`);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
if (remote.status !== 'offline') {
remote.status = 'offline';
console.log(`Remote went offline: ${remote.name} (${remote.id}) - ${error}`);
}
}
}
private startHealthChecker() {
this.healthCheckInterval = setInterval(() => {
// Check all remotes in parallel
const healthChecks = Array.from(this.remotes.values()).map((remote) =>
this.checkRemoteHealth(remote)
);
Promise.all(healthChecks).catch((err) => {
console.error('Error in health checks:', err);
});
}, this.HEALTH_CHECK_INTERVAL);
}
destroy() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
}
}

View file

@ -114,7 +114,7 @@ let remoteRegistry: RemoteRegistry | null = null;
let hqClient: HQClient | null = null;
if (isHQMode) {
remoteRegistry = new RemoteRegistry();
remoteRegistry = new RemoteRegistry(basicAuthPassword);
console.log(`${GREEN}Running in HQ mode${RESET}`);
}
@ -448,40 +448,6 @@ app.post('/api/remotes/register', (req, res) => {
res.json({ success: true, remote: { id: remote.id, name: remote.name } });
});
// Heartbeat from remote server
app.post('/api/remotes/:remoteId/heartbeat', (req, res) => {
if (!isHQMode || !remoteRegistry) {
return res.status(404).json({ error: 'HQ mode not enabled' });
}
const { remoteId } = req.params;
const { sessionCount } = req.body;
const authHeader = req.headers.authorization;
// Verify token
const remote = remoteRegistry.getRemote(remoteId);
if (!remote) {
return res.status(404).json({ error: 'Remote not found' });
}
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization' });
}
const token = authHeader.slice(7);
if (token !== remote.token) {
return res.status(401).json({ error: 'Invalid token' });
}
const success = remoteRegistry.updateHeartbeat(remoteId, sessionCount || 0);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Remote not found' });
}
});
// Unregister remote
app.delete('/api/remotes/:remoteId', (req, res) => {
if (!isHQMode || !remoteRegistry) {