mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(maint): session-status endpoint for agent picker
Unlock Agent / Force Ready shortcuts used to read the target agentId from localStorage helix_agent_config — supervisors don't have that set and got 400 'agentId required'.
- SessionService.listLockedSessions() — SCAN over agent:session:*
- POST /api/maint/session-status returns { locked, free } by joining
the platform Agent entities against Redis session locks
- orphan locks (Redis key with no matching Agent record) surface in
the Locked bucket so the operator can still clear stale lock state
This commit is contained in:
@@ -55,6 +55,26 @@ export class SessionService {
|
||||
await this.redis.del(this.key(agentId));
|
||||
}
|
||||
|
||||
// Enumerate every active session lock so the maint UI can show which
|
||||
// agentIds are currently held (and by whom) vs free. Uses SCAN, not
|
||||
// KEYS, to avoid blocking Redis on workspaces with many keys.
|
||||
async listLockedSessions(): Promise<Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }>> {
|
||||
const out: Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }> = [];
|
||||
const stream = this.redis.scanStream({ match: 'agent:session:*', count: 100 });
|
||||
const keys: string[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', (chunk: string[]) => keys.push(...chunk));
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
for (const key of keys) {
|
||||
const agentId = key.slice('agent:session:'.length);
|
||||
const session = await this.getSession(agentId);
|
||||
if (session) out.push({ agentId, ...session });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Generic cache operations for any module
|
||||
async getCache(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
|
||||
@@ -56,6 +56,59 @@ export class MaintController {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the current per-agent session state — which ozonetelAgentIds
|
||||
// are currently locked (held by a member IP) and which are free. Used
|
||||
// by the maint OTP modal to render a picker so a supervisor can unlock
|
||||
// the right agent without knowing the id off the top of their head.
|
||||
// Read-only; OTP-guarded like the rest of /api/maint.
|
||||
@Post('session-status')
|
||||
async sessionStatus() {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||
).catch(() => ({ agents: { edges: [] } }));
|
||||
|
||||
const allAgents = (data?.agents?.edges ?? []).map((e: any) => e.node).filter((a: any) => a.ozonetelAgentId);
|
||||
const sessions = await this.session.listLockedSessions();
|
||||
const sessionByAgent = new Map(sessions.map((s) => [s.agentId.toLowerCase(), s]));
|
||||
|
||||
const locked: Array<any> = [];
|
||||
const free: Array<any> = [];
|
||||
const seenAgentIds = new Set<string>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
const key = String(agent.ozonetelAgentId).toLowerCase();
|
||||
seenAgentIds.add(key);
|
||||
const session = sessionByAgent.get(key);
|
||||
const row = {
|
||||
agentId: agent.ozonetelAgentId,
|
||||
displayName: agent.name ?? agent.ozonetelDisplayName ?? agent.ozonetelAgentId,
|
||||
};
|
||||
if (session) {
|
||||
locked.push({ ...row, heldByIp: session.ip, lockedAt: session.lockedAt });
|
||||
} else {
|
||||
free.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Surface orphan locks (Redis holds a session for an ozonetelAgentId
|
||||
// with no matching Agent entity). Rare but possible after SDK renames
|
||||
// or workspace resets — without surfacing them, the operator can't
|
||||
// clear the stale lock via the UI.
|
||||
for (const session of sessions) {
|
||||
const key = session.agentId.toLowerCase();
|
||||
if (!seenAgentIds.has(key)) {
|
||||
locked.push({
|
||||
agentId: session.agentId,
|
||||
displayName: `${session.agentId} (orphan — no Agent record)`,
|
||||
heldByIp: session.ip,
|
||||
lockedAt: session.lockedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { locked, free };
|
||||
}
|
||||
|
||||
@Post('unlock-agent')
|
||||
async unlockAgent(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
|
||||
Reference in New Issue
Block a user