diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index 02ae34a..cfffd4f 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -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> { + 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((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 { return this.redis.get(key); diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index abfd3fb..4c9c90e 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -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( + `{ 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 = []; + const free: Array = []; + const seenAgentIds = new Set(); + + 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);