Add macOS companion node status and system.nodes APIs

This commit is contained in:
William Valentin
2026-02-16 12:41:58 -08:00
parent 8a0b4f3dbb
commit bea4c54f3b
14 changed files with 500 additions and 6 deletions
+12
View File
@@ -186,5 +186,17 @@ describe('authorizeNodeMethod', () => {
roleScopes: { companion: ['node.capabilities.get', 'node.location.set'] },
});
expect(allowedLocation.authenticated).toBe(true);
const deniedStatus = authorizeNodeMethod({
enabled: true,
method: 'node.status.set',
nodeRole: 'observer',
allowedRoles: ['companion', 'observer'],
roleScopes: {
companion: ['node.capabilities.get', 'node.status.set'],
observer: ['node.capabilities.get'],
},
});
expect(deniedStatus.authenticated).toBe(false);
});
});
+52
View File
@@ -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', () => {
+1 -1
View File
@@ -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';
+50
View File
@@ -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');
});
});
+50
View File
@@ -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;
+34 -1
View File
@@ -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 };
+40
View File
@@ -5,6 +5,7 @@ import {
parseNodeRegisterParams,
parseNodeLocationSetParams,
parseNodeLocationGetParams,
parseNodeStatusSetParams,
makeResponse,
makeError,
makeEvent,
@@ -171,6 +172,45 @@ describe('protocol', () => {
});
});
describe('parseNodeStatusSetParams', () => {
it('parses valid node status set params', () => {
const parsed = parseNodeStatusSetParams({
connectionId: 'conn-1',
platform: 'macos',
appVersion: '0.1.0',
deviceName: 'Willbook',
statusText: 'Idle',
batteryPct: 73,
powerSource: 'battery',
});
expect(parsed).toEqual({
connectionId: 'conn-1',
platform: 'macos',
appVersion: '0.1.0',
deviceName: 'Willbook',
statusText: 'Idle',
batteryPct: 73,
powerSource: 'battery',
});
});
it('rejects invalid node status set params', () => {
expect(parseNodeStatusSetParams({
connectionId: 'conn-1',
platform: 'beos',
})).toBeNull();
expect(parseNodeStatusSetParams({
connectionId: 'conn-1',
platform: 'macos',
batteryPct: 120,
})).toBeNull();
expect(parseNodeStatusSetParams({
connectionId: '',
platform: 'macos',
})).toBeNull();
});
});
describe('makeResponse', () => {
it('creates a response message', () => {
expect(makeResponse(1, { status: 'ok' })).toEqual({
+48
View File
@@ -34,6 +34,16 @@ export interface NodeLocationGetParams {
connectionId: string;
}
export interface NodeStatusSetParams {
connectionId: string;
platform: 'macos' | 'ios' | 'android' | 'linux' | 'windows' | 'unknown';
appVersion?: string;
deviceName?: string;
statusText?: string;
batteryPct?: number;
powerSource?: 'ac' | 'battery' | 'unknown';
}
// ── Server → Client ────────────────────────────────────────────
export interface GatewayResponse {
@@ -245,6 +255,44 @@ export function parseNodeLocationGetParams(params: unknown): NodeLocationGetPara
};
}
export function parseNodeStatusSetParams(params: unknown): NodeStatusSetParams | null {
if (!params || typeof params !== 'object') {
return null;
}
const p = params as Record<string, unknown>;
if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) {
return null;
}
if (typeof p.platform !== 'string' || !['macos', 'ios', 'android', 'linux', 'windows', 'unknown'].includes(p.platform)) {
return null;
}
if (p.appVersion !== undefined && typeof p.appVersion !== 'string') {
return null;
}
if (p.deviceName !== undefined && typeof p.deviceName !== 'string') {
return null;
}
if (p.statusText !== undefined && typeof p.statusText !== 'string') {
return null;
}
if (p.batteryPct !== undefined && (typeof p.batteryPct !== 'number' || !Number.isFinite(p.batteryPct) || p.batteryPct < 0 || p.batteryPct > 100)) {
return null;
}
if (p.powerSource !== undefined && !['ac', 'battery', 'unknown'].includes(String(p.powerSource))) {
return null;
}
return {
connectionId: p.connectionId,
platform: p.platform as NodeStatusSetParams['platform'],
appVersion: typeof p.appVersion === 'string' ? p.appVersion : undefined,
deviceName: typeof p.deviceName === 'string' ? p.deviceName : undefined,
statusText: typeof p.statusText === 'string' ? p.statusText : undefined,
batteryPct: typeof p.batteryPct === 'number' ? p.batteryPct : undefined,
powerSource: p.powerSource as NodeStatusSetParams['powerSource'] | undefined,
};
}
export function makeResponse(id: number, result: unknown): GatewayResponse {
return { id, result };
}
+57
View File
@@ -236,6 +236,8 @@ describe('GatewayServer integration', () => {
expect(methods).toContain('tools.invoke');
expect(methods).toContain('canvas.put');
expect(methods).toContain('canvas.list');
expect(methods).toContain('system.nodes');
expect(methods).toContain('node.status.set');
});
it('supports canvas artifact lifecycle via gateway RPC', async () => {
@@ -752,4 +754,59 @@ describe('GatewayServer node registration and capability negotiation', () => {
}
}
});
it('supports node.status.set and exposes registered nodes via system.nodes', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const ws = await new Promise<WebSocket>((resolve, reject) => {
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
c.on('open', () => resolve(c));
c.on('error', reject);
});
try {
const registered = await sendAndReceive(ws, {
id: 20,
method: 'node.register',
params: {
nodeId: 'node-mac',
role: 'companion',
protocolVersion: 1,
capabilities: ['ui.canvas'],
},
});
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
const status = await sendAndReceive(ws, {
id: 21,
method: 'node.status.set',
params: {
platform: 'macos',
appVersion: '0.3.1',
deviceName: 'MacBook Pro',
batteryPct: 64,
powerSource: 'battery',
},
});
expect(((status as GatewayResponse).result as { updated: boolean }).updated).toBe(true);
const nodes = await sendAndReceive(ws, {
id: 22,
method: 'system.nodes',
params: { role: 'companion', platform: 'macos', limit: 10 },
});
const list = ((nodes as GatewayResponse).result as {
nodes: Array<{ nodeId: string; status?: { platform: string; appVersion?: string } }>;
}).nodes;
expect(list.length).toBeGreaterThanOrEqual(1);
expect(list.some((entry) => entry.nodeId === 'node-mac')).toBe(true);
expect(list.find((entry) => entry.nodeId === 'node-mac')?.status?.platform).toBe('macos');
} finally {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
});
});
+53 -1
View File
@@ -220,6 +220,48 @@ export class GatewayServer {
}
return sorted;
},
getNodes: ({ role, platform, limit } = {}) => {
const entries: Array<{
connectionId: string;
nodeId: string;
role: string;
identity?: string;
protocolVersion: number;
capabilities: string[];
registeredAt: number;
location?: NodeConnectionState['location'];
status?: NodeConnectionState['status'];
}> = [];
for (const [connectionId, state] of this.connectionStateMap.entries()) {
if (!state.node) {
continue;
}
if (role && state.node.role !== role) {
continue;
}
if (platform && state.status?.platform !== platform) {
continue;
}
entries.push({
connectionId,
nodeId: state.node.nodeId,
role: state.node.role,
identity: state.identity,
protocolVersion: state.node.protocolVersion,
capabilities: state.node.capabilities,
registeredAt: state.node.registeredAt,
location: state.location,
status: state.status,
});
}
const sorted = entries.sort((a, b) => b.registeredAt - a.registeredAt);
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
return sorted.slice(0, Math.floor(limit));
}
return sorted;
},
getUsage: () => ({
totalSessions: this.config.sessionManager.listSessions().length,
activeConnections: this.sessionBridge.connectionCount,
@@ -342,6 +384,16 @@ export class GatewayServer {
location,
});
},
setNodeStatus: (connectionId, status) => {
const existing = this.connectionStateMap.get(connectionId);
if (!existing) {
return;
}
this.connectionStateMap.set(connectionId, {
...existing,
status,
});
},
});
// Config handlers (only if config object is provided)
@@ -688,7 +740,7 @@ export class GatewayServer {
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
allowedRoles: this.config.nodes?.allowedRoles ?? [],
roleScopes: {
companion: ['node.capabilities.get', 'node.location.set', 'node.location.get'],
companion: ['node.capabilities.get', 'node.location.set', 'node.location.get', 'node.status.set'],
observer: ['node.capabilities.get', 'node.location.get'],
automation: ['node.capabilities.get', 'node.location.get'],
},