diff --git a/README.md b/README.md index ea9d906..dc50cad 100644 --- a/README.md +++ b/README.md @@ -931,6 +931,9 @@ Methods: - `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status). - `system.capabilities` returns gateway protocol and node policy snapshot. +Companion runtime helper: +- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, and `system.nodes`. + ## Canvas / A2UI Foundation Gateway provides a session-scoped canvas artifact API for companion/UI surfaces: diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index fae7210..38bc367 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1290,3 +1290,4 @@ For more implementation details, see: - Protocol types: `src/gateway/protocol.ts` - Handlers: `src/gateway/handlers/` - Gateway server: `src/gateway/server.ts` +- Companion runtime client helper: `src/companion/runtimeClient.ts` diff --git a/docs/architecture/CONTRIBUTOR_MAP.md b/docs/architecture/CONTRIBUTOR_MAP.md index ca30257..ae9cbc8 100644 --- a/docs/architecture/CONTRIBUTOR_MAP.md +++ b/docs/architecture/CONTRIBUTOR_MAP.md @@ -22,6 +22,7 @@ src/ models/ Provider clients + model router + retry/cost/capabilities channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/LINE/Feishu/Zalo/etc.) gateway/ WebSocket JSON-RPC server + web UI + handlers + companion/ Node-side companion runtime client for gateway node/system RPC memory/ Hybrid search + embeddings + persistence session/ SQLite store + session mgmt cli/ CLI entrypoints + setup wizard diff --git a/docs/plans/2026-02-16-companion-runtime-client-foundation-checklist.md b/docs/plans/2026-02-16-companion-runtime-client-foundation-checklist.md new file mode 100644 index 0000000..dd72857 --- /dev/null +++ b/docs/plans/2026-02-16-companion-runtime-client-foundation-checklist.md @@ -0,0 +1,37 @@ +# Companion Runtime Client Foundation Checklist (2026-02-16) + +## Scope + +- Add a reusable Node/WebSocket runtime client for companion apps to call gateway node/system RPC methods without manually handling request IDs, pending maps, or error-code parsing. +- Keep API focused on current companion milestones: + - `node.register` + - `node.capabilities.get` + - `node.location.set/get` + - `node.status.set` + - `node.push_token.set` + - `system.capabilities` + - `system.nodes` + +## Implementation + +- Added `src/companion/runtimeClient.ts`: + - `CompanionRuntimeClient` typed wrapper around gateway JSON-RPC over `ws`. + - `GatewayRpcError` carrying gateway error code + message. + - Connection lifecycle (`connect`, `disconnect`, `connected`) and typed `call()`. + - Method-specific wrappers for node/system companion RPCs. + - Token query-param support for gateway bearer auth. +- Added `src/companion/index.ts` exports for companion runtime integration. +- Added `src/companion/runtimeClient.test.ts` integration coverage against live `GatewayServer` fixture. + +## Validation + +- Companion runtime tests: + - registration + capability flow + - status/location/push updates and `system.nodes` visibility + - RPC error mapping into `GatewayRpcError` + +## Docs Updated + +- `README.md` (Gateway Node Capability Negotiation section): references `src/companion/runtimeClient.ts` helper. +- `docs/api/PROTOCOL.md`: implementation reference updated with runtime client helper. +- `docs/architecture/CONTRIBUTOR_MAP.md`: repo tour now includes `src/companion/`. diff --git a/docs/plans/state.json b/docs/plans/state.json index 8f19797..98a3f5b 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -707,6 +707,25 @@ ], "test_status": "pnpm test:run src/channels/zalo/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck + pnpm build passing" }, + "companion-runtime-client-foundation": { + "file": "2026-02-16-companion-runtime-client-foundation-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Added a reusable companion runtime gateway client (`CompanionRuntimeClient`) for Node/WebSocket JSON-RPC with typed wrappers for node registration/capability/status/location/push and operator system capability/node listing methods, including RPC error-code mapping and integration tests.", + "files_created": [ + "docs/plans/2026-02-16-companion-runtime-client-foundation-checklist.md", + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "src/companion/index.ts" + ], + "files_modified": [ + "README.md", + "docs/api/PROTOCOL.md", + "docs/architecture/CONTRIBUTOR_MAP.md" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts + pnpm typecheck + pnpm build passing" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3268,7 +3287,7 @@ } }, "overall_progress": { - "total_test_count": 1814, + "total_test_count": 1817, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3288,7 +3307,7 @@ "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: companion app runtime clients (macOS/iOS/Android implementation) — open next scoped implementation checklist" + "next_up": "OpenClaw gap: implement macOS/iOS/Android companion runtime clients on top of `src/companion/runtimeClient.ts`" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/companion/index.ts b/src/companion/index.ts new file mode 100644 index 0000000..92cd25f --- /dev/null +++ b/src/companion/index.ts @@ -0,0 +1,25 @@ +export { + CompanionRuntimeClient, + GatewayRpcError, +} from './runtimeClient.js'; + +export type { + CompanionRuntimeClientOptions, + RegisterNodeInput, + ListNodesInput, + SetNodeStatusInput, + SetNodeLocationInput, + SetNodePushTokenInput, + NodeRegisterResult, + NodeCapabilitiesResult, + NodeStatusSetResult, + NodeLocationSetResult, + NodeLocationGetResult, + NodePushTokenSetResult, + SystemCapabilitiesResult, + SystemNodesResult, + SystemNodeEntry, + NodeLocation, + NodeStatus, + NodePushSummary, +} from './runtimeClient.js'; diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts new file mode 100644 index 0000000..91dcdaa --- /dev/null +++ b/src/companion/runtimeClient.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { resolve } from 'path'; +import { createServer } from 'net'; +import type { GatewayServerConfig } from '../gateway/server.js'; +import { GatewayServer } from '../gateway/server.js'; +import { CompanionRuntimeClient, GatewayRpcError } from './runtimeClient.js'; + +async function canListenOnLocalhost(): Promise { + return new Promise((resolvePromise) => { + const s = createServer(); + s.once('error', () => resolvePromise(false)); + s.listen(0, '127.0.0.1', () => { + s.close(() => resolvePromise(true)); + }); + }); +} + +const mockSession = { + id: 'test', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + setHistory: vi.fn(), + replaceHistory: vi.fn(), +}; + +const mockSessionManager = { + getSession: vi.fn(() => mockSession), + listSessions: vi.fn(() => ['ws:test']), + transferSession: vi.fn(), + closeSession: vi.fn(), +}; + +const mockModelClient = { + chat: vi.fn(async () => ({ + content: 'Hello from Flynn!', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + })), +}; + +const mockToolRegistry = { + register: vi.fn(), + get: vi.fn(), + list: vi.fn(() => []), + filteredList: vi.fn(() => []), + toAnthropicFormat: vi.fn(() => []), + toOpenAIFormat: vi.fn(() => []), + filteredToAnthropicFormat: vi.fn(() => []), + filteredToOpenAIFormat: vi.fn(() => []), +}; + +const mockToolExecutor = { + execute: vi.fn(async () => ({ success: true, output: 'ok' })), +}; + +const TEST_PORT = 18911; +const TEST_TOKEN = 'runtime-client-token'; + +let LISTEN_ALLOWED = true; +let server: GatewayServer; + +beforeAll(async () => { + LISTEN_ALLOWED = await canListenOnLocalhost(); + if (!LISTEN_ALLOWED) { + return; + } + + server = new GatewayServer({ + port: TEST_PORT, + sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], + modelClient: mockModelClient, + systemPrompt: 'Test prompt', + toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], + toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], + version: '0.1.0-test', + uiDir: resolve(import.meta.dirname, '../gateway/ui'), + auth: { token: TEST_TOKEN }, + nodes: { + enabled: true, + allowedRoles: ['companion'], + featureGates: { 'ui.canvas': true }, + locationEnabled: true, + pushEnabled: true, + }, + }); + + await server.start(); +}); + +afterAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } + await server.stop(); +}); + +describe('CompanionRuntimeClient', () => { + it('connects and performs node registration + capability discovery', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const client = new CompanionRuntimeClient({ + url: `ws://127.0.0.1:${TEST_PORT}`, + token: TEST_TOKEN, + }); + + await client.connect(); + expect(client.connected).toBe(true); + + try { + const register = await client.registerNode({ + nodeId: 'macos-companion', + role: 'companion', + capabilities: ['ui.canvas', 'node.location.read'], + }); + + expect(register.registered).toBe(true); + expect(register.node.id).toBe('macos-companion'); + expect(register.protocol.serverVersion).toBe(1); + expect(register.capabilities.enabled).toContain('ui.canvas'); + + const nodeCaps = await client.getNodeCapabilities(); + expect(nodeCaps.node.id).toBe('macos-companion'); + expect(nodeCaps.capabilities.enabled).toContain('ui.canvas'); + + const systemCaps = await client.getSystemCapabilities(); + expect(systemCaps.nodes.enabled).toBe(true); + expect(systemCaps.nodes.registered).toBe(true); + expect(systemCaps.nodes.nodeId).toBe('macos-companion'); + } finally { + client.disconnect(); + } + }); + + it('updates status/location/push and exposes them through system.nodes', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const client = new CompanionRuntimeClient({ + url: `ws://127.0.0.1:${TEST_PORT}`, + token: TEST_TOKEN, + }); + + await client.connect(); + + try { + await client.registerNode({ + nodeId: 'ios-companion', + role: 'companion', + capabilities: ['node.location.write', 'node.push.register'], + }); + + const status = await client.setNodeStatus({ + platform: 'ios', + appVersion: '1.2.3', + batteryPct: 77, + powerSource: 'battery', + }); + expect(status.updated).toBe(true); + expect(status.status.platform).toBe('ios'); + + const location = await client.setNodeLocation({ + latitude: 37.78, + longitude: -122.41, + source: 'gps', + }); + expect(location.updated).toBe(true); + expect(location.location.source).toBe('gps'); + + const locationGet = await client.getNodeLocation(); + expect(locationGet.location?.latitude).toBe(37.78); + + const push = await client.setNodePushToken({ + provider: 'apns', + token: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + topic: 'dev.flynn.companion', + environment: 'production', + }); + expect(push.updated).toBe(true); + expect(push.push.provider).toBe('apns'); + expect(push.push.tokenPreview.startsWith('***')).toBe(true); + + const nodes = await client.listSystemNodes({ role: 'companion', platform: 'ios' }); + expect(nodes.summary.total).toBeGreaterThanOrEqual(1); + const current = nodes.nodes.find((entry) => entry.nodeId === 'ios-companion'); + expect(current).toBeTruthy(); + expect(current?.status?.platform).toBe('ios'); + expect(current?.push?.provider).toBe('apns'); + } finally { + client.disconnect(); + } + }); + + it('surfaces gateway errors as GatewayRpcError', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const client = new CompanionRuntimeClient({ + url: `ws://127.0.0.1:${TEST_PORT}`, + token: TEST_TOKEN, + }); + + await client.connect(); + + try { + await expect(client.registerNode({ + nodeId: 'observer-node', + role: 'observer', + capabilities: ['node.location.read'], + })).rejects.toBeInstanceOf(GatewayRpcError); + + await expect(client.registerNode({ + nodeId: 'observer-node', + role: 'observer', + capabilities: ['node.location.read'], + })).rejects.toMatchObject({ + code: -5, + }); + } finally { + client.disconnect(); + } + }); +}); diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts new file mode 100644 index 0000000..805f03e --- /dev/null +++ b/src/companion/runtimeClient.ts @@ -0,0 +1,429 @@ +import { WebSocket } from 'ws'; +import type { + NodeLocationSetParams, + NodePushTokenSetParams, + NodeStatusSetParams, +} from '../gateway/protocol.js'; +import { GATEWAY_PROTOCOL_VERSION } from '../gateway/protocol.js'; + +interface RpcSuccess { + id: number; + result: unknown; +} + +interface RpcFailure { + id: number; + error: { + code: number; + message: string; + }; +} + +interface RpcEvent { + id: number; + event: string; + data: unknown; +} + +type RpcMessage = RpcSuccess | RpcFailure | RpcEvent; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +}; + +export interface CompanionRuntimeClientOptions { + url: string; + token?: string; + requestTimeoutMs?: number; + websocketFactory?: (url: string) => WebSocket; +} + +export interface RegisterNodeInput { + nodeId: string; + role: string; + capabilities: string[]; + protocolVersion?: number; +} + +export interface NodeIdentitySummary { + id: string; + role: string; +} + +export interface NodeRegisterResult { + registered: boolean; + node: NodeIdentitySummary; + protocol: { + serverVersion: number; + clientVersion: number; + negotiatedVersion: number; + }; + capabilities: { + declared: string[]; + enabled: string[]; + }; +} + +export interface NodeCapabilitiesResult { + protocol: { + serverVersion: number; + nodeVersion: number; + negotiatedVersion: number; + }; + node: { + id: string; + role: string; + registeredAt: number; + }; + capabilities: { + declared: string[]; + enabled: string[]; + featureGates: Record; + }; +} + +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 NodeLocation { + latitude: number; + longitude: number; + accuracyMeters?: number; + altitudeMeters?: number; + headingDegrees?: number; + speedMps?: number; + source: 'gps' | 'network' | 'manual' | 'unknown'; + capturedAt: number; + receivedAt: number; +} + +export interface NodePushSummary { + provider: 'apns' | 'fcm'; + tokenPreview: string; + topic?: string; + environment?: 'sandbox' | 'production'; + registeredAt: number; +} + +export interface NodeStatusSetResult { + updated: boolean; + node: NodeIdentitySummary; + status: NodeStatus; +} + +export interface NodeLocationSetResult { + updated: boolean; + node: NodeIdentitySummary; + location: NodeLocation; +} + +export interface NodePushTokenSetResult { + updated: boolean; + node: NodeIdentitySummary; + push: NodePushSummary; +} + +export interface NodeLocationGetResult { + node: NodeIdentitySummary; + location: NodeLocation | null; +} + +export interface SystemCapabilitiesResult { + protocol: { + version: number; + }; + nodes: { + enabled: boolean; + locationEnabled: boolean; + pushEnabled: boolean; + allowedRoles: string[]; + registered: boolean; + role?: string; + nodeId?: string; + }; + featureGates: Record; +} + +export interface SystemNodeEntry { + connectionId: string; + nodeId: string; + role: string; + identity?: string; + protocolVersion: number; + capabilities: string[]; + registeredAt: number; + location?: NodeLocation; + status?: NodeStatus; + push?: NodePushSummary; +} + +export interface SystemNodesResult { + nodes: SystemNodeEntry[]; + summary: { + total: number; + }; +} + +export interface ListNodesInput { + role?: string; + platform?: string; + limit?: number; +} + +export interface SetNodeStatusInput extends Omit {} + +export interface SetNodeLocationInput extends Omit {} + +export interface SetNodePushTokenInput extends Omit {} + +export class GatewayRpcError extends Error { + readonly code: number; + + constructor(code: number, message: string) { + super(message); + this.name = 'GatewayRpcError'; + this.code = code; + } +} + +export class CompanionRuntimeClient { + private readonly url: string; + private readonly token?: string; + private readonly requestTimeoutMs: number; + private readonly websocketFactory: (url: string) => WebSocket; + + private ws: WebSocket | null = null; + private nextId = 1; + private pending = new Map(); + + constructor(options: CompanionRuntimeClientOptions) { + this.url = options.url; + this.token = options.token; + this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + this.websocketFactory = options.websocketFactory ?? ((url) => new WebSocket(url)); + } + + get connected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + async connect(): Promise { + if (this.connected) { + return; + } + + const ws = this.websocketFactory(withToken(this.url, this.token)); + + await new Promise((resolve, reject) => { + let settled = false; + + const onOpen = () => { + cleanup(); + settled = true; + this.ws = ws; + this.ws.on('message', (raw) => this.handleMessage(raw.toString())); + this.ws.on('close', () => this.rejectAllPending(new Error('WebSocket closed'))); + this.ws.on('error', () => { + // close event handles pending rejection + }); + resolve(); + }; + + const onError = (err: Error) => { + cleanup(); + settled = true; + reject(err); + }; + + const onClose = () => { + cleanup(); + if (!settled) { + settled = true; + reject(new Error('WebSocket closed before connection established')); + } + }; + + const cleanup = () => { + ws.off('open', onOpen); + ws.off('error', onError); + ws.off('close', onClose); + }; + + ws.once('open', onOpen); + ws.once('error', onError); + ws.once('close', onClose); + }); + } + + disconnect(code?: number, reason?: string): void { + if (!this.ws) { + return; + } + + const ws = this.ws; + this.ws = null; + this.rejectAllPending(new Error('Disconnected')); + ws.close(code, reason); + } + + async call(method: string, params?: Record): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not connected'); + } + + const id = this.nextId++; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`RPC timeout for method ${method}`)); + }, this.requestTimeoutMs); + + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + timeout, + }); + + this.ws?.send(JSON.stringify({ id, method, params: params ?? {} }), (err) => { + if (!err) { + return; + } + + const pending = this.pending.get(id); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + this.pending.delete(id); + reject(err); + }); + }); + } + + registerNode(input: RegisterNodeInput): Promise { + return this.call('node.register', { + nodeId: input.nodeId, + role: input.role, + protocolVersion: input.protocolVersion ?? GATEWAY_PROTOCOL_VERSION, + capabilities: input.capabilities, + }); + } + + getNodeCapabilities(): Promise { + return this.call('node.capabilities.get'); + } + + setNodeStatus(input: SetNodeStatusInput): Promise { + return this.call('node.status.set', { + platform: input.platform, + appVersion: input.appVersion, + deviceName: input.deviceName, + statusText: input.statusText, + batteryPct: input.batteryPct, + powerSource: input.powerSource, + }); + } + + setNodeLocation(input: SetNodeLocationInput): Promise { + return this.call('node.location.set', { + latitude: input.latitude, + longitude: input.longitude, + accuracyMeters: input.accuracyMeters, + altitudeMeters: input.altitudeMeters, + headingDegrees: input.headingDegrees, + speedMps: input.speedMps, + source: input.source, + capturedAt: input.capturedAt, + }); + } + + getNodeLocation(): Promise { + return this.call('node.location.get'); + } + + setNodePushToken(input: SetNodePushTokenInput): Promise { + return this.call('node.push_token.set', { + provider: input.provider, + token: input.token, + topic: input.topic, + environment: input.environment, + }); + } + + getSystemCapabilities(): Promise { + return this.call('system.capabilities'); + } + + listSystemNodes(input?: ListNodesInput): Promise { + return this.call('system.nodes', { + role: input?.role, + platform: input?.platform, + limit: input?.limit, + }); + } + + private handleMessage(raw: string): void { + let parsed: RpcMessage; + try { + parsed = JSON.parse(raw) as RpcMessage; + } catch { + return; + } + + if (!('id' in parsed) || typeof parsed.id !== 'number') { + return; + } + + if ('event' in parsed) { + return; + } + + const pending = this.pending.get(parsed.id); + if (!pending) { + return; + } + + this.pending.delete(parsed.id); + + if ('error' in parsed) { + pending.reject(new GatewayRpcError(parsed.error.code, parsed.error.message)); + return; + } + + pending.resolve(parsed.result); + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pending) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + } +} + +function withToken(url: string, token?: string): string { + if (!token) { + return url; + } + + const parsed = new URL(url); + parsed.searchParams.set('token', token); + return parsed.toString(); +}