Add macOS companion node status and system.nodes APIs
This commit is contained in:
@@ -215,6 +215,58 @@ describe('system handlers', () => {
|
||||
expect(locations[0]?.nodeId).toBe('node-1');
|
||||
expect(getPath(result.result, 'summary')).toEqual({ total: 1 });
|
||||
});
|
||||
|
||||
it('system.nodes returns empty result when getNodes is not provided', async () => {
|
||||
const req: GatewayRequest = { id: 8, method: 'system.nodes' };
|
||||
const result = await handlers['system.nodes'](req) as GatewayResponse;
|
||||
expect(result.id).toBe(8);
|
||||
expect(getPath(result.result, 'nodes')).toEqual([]);
|
||||
expect(getPath(result.result, 'summary')).toEqual({ total: 0 });
|
||||
});
|
||||
|
||||
it('system.nodes returns filtered registered node snapshots', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
getNodes: ({ role, platform, limit } = {}) => {
|
||||
const all = [
|
||||
{
|
||||
connectionId: 'c1',
|
||||
nodeId: 'companion-mac',
|
||||
role: 'companion',
|
||||
identity: 'will@example.com',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['ui.canvas'],
|
||||
registeredAt: 100,
|
||||
status: { platform: 'macos' as const, appVersion: '0.3.0', powerSource: 'ac' as const, reportedAt: 120 },
|
||||
},
|
||||
{
|
||||
connectionId: 'c2',
|
||||
nodeId: 'observer-linux',
|
||||
role: 'observer',
|
||||
protocolVersion: 1,
|
||||
capabilities: [],
|
||||
registeredAt: 90,
|
||||
status: { platform: 'linux' as const, powerSource: 'unknown' as const, reportedAt: 95 },
|
||||
},
|
||||
];
|
||||
return all
|
||||
.filter((entry) => !role || entry.role === role)
|
||||
.filter((entry) => !platform || entry.status?.platform === platform)
|
||||
.slice(0, limit ?? 100);
|
||||
},
|
||||
});
|
||||
|
||||
const req: GatewayRequest = {
|
||||
id: 9,
|
||||
method: 'system.nodes',
|
||||
params: { role: 'companion', platform: 'macos', limit: 1 },
|
||||
};
|
||||
const result = await handlers['system.nodes'](req) as GatewayResponse;
|
||||
const nodes = getPath(result.result, 'nodes') as Array<{ nodeId: string }>;
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]?.nodeId).toBe('companion-mac');
|
||||
expect(getPath(result.result, 'summary')).toEqual({ total: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('system.tokenUsage handler', () => {
|
||||
|
||||
@@ -20,4 +20,4 @@ export type { HistoryHandlerDeps } from './history.js';
|
||||
export { createCanvasHandlers } from './canvas.js';
|
||||
export type { CanvasHandlerDeps } from './canvas.js';
|
||||
export { createNodeHandlers } from './node.js';
|
||||
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation } from './node.js';
|
||||
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation, NodeStatus } from './node.js';
|
||||
|
||||
@@ -18,6 +18,10 @@ describe('node handlers', () => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, location });
|
||||
},
|
||||
setNodeStatus: (connectionId, status) => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, status });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handlers['node.register']({
|
||||
@@ -48,6 +52,7 @@ describe('node handlers', () => {
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.register']({
|
||||
@@ -83,6 +88,7 @@ describe('node handlers', () => {
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.capabilities.get']({
|
||||
@@ -116,6 +122,7 @@ describe('node handlers', () => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, location });
|
||||
},
|
||||
setNodeStatus: () => {},
|
||||
});
|
||||
|
||||
const setResult = await handlers['node.location.set']({
|
||||
@@ -159,6 +166,7 @@ describe('node handlers', () => {
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.location.set']({
|
||||
@@ -168,4 +176,46 @@ describe('node handlers', () => {
|
||||
});
|
||||
expect((result as { error: { message: string } }).error.message).toContain('disabled');
|
||||
});
|
||||
|
||||
it('stores companion node status updates', async () => {
|
||||
const states = new Map<string, NodeConnectionState>([['conn-1', {
|
||||
node: {
|
||||
nodeId: 'node-a',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['status'],
|
||||
registeredAt: Date.now(),
|
||||
},
|
||||
}]]);
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: (connectionId, status) => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, status });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handlers['node.status.set']({
|
||||
id: 7,
|
||||
method: 'node.status.set',
|
||||
params: {
|
||||
connectionId: 'conn-1',
|
||||
platform: 'macos',
|
||||
appVersion: '0.2.0',
|
||||
deviceName: 'Office Mac',
|
||||
batteryPct: 81,
|
||||
powerSource: 'ac',
|
||||
},
|
||||
});
|
||||
|
||||
expect((result as { result: { updated: boolean } }).result.updated).toBe(true);
|
||||
expect(states.get('conn-1')?.status?.platform).toBe('macos');
|
||||
expect(states.get('conn-1')?.status?.appVersion).toBe('0.2.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseNodeRegisterParams,
|
||||
parseNodeLocationSetParams,
|
||||
parseNodeLocationGetParams,
|
||||
parseNodeStatusSetParams,
|
||||
} from '../protocol.js';
|
||||
|
||||
export interface NodeRegistration {
|
||||
@@ -21,6 +22,7 @@ export interface NodeConnectionState {
|
||||
identity?: string;
|
||||
node?: NodeRegistration;
|
||||
location?: NodeLocation;
|
||||
status?: NodeStatus;
|
||||
}
|
||||
|
||||
export interface NodeLocation {
|
||||
@@ -35,6 +37,16 @@ export interface NodeLocation {
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export interface NodeStatus {
|
||||
platform: 'macos' | 'ios' | 'android' | 'linux' | 'windows' | 'unknown';
|
||||
appVersion?: string;
|
||||
deviceName?: string;
|
||||
statusText?: string;
|
||||
batteryPct?: number;
|
||||
powerSource: 'ac' | 'battery' | 'unknown';
|
||||
reportedAt: number;
|
||||
}
|
||||
|
||||
export interface NodeHandlerDeps {
|
||||
enabled: boolean;
|
||||
locationEnabled: boolean;
|
||||
@@ -43,6 +55,7 @@ export interface NodeHandlerDeps {
|
||||
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
||||
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
||||
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
|
||||
setNodeStatus: (connectionId: string, status: NodeStatus) => void;
|
||||
}
|
||||
|
||||
export function createNodeHandlers(deps: NodeHandlerDeps) {
|
||||
@@ -198,6 +211,43 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
|
||||
});
|
||||
},
|
||||
|
||||
'node.status.set': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.enabled) {
|
||||
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
||||
}
|
||||
|
||||
const parsed = parseNodeStatusSetParams(request.params);
|
||||
if (!parsed) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.status.set params');
|
||||
}
|
||||
|
||||
const state = deps.getConnectionState(parsed.connectionId);
|
||||
if (!state?.node) {
|
||||
return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection');
|
||||
}
|
||||
|
||||
const status: NodeStatus = {
|
||||
platform: parsed.platform,
|
||||
appVersion: parsed.appVersion?.trim() || undefined,
|
||||
deviceName: parsed.deviceName?.trim() || undefined,
|
||||
statusText: parsed.statusText?.trim() || undefined,
|
||||
batteryPct: parsed.batteryPct,
|
||||
powerSource: parsed.powerSource ?? 'unknown',
|
||||
reportedAt: Date.now(),
|
||||
};
|
||||
|
||||
deps.setNodeStatus(parsed.connectionId, status);
|
||||
|
||||
return makeResponse(request.id, {
|
||||
updated: true,
|
||||
node: {
|
||||
id: state.node.nodeId,
|
||||
role: state.node.role,
|
||||
},
|
||||
status,
|
||||
});
|
||||
},
|
||||
|
||||
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { connectionId?: string } | undefined;
|
||||
const connectionId = params?.connectionId;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
||||
import type { ServiceInfo } from './services.js';
|
||||
import type { NodeLocation } from './node.js';
|
||||
import type { NodeLocation, NodeStatus } from './node.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -29,6 +29,18 @@ export interface NodeLocationEntry {
|
||||
location: NodeLocation;
|
||||
}
|
||||
|
||||
export interface NodeEntry {
|
||||
connectionId: string;
|
||||
nodeId: string;
|
||||
role: string;
|
||||
identity?: string;
|
||||
protocolVersion: number;
|
||||
capabilities: string[];
|
||||
registeredAt: number;
|
||||
location?: NodeLocation;
|
||||
status?: NodeStatus;
|
||||
}
|
||||
|
||||
export interface SystemHandlerDeps {
|
||||
startTime: number;
|
||||
version: string;
|
||||
@@ -53,6 +65,8 @@ export interface SystemHandlerDeps {
|
||||
getPresence?: (opts?: { channel?: string; status?: 'online' | 'offline'; limit?: number }) => PresenceEntry[];
|
||||
/** Optional callback to retrieve latest node location data. */
|
||||
getNodeLocations?: (opts?: { role?: string; nodeId?: string; limit?: number }) => NodeLocationEntry[];
|
||||
/** Optional callback to retrieve registered node connection snapshots. */
|
||||
getNodes?: (opts?: { role?: string; platform?: string; limit?: number }) => NodeEntry[];
|
||||
}
|
||||
|
||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
@@ -142,6 +156,25 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
});
|
||||
},
|
||||
|
||||
'system.nodes': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getNodes) {
|
||||
return makeResponse(request.id, { nodes: [], summary: { total: 0 } });
|
||||
}
|
||||
|
||||
const params = request.params as { role?: string; platform?: string; limit?: number } | undefined;
|
||||
const nodes = deps.getNodes({
|
||||
role: params?.role,
|
||||
platform: params?.platform,
|
||||
limit: params?.limit,
|
||||
});
|
||||
return makeResponse(request.id, {
|
||||
nodes,
|
||||
summary: {
|
||||
total: nodes.length,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
||||
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
||||
|
||||
Reference in New Issue
Block a user