Add macOS companion node status and system.nodes APIs
This commit is contained in:
@@ -867,7 +867,9 @@ Methods:
|
|||||||
- `node.capabilities.get` returns negotiated protocol version and enabled capabilities.
|
- `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.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.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.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.
|
- `system.capabilities` returns gateway protocol and node policy snapshot.
|
||||||
|
|
||||||
## Canvas / A2UI Foundation
|
## Canvas / A2UI Foundation
|
||||||
|
|||||||
@@ -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.
|
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`
|
#### `system.capabilities`
|
||||||
|
|
||||||
Return gateway protocol version, node policy status, and feature-gate snapshot.
|
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.
|
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 Methods
|
||||||
|
|
||||||
#### `canvas.put`
|
#### `canvas.put`
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
+28
-3
@@ -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"
|
"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": {
|
"qmd-backend": {
|
||||||
"file": "2026-02-16-qmd-backend-checklist.md",
|
"file": "2026-02-16-qmd-backend-checklist.md",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -3078,7 +3103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1780,
|
"total_test_count": 1786,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -3093,12 +3118,12 @@
|
|||||||
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -186,5 +186,17 @@ describe('authorizeNodeMethod', () => {
|
|||||||
roleScopes: { companion: ['node.capabilities.get', 'node.location.set'] },
|
roleScopes: { companion: ['node.capabilities.get', 'node.location.set'] },
|
||||||
});
|
});
|
||||||
expect(allowedLocation.authenticated).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -215,6 +215,58 @@ describe('system handlers', () => {
|
|||||||
expect(locations[0]?.nodeId).toBe('node-1');
|
expect(locations[0]?.nodeId).toBe('node-1');
|
||||||
expect(getPath(result.result, 'summary')).toEqual({ total: 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', () => {
|
describe('system.tokenUsage handler', () => {
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ export type { HistoryHandlerDeps } from './history.js';
|
|||||||
export { createCanvasHandlers } from './canvas.js';
|
export { createCanvasHandlers } from './canvas.js';
|
||||||
export type { CanvasHandlerDeps } from './canvas.js';
|
export type { CanvasHandlerDeps } from './canvas.js';
|
||||||
export { createNodeHandlers } from './node.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) ?? {};
|
const prior = states.get(connectionId) ?? {};
|
||||||
states.set(connectionId, { ...prior, location });
|
states.set(connectionId, { ...prior, location });
|
||||||
},
|
},
|
||||||
|
setNodeStatus: (connectionId, status) => {
|
||||||
|
const prior = states.get(connectionId) ?? {};
|
||||||
|
states.set(connectionId, { ...prior, status });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.register']({
|
const result = await handlers['node.register']({
|
||||||
@@ -48,6 +52,7 @@ describe('node handlers', () => {
|
|||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
setNodeRegistration: () => {},
|
setNodeRegistration: () => {},
|
||||||
setNodeLocation: () => {},
|
setNodeLocation: () => {},
|
||||||
|
setNodeStatus: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.register']({
|
const result = await handlers['node.register']({
|
||||||
@@ -83,6 +88,7 @@ describe('node handlers', () => {
|
|||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
setNodeRegistration: () => {},
|
setNodeRegistration: () => {},
|
||||||
setNodeLocation: () => {},
|
setNodeLocation: () => {},
|
||||||
|
setNodeStatus: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.capabilities.get']({
|
const result = await handlers['node.capabilities.get']({
|
||||||
@@ -116,6 +122,7 @@ describe('node handlers', () => {
|
|||||||
const prior = states.get(connectionId) ?? {};
|
const prior = states.get(connectionId) ?? {};
|
||||||
states.set(connectionId, { ...prior, location });
|
states.set(connectionId, { ...prior, location });
|
||||||
},
|
},
|
||||||
|
setNodeStatus: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const setResult = await handlers['node.location.set']({
|
const setResult = await handlers['node.location.set']({
|
||||||
@@ -159,6 +166,7 @@ describe('node handlers', () => {
|
|||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
setNodeRegistration: () => {},
|
setNodeRegistration: () => {},
|
||||||
setNodeLocation: () => {},
|
setNodeLocation: () => {},
|
||||||
|
setNodeStatus: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.location.set']({
|
const result = await handlers['node.location.set']({
|
||||||
@@ -168,4 +176,46 @@ describe('node handlers', () => {
|
|||||||
});
|
});
|
||||||
expect((result as { error: { message: string } }).error.message).toContain('disabled');
|
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,
|
parseNodeRegisterParams,
|
||||||
parseNodeLocationSetParams,
|
parseNodeLocationSetParams,
|
||||||
parseNodeLocationGetParams,
|
parseNodeLocationGetParams,
|
||||||
|
parseNodeStatusSetParams,
|
||||||
} from '../protocol.js';
|
} from '../protocol.js';
|
||||||
|
|
||||||
export interface NodeRegistration {
|
export interface NodeRegistration {
|
||||||
@@ -21,6 +22,7 @@ export interface NodeConnectionState {
|
|||||||
identity?: string;
|
identity?: string;
|
||||||
node?: NodeRegistration;
|
node?: NodeRegistration;
|
||||||
location?: NodeLocation;
|
location?: NodeLocation;
|
||||||
|
status?: NodeStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeLocation {
|
export interface NodeLocation {
|
||||||
@@ -35,6 +37,16 @@ export interface NodeLocation {
|
|||||||
receivedAt: number;
|
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 {
|
export interface NodeHandlerDeps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
locationEnabled: boolean;
|
locationEnabled: boolean;
|
||||||
@@ -43,6 +55,7 @@ export interface NodeHandlerDeps {
|
|||||||
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
||||||
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
||||||
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
|
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
|
||||||
|
setNodeStatus: (connectionId: string, status: NodeStatus) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNodeHandlers(deps: NodeHandlerDeps) {
|
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> => {
|
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
const params = request.params as { connectionId?: string } | undefined;
|
const params = request.params as { connectionId?: string } | undefined;
|
||||||
const connectionId = params?.connectionId;
|
const connectionId = params?.connectionId;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
|||||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||||
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
||||||
import type { ServiceInfo } from './services.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. */
|
/** Per-session token usage report returned by system.tokenUsage. */
|
||||||
export interface TokenUsageEntry {
|
export interface TokenUsageEntry {
|
||||||
@@ -29,6 +29,18 @@ export interface NodeLocationEntry {
|
|||||||
location: NodeLocation;
|
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 {
|
export interface SystemHandlerDeps {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -53,6 +65,8 @@ export interface SystemHandlerDeps {
|
|||||||
getPresence?: (opts?: { channel?: string; status?: 'online' | 'offline'; limit?: number }) => PresenceEntry[];
|
getPresence?: (opts?: { channel?: string; status?: 'online' | 'offline'; limit?: number }) => PresenceEntry[];
|
||||||
/** Optional callback to retrieve latest node location data. */
|
/** Optional callback to retrieve latest node location data. */
|
||||||
getNodeLocations?: (opts?: { role?: string; nodeId?: string; limit?: number }) => NodeLocationEntry[];
|
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) {
|
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> => {
|
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
||||||
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
parseNodeRegisterParams,
|
parseNodeRegisterParams,
|
||||||
parseNodeLocationSetParams,
|
parseNodeLocationSetParams,
|
||||||
parseNodeLocationGetParams,
|
parseNodeLocationGetParams,
|
||||||
|
parseNodeStatusSetParams,
|
||||||
makeResponse,
|
makeResponse,
|
||||||
makeError,
|
makeError,
|
||||||
makeEvent,
|
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', () => {
|
describe('makeResponse', () => {
|
||||||
it('creates a response message', () => {
|
it('creates a response message', () => {
|
||||||
expect(makeResponse(1, { status: 'ok' })).toEqual({
|
expect(makeResponse(1, { status: 'ok' })).toEqual({
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export interface NodeLocationGetParams {
|
|||||||
connectionId: string;
|
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 ────────────────────────────────────────────
|
// ── Server → Client ────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GatewayResponse {
|
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 {
|
export function makeResponse(id: number, result: unknown): GatewayResponse {
|
||||||
return { id, result };
|
return { id, result };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ describe('GatewayServer integration', () => {
|
|||||||
expect(methods).toContain('tools.invoke');
|
expect(methods).toContain('tools.invoke');
|
||||||
expect(methods).toContain('canvas.put');
|
expect(methods).toContain('canvas.put');
|
||||||
expect(methods).toContain('canvas.list');
|
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 () => {
|
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
@@ -220,6 +220,48 @@ export class GatewayServer {
|
|||||||
}
|
}
|
||||||
return sorted;
|
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: () => ({
|
getUsage: () => ({
|
||||||
totalSessions: this.config.sessionManager.listSessions().length,
|
totalSessions: this.config.sessionManager.listSessions().length,
|
||||||
activeConnections: this.sessionBridge.connectionCount,
|
activeConnections: this.sessionBridge.connectionCount,
|
||||||
@@ -342,6 +384,16 @@ export class GatewayServer {
|
|||||||
location,
|
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)
|
// Config handlers (only if config object is provided)
|
||||||
@@ -688,7 +740,7 @@ export class GatewayServer {
|
|||||||
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
|
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
|
||||||
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
||||||
roleScopes: {
|
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'],
|
observer: ['node.capabilities.get', 'node.location.get'],
|
||||||
automation: ['node.capabilities.get', 'node.location.get'],
|
automation: ['node.capabilities.get', 'node.location.get'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user