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:
2026-04-15 18:55:18 +05:30
parent d459d6469a
commit 67c41f4783
2 changed files with 73 additions and 0 deletions

View File

@@ -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);

View File

@@ -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);