Sprint 004 non-blocking: - null byte 방지 (harness file write) Sprint 005 본문: - CostLog Prisma 모델 추가 - TASK-016: GET /api/admin/costs + POST /api/admin/costs/record/:name 자매별/모델별/일별 토큰 + 예상 비용 (USD), 기간 필터(day/week/month) - TASK-018: WebSocket Gateway (@WebSocketGateway /ws namespace) sisters:update, activity:new 브로드캐스트 EventsScheduler: 30초 주기 자매 상태 체크 + 브로드캐스트 ActivityService: 새 로그 생성 시 실시간 브로드캐스트 - TASK-017: /admin/costs 비용 대시보드 (BarChart + 요약 카드) - 메인 대시보드 useSocket 훅 + 실시간 연결 상태 표시 - 테스트 22/22 pass, FE 12 routes build 성공 - NEXT_PUBLIC_WS_URL env (.env.local.example 업데이트)
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SshService } from '../sisters/ssh.service';
|
|
import { ActivityService } from '../activity/activity.service';
|
|
import { SisterName } from '../common/sister-name.pipe';
|
|
|
|
const ALLOWED_HARNESS_FILES = ['AGENTS.md', 'SOUL.md', 'PROTOCOL.md', 'TOOLS.md', 'HEARTBEAT.md'] as const;
|
|
type HarnessFile = (typeof ALLOWED_HARNESS_FILES)[number];
|
|
|
|
@Injectable()
|
|
export class AdminService {
|
|
private readonly logger = new Logger(AdminService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly ssh: SshService,
|
|
private readonly activity: ActivityService,
|
|
private readonly config: ConfigService,
|
|
) {}
|
|
|
|
async restartSister(name: SisterName) {
|
|
const sister = await this.getSister(name);
|
|
const keyPath = this.getKeyPath();
|
|
|
|
try {
|
|
const result = await this.ssh.executeCommand(
|
|
sister.ip,
|
|
sister.user,
|
|
keyPath,
|
|
'openclaw gateway restart && echo "RESTART_OK"',
|
|
);
|
|
|
|
const success = result.stdout.includes('RESTART_OK');
|
|
|
|
await this.activity.log({
|
|
sisterId: sister.id,
|
|
action: 'gateway_restart',
|
|
detail: success ? 'Gateway restart successful' : `stderr: ${result.stderr}`,
|
|
});
|
|
|
|
return { success, output: result.stdout, error: result.stderr || null };
|
|
} catch (e) {
|
|
this.logger.warn(`Restart failed for ${name}`);
|
|
await this.activity.log({
|
|
sisterId: sister.id,
|
|
action: 'gateway_restart',
|
|
detail: `failed: ${(e as Error).message}`,
|
|
});
|
|
return { success: false, output: '', error: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
async resetSisterSession(name: SisterName) {
|
|
const sister = await this.getSister(name);
|
|
const keyPath = this.getKeyPath();
|
|
|
|
try {
|
|
const result = await this.ssh.executeCommand(
|
|
sister.ip,
|
|
sister.user,
|
|
keyPath,
|
|
'rm -f ~/.openclaw/sessions/main.json && echo "RESET_OK"',
|
|
);
|
|
|
|
const success = result.stdout.includes('RESET_OK');
|
|
|
|
await this.activity.log({
|
|
sisterId: sister.id,
|
|
action: 'session_reset',
|
|
detail: success ? 'Session reset successful' : `stderr: ${result.stderr}`,
|
|
});
|
|
|
|
return { success, output: result.stdout, error: result.stderr || null };
|
|
} catch (e) {
|
|
this.logger.warn(`Reset failed for ${name}`);
|
|
return { success: false, output: '', error: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
async getHarnessFile(name: SisterName, file: string) {
|
|
this.validateHarnessFile(file);
|
|
const sister = await this.getSister(name);
|
|
const keyPath = this.getKeyPath();
|
|
|
|
try {
|
|
const result = await this.ssh.executeCommand(
|
|
sister.ip,
|
|
sister.user,
|
|
keyPath,
|
|
`cat ~/.openclaw/workspace/${file} 2>/dev/null || echo ""`,
|
|
);
|
|
return { name, file, content: result.stdout };
|
|
} catch {
|
|
return { name, file, content: '' };
|
|
}
|
|
}
|
|
|
|
async updateHarnessFile(name: SisterName, file: string, content: string) {
|
|
this.validateHarnessFile(file);
|
|
const sister = await this.getSister(name);
|
|
const keyPath = this.getKeyPath();
|
|
|
|
// 내용에서 위험한 셸 escape + null byte 방지
|
|
const escaped = content.replace(/\x00/g, '').replace(/'/g, "'\\''");
|
|
|
|
try {
|
|
const result = await this.ssh.executeCommand(
|
|
sister.ip,
|
|
sister.user,
|
|
keyPath,
|
|
[
|
|
`cd ~/.openclaw/workspace`,
|
|
`printf '%s' '${escaped}' > ${file}`,
|
|
`git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`,
|
|
`echo "WRITE_OK"`,
|
|
].join(' && '),
|
|
);
|
|
|
|
const success = result.stdout.includes('WRITE_OK');
|
|
|
|
await this.activity.log({
|
|
sisterId: sister.id,
|
|
action: 'harness_updated',
|
|
detail: `${file} updated${success ? '' : ' (with errors)'}`,
|
|
});
|
|
|
|
return { success, output: result.stdout, error: result.stderr || null };
|
|
} catch (e) {
|
|
this.logger.warn(`Harness write failed for ${name}/${file}`);
|
|
return { success: false, output: '', error: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
async getSisterLogs(name: SisterName, lines = 100) {
|
|
const sister = await this.getSister(name);
|
|
const keyPath = this.getKeyPath();
|
|
|
|
try {
|
|
const result = await this.ssh.executeCommand(
|
|
sister.ip,
|
|
sister.user,
|
|
keyPath,
|
|
`journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`,
|
|
);
|
|
return {
|
|
name,
|
|
lines: result.stdout.split('\n').filter((l) => l),
|
|
total: result.stdout.split('\n').filter((l) => l).length,
|
|
};
|
|
} catch {
|
|
return { name, lines: ['(SSH 연결 불가)'], total: 1 };
|
|
}
|
|
}
|
|
|
|
private validateHarnessFile(file: string) {
|
|
if (!ALLOWED_HARNESS_FILES.includes(file as HarnessFile)) {
|
|
throw new BadRequestException(
|
|
`Invalid file. Allowed: ${ALLOWED_HARNESS_FILES.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async getSister(name: SisterName) {
|
|
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
|
|
if (!sister) throw new Error(`Sister ${name} not found`);
|
|
return sister;
|
|
}
|
|
|
|
private getKeyPath(): string {
|
|
const p = this.config.get<string>('SSH_KEY_PATH');
|
|
if (!p) throw new Error('SSH_KEY_PATH is not set');
|
|
return p;
|
|
}
|
|
}
|