Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,43 @@ describe('pod agents lastActiveAt derivation (#915)', () => {
expect(res.body.agents[0].connection.lastSeenAt).toEqual(expect.any(String));
});

it('projects a daemon-bound seat as a wrapper even when its install label is not wrapper', async () => {
AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({
agentName: 'local', config: new Map([['runtime', { runtimeType: 'claude-code' }]]),
})]);
User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
_id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [],
}]) }) });
// The runtime key is aged past the 5-minute window, so only the live machine can make this seat connected.
AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([
{ kind: 'runtime', agentUserId: 'bot-1', status: 'active', lastUsedAt: new Date(Date.now() - 10 * 60 * 1000) },
{ kind: 'daemon', machineId: 'machine-1', status: 'active' },
]) }) });
Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
machineId: 'machine-1', lastSeenAt: new Date(),
}]) }) });
const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`);
expect(res.status).toBe(200);
expect(res.body.agents[0].connection.state).toBe('connected');
expect(res.body.agents[0].connection.lastSeenAt).toEqual(expect.any(String));
expect(res.body.agents[0].runtime.runtimeType).toBe('wrapper');
expect(res.body.agents[0].runtime.adapter).toBe('claude-code');
});

it('reports a daemon-bound seat with no live signal as never connected, not missing', async () => {
AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({
agentName: 'local', config: new Map([['runtime', { runtimeType: 'claude-code' }]]),
})]);
User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
_id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [],
}]) }) });
AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) });
Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) });
const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`);
expect(res.status).toBe(200);
expect(res.body.agents[0].connection).toEqual({ state: 'never', lastSeenAt: null });
});

it('surfaces AgentRun activity for native agents with no heartbeats and no tokens', async () => {
AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation()]);
AgentRun.aggregate.mockResolvedValue([
Expand Down
24 changes: 20 additions & 4 deletions backend/routes/registry/pod-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,16 +381,32 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => {
// Legacy local-cli installs are normalized to their harness name
// (codex/claude-code) in the public payload. Host, not that label,
// identifies a BYO listener whose credential use proves liveness.
// A seat bound to a daemon machine is a wrapper whatever its install
// label says: manage-settings projects it as `wrapper` for the same
// reason. Without this, a running daemon-managed seat carried no
// connection and Settings rendered "Connection status unavailable".
// The runtime type is projected as `wrapper` too: Settings keys its
// machine-key action and Reconnect link on that field, not on connection.
const machineId = user?.botMetadata?.machineId;
const daemonBound = Boolean(machineId);
// The harness label moves to `adapter`, so the chat tag keeps naming it.
const seatPayload = daemonBound
? { ...payload, runtime: {
...payload?.runtime,
runtimeType: 'wrapper',
adapter: payload?.runtime?.adapter || payload?.runtime?.runtimeType,
} }
: payload;
const hasRuntimeConnection = payload?.runtime?.host === 'byo'
|| runtimeType === 'wrapper'
|| daemonBound
|| (runtimeType === 'webhook' && !payload?.runtime?.host);
if (!connectionAvailable || !user || !hasRuntimeConnection
|| normalizeConfigMap(i.config)?.runtime?.webhookUrl) return payload;
const machineId = user.botMetadata?.machineId;
|| normalizeConfigMap(i.config)?.runtime?.webhookUrl) return seatPayload;
return {
...payload,
...seatPayload,
connection: deriveAgentConnection({
runtimeType,
runtimeType: daemonBound ? 'wrapper' : runtimeType,
credentials: credentialByAgent.get(String(user._id)) || [],
machineCredentials: credentialByMachine.get(String(machineId)) || [],
embeddedTokens: [...(user.agentRuntimeTokens || []), ...(i.runtimeTokens || [])],
Expand Down
Loading