import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AgentPresence } from '../entities/agent-presence.entity.js'; export type PresenceStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | 'unknown'; @Injectable() export class AgentPresenceService { constructor( @InjectRepository(AgentPresence) private readonly repo: Repository, ) {} /** * Get a user's current presence. Returns 'unknown' if no row. * Used by `RealtimeGateway` per-recipient when xType === 'announce'. */ async getStatus(userId: string): Promise { if (!userId) return 'unknown'; const row = await this.repo.findOne({ where: { userId } }); return row?.status ?? 'unknown'; } /** Bulk variant for delivery-time lookups across many recipients in one trip. */ async getStatusMap(userIds: string[]): Promise> { const out = new Map(); for (const id of userIds) out.set(id, 'unknown'); if (userIds.length === 0) return out; const rows = await this.repo .createQueryBuilder('p') .where('p.userId IN (:...ids)', { ids: userIds }) .getMany(); for (const r of rows) out.set(r.userId, r.status); return out; } /** * Upsert a user's presence. Source is a free-text tag for debugging * (e.g. "hf-plugin", "manual", "test"). PUT /agents/:id/presence * calls this; the plugin pushes only on diff so writes are sparse. * * Implementation note: the older findOne+save split was a read-modify- * write race — two concurrent first-time writes for the same userId * would both read no row, both INSERT, second hits unique-key dup * (`agent_presences.PRIMARY`) and 500s. Fabric.OpenclawPlugin's * presence-sync occasionally fires two PUTs for the same agent within * ~10 ms (tick overlap on its side — separate fix in the plugin), * which surfaced this race in prod. * * `repo.upsert(values, conflictPaths)` compiles to MySQL * `INSERT … ON DUPLICATE KEY UPDATE` and is atomic at the storage * engine level — no read needed, no race window. We synthesize the * returned entity from what we just wrote rather than round-tripping * a SELECT — the controller only reads {userId, status} off it. */ async setStatus(userId: string, status: PresenceStatus, source: string): Promise { await this.repo.upsert({ userId, status, source }, ['userId']); return this.repo.create({ userId, status, source }); } }