diff --git a/README.md b/README.md index d67c7ea..d70a356 100644 --- a/README.md +++ b/README.md @@ -867,7 +867,9 @@ Methods: - `node.capabilities.get` returns negotiated protocol version and enabled capabilities. - `node.location.set` updates the node's last-known location (when `server.nodes.location.enabled` is true). - `node.location.get` returns the node's stored location payload. +- `node.status.set` publishes companion status/heartbeat fields (`platform`, `appVersion`, `batteryPct`, etc.). - `system.location` provides an operator view of registered node locations. +- `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status). - `system.capabilities` returns gateway protocol and node policy snapshot. ## Canvas / A2UI Foundation diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 9c8db12..460ad03 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -647,6 +647,26 @@ Requires `server.nodes.enabled: true` and `server.nodes.location.enabled: true`. Return the stored last-known location for the currently registered node connection. +#### `node.status.set` + +Publish companion/node runtime status metadata (for example macOS menu-bar heartbeat state). + +**Request:** +```json +{ + "id": 12, + "method": "node.status.set", + "params": { + "platform": "macos", + "appVersion": "0.3.1", + "deviceName": "MacBook Pro", + "statusText": "Idle", + "batteryPct": 64, + "powerSource": "battery" + } +} +``` + #### `system.capabilities` Return gateway protocol version, node policy status, and feature-gate snapshot. @@ -684,6 +704,10 @@ Return gateway protocol version, node policy status, and feature-gate snapshot. Return the operator-facing snapshot of registered node locations. +#### `system.nodes` + +Return the operator-facing snapshot of registered node connections (identity, role, capabilities, location/status). + ### Canvas Methods #### `canvas.put` diff --git a/docs/plans/2026-02-16-macos-menu-bar-companion-foundation-checklist.md b/docs/plans/2026-02-16-macos-menu-bar-companion-foundation-checklist.md new file mode 100644 index 0000000..010eed7 --- /dev/null +++ b/docs/plans/2026-02-16-macos-menu-bar-companion-foundation-checklist.md @@ -0,0 +1,49 @@ +# macOS Menu Bar Companion Foundation Checklist + +**Date:** 2026-02-16 +**Scope:** Close the OpenClaw "macOS menu bar app" gap with a gateway-side companion status foundation. + +## Goal + +Add a practical companion-node status surface so a macOS menu bar app can report heartbeat/platform metadata and operators can inspect active companion state. + +## Implemented + +- Added node status protocol parser: + - `parseNodeStatusSetParams()` +- Added node status RPC: + - `node.status.set` +- Extended node connection state: + - `status` payload (`platform`, `appVersion`, `deviceName`, `statusText`, `batteryPct`, `powerSource`, `reportedAt`) +- Added operator node snapshot endpoint: + - `system.nodes` +- Wired gateway runtime callbacks for node snapshot listing and status persistence. +- Updated node method authorization scopes: + - `companion` role can call `node.status.set` + - observer/automation remain read-only for node scoped methods. + +## Docs Updated + +- `README.md` — node method list now includes `node.status.set` and `system.nodes`. +- `docs/api/PROTOCOL.md` — added request docs for `node.status.set` and `system.nodes`. + +## Tests + +- `src/gateway/protocol.test.ts` + - status parser valid/invalid coverage. +- `src/gateway/handlers/node.test.ts` + - status update persistence behavior. +- `src/gateway/handlers/handlers.test.ts` + - `system.nodes` empty + filtered responses. +- `src/gateway/server.test.ts` + - end-to-end `node.status.set` + `system.nodes` flow. +- `src/gateway/auth.test.ts` + - role-scope denial for `node.status.set` where not permitted. + +## Validation Run + +```bash +pnpm test:run src/gateway/protocol.test.ts src/gateway/auth.test.ts src/gateway/handlers/node.test.ts src/gateway/handlers/handlers.test.ts src/gateway/server.test.ts +pnpm typecheck +pnpm build +``` diff --git a/docs/plans/state.json b/docs/plans/state.json index ab66499..2660bad 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -517,6 +517,31 @@ ], "test_status": "pnpm test:run src/gateway/canvas-store.test.ts src/gateway/handlers/handlers.test.ts src/gateway/server.test.ts + pnpm typecheck + pnpm build passing" }, + "macos-menu-bar-companion-foundation": { + "file": "2026-02-16-macos-menu-bar-companion-foundation-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented macOS companion foundation on gateway node RPC: added `node.status.set` for companion heartbeat/status metadata and `system.nodes` for operator visibility of registered node snapshots (role/capabilities/identity/location/status), with role-scope auth enforcement and tests/docs.", + "files_created": [ + "docs/plans/2026-02-16-macos-menu-bar-companion-foundation-checklist.md" + ], + "files_modified": [ + "src/gateway/protocol.ts", + "src/gateway/protocol.test.ts", + "src/gateway/handlers/node.ts", + "src/gateway/handlers/node.test.ts", + "src/gateway/handlers/system.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/handlers/index.ts", + "src/gateway/server.ts", + "src/gateway/server.test.ts", + "src/gateway/auth.test.ts", + "README.md", + "docs/api/PROTOCOL.md" + ], + "test_status": "pnpm test:run src/gateway/protocol.test.ts src/gateway/auth.test.ts src/gateway/handlers/node.test.ts src/gateway/handlers/handlers.test.ts src/gateway/server.test.ts + pnpm typecheck + pnpm build passing" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3078,7 +3103,7 @@ } }, "overall_progress": { - "total_test_count": 1780, + "total_test_count": 1786, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3093,12 +3118,12 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "120/128 match (94%), 0 partial (0%), 8 missing (6%)", + "feature_gap_scorecard": "121/128 match (95%), 0 partial (0%), 7 missing (5%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap: macOS menu bar companion app (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: iOS node (open next scoped implementation checklist)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 20d6a72..697f429 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -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); }); }); diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 6e49f80..d28ca2f 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -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', () => { diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 7e5aaa1..4d50f53 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -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'; diff --git a/src/gateway/handlers/node.test.ts b/src/gateway/handlers/node.test.ts index 0541f49..73af5f8 100644 --- a/src/gateway/handlers/node.test.ts +++ b/src/gateway/handlers/node.test.ts @@ -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([['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'); + }); }); diff --git a/src/gateway/handlers/node.ts b/src/gateway/handlers/node.ts index e627107..c77049f 100644 --- a/src/gateway/handlers/node.ts +++ b/src/gateway/handlers/node.ts @@ -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 => { + 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 => { const params = request.params as { connectionId?: string } | undefined; const connectionId = params?.connectionId; diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 49429f0..76139f5 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -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 => { + 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 => { const uptime = Math.floor((Date.now() - deps.startTime) / 1000); const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 }; diff --git a/src/gateway/protocol.test.ts b/src/gateway/protocol.test.ts index a2eb55c..83f54f7 100644 --- a/src/gateway/protocol.test.ts +++ b/src/gateway/protocol.test.ts @@ -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({ diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index db97cce..92cb327 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -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; + 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 }; } diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 8e9877c..879d325 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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((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(); + } + } + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index e62a9bd..39d75cf 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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'], },