Phase3
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:17:26 +02:00
parent c4077d4673
commit 9b061c3ee7
92 changed files with 4128 additions and 146 deletions

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
import { NodeBridgeService } from './node-bridge.service';
@Module({
imports: [forwardRef(() => ServersModule)],
providers: [NodeBridgeService],
exports: [NodeBridgeService],
})
export class NodeBridgeModule {}

View File

@@ -0,0 +1,85 @@
import { randomUUID } from 'node:crypto';
import {
GatewayTimeoutException,
Inject,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
export interface AgentResponse {
success: boolean;
result?: unknown;
resultCode?: string;
errorCode?: string;
errorMessage?: string;
}
@Injectable()
export class NodeBridgeService {
constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis,
private readonly serversService: ServersService,
) {}
async sendAgentRequest(
nodeId: string,
type: string,
payload: Record<string, unknown>,
timeoutMs = 30_000,
): Promise<AgentResponse> {
const messageId = randomUUID();
const config = getConfig();
const subscriber = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
const responseChannel = `hgc:node:responses:${messageId}`;
try {
const responsePromise = new Promise<AgentResponse>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new GatewayTimeoutException('Agent request timed out'));
}, timeoutMs);
subscriber.on('message', (channel, message) => {
if (channel !== responseChannel) {
return;
}
clearTimeout(timer);
try {
resolve(JSON.parse(message) as AgentResponse);
} catch {
reject(new ServiceUnavailableException('Invalid agent response'));
}
});
});
await subscriber.subscribe(responseChannel);
await this.serversService.publishNodeCommand(nodeId, {
protocolVersion: 1,
messageId,
type,
timestamp: new Date().toISOString(),
payload: {
...payload,
generation: payload['generation'] ?? 0,
},
});
return await responsePromise;
} finally {
await subscriber.unsubscribe(responseChannel);
await subscriber.quit();
}
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
@@ -7,7 +7,7 @@ import { NodesGateway } from './nodes.gateway';
import { NodesService } from './nodes.service';
@Module({
imports: [ServersModule],
imports: [forwardRef(() => ServersModule)],
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
exports: [NodesService],
})

View File

@@ -13,6 +13,7 @@ import { verifyTokenHash } from '@hexahost/auth';
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
import { ConsoleSessionService } from '../servers/console/console-session.service';
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
type GameServerStatus = GameServer['status'];
@@ -47,6 +48,7 @@ export class NodesService {
constructor(
private readonly prisma: PrismaService,
private readonly serversService: ServersService,
private readonly consoleSessionService: ConsoleSessionService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
@@ -145,12 +147,21 @@ export class NodesService {
case 'server.state':
await this.handleServerState(envelope);
break;
case 'server.log':
this.handleServerLog(envelope);
break;
case 'server.operation.completed':
await this.handleOperationCompleted(envelope);
break;
case 'server.operation.failed':
await this.handleOperationFailed(envelope);
break;
case 'server.files.list.result':
case 'server.files.read.result':
case 'server.files.write.result':
case 'server.command.result':
await this.handleDirectResult(envelope);
break;
default:
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
}
@@ -210,28 +221,53 @@ export class NodesService {
});
}
private handleServerLog(envelope: AgentEnvelope): void {
const serverId = envelope.payload?.['serverId'];
const line =
envelope.payload?.['line'] ?? envelope.payload?.['message'];
if (typeof serverId === 'string' && typeof line === 'string') {
this.consoleSessionService.broadcast(serverId, line);
}
}
private async handleDirectResult(envelope: AgentEnvelope): Promise<void> {
const messageId = envelope.correlationId ?? envelope.messageId;
if (typeof messageId !== 'string') {
return;
}
await this.publishNodeResponse(messageId, {
success: true,
result: envelope.payload?.['result'] ?? envelope.payload,
});
}
private async handleOperationCompleted(
envelope: AgentEnvelope,
): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const operation = envelope.payload?.['operation'];
const messageId = envelope.messageId;
const operation =
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
const messageId = envelope.correlationId ?? envelope.messageId;
const result = envelope.payload?.['result'];
const isBridgeOperation = this.isBridgeOperation(operation);
if (typeof serverId !== 'string') {
return;
}
const target = this.resolveCompletedStatus(operation);
if (target) {
await this.serversService.applyAgentStatus(serverId, target, {
correlationId: envelope.correlationId,
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
});
if (typeof serverId === 'string' && !isBridgeOperation) {
const target = this.resolveCompletedStatus(operation);
if (target) {
await this.serversService.applyAgentStatus(serverId, target, {
correlationId: envelope.correlationId,
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
});
}
}
if (typeof messageId === 'string') {
await this.publishNodeResponse(messageId, {
success: true,
result,
resultCode: typeof operation === 'string' ? operation : 'completed',
});
}
@@ -239,9 +275,12 @@ export class NodesService {
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const messageId = envelope.messageId;
const operation =
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
const messageId = envelope.correlationId ?? envelope.messageId;
const isBridgeOperation = this.isBridgeOperation(operation);
if (typeof serverId === 'string') {
if (typeof serverId === 'string' && !isBridgeOperation) {
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
correlationId: envelope.correlationId,
reason: 'agent operation failed',
@@ -271,10 +310,25 @@ export class NodesService {
}
}
private async publishNodeResponse(
private isBridgeOperation(operation: unknown): boolean {
if (typeof operation !== 'string') {
return false;
}
const normalized = operation.replace(/^server\./, '');
return (
normalized === 'files.list' ||
normalized === 'files.read' ||
normalized === 'files.write' ||
normalized === 'command'
);
}
async publishNodeResponse(
messageId: string,
response: {
success: boolean;
result?: unknown;
resultCode?: string;
errorCode?: string;
errorMessage?: string;