From 5db7beeb538410be3b39f5d8637e2d28f5b84ffc Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:35:58 -0800 Subject: [PATCH] feat(companion): add publishHeartbeat helper across platform clients --- README.md | 1 + docs/plans/state.json | 15 ++++++++ src/companion/index.ts | 1 + .../platformClients.integration.test.ts | 2 +- src/companion/platformClients.test.ts | 15 ++++++++ src/companion/platformClients.ts | 38 +++++++++++++++++++ 6 files changed, 71 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1977225..3601884 100644 --- a/README.md +++ b/README.md @@ -1196,6 +1196,7 @@ Companion runtime helper: - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) - `AndroidCompanionClient` (`platform: "android"`, FCM push registration) - shared `bootstrap()` helper (`register` + `getCapabilities`) for startup handshakes + - shared `publishHeartbeat()` helper for periodic `node.status.set` updates with safe defaults ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index 5b4bc19..483196d 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -217,6 +217,21 @@ ], "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-platform-heartbeat-helper": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added `publishHeartbeat()` convenience methods across platform companion clients to standardize periodic status reporting with safe defaults (`statusText: heartbeat`, `powerSource: unknown`), plus test and docs updates.", + "files_modified": [ + "src/companion/platformClients.ts", + "src/companion/platformClients.test.ts", + "src/companion/platformClients.integration.test.ts", + "src/companion/index.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/index.ts b/src/companion/index.ts index c6affd0..2204a9c 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -41,5 +41,6 @@ export type { PlatformClientOptions, RegisterPushTokenInput, SharedStatusInput, + HeartbeatStatusInput, PlatformBootstrapResult, } from './platformClients.js'; diff --git a/src/companion/platformClients.integration.test.ts b/src/companion/platformClients.integration.test.ts index 9a4be72..d3c0167 100644 --- a/src/companion/platformClients.integration.test.ts +++ b/src/companion/platformClients.integration.test.ts @@ -121,7 +121,7 @@ describe('platform clients integration', () => { const boot = await client.bootstrap(); expect(boot.register.registered).toBe(true); expect(boot.capabilities.node.id).toBe('macos-e2e'); - const status = await client.setStatus({ + const status = await client.publishHeartbeat({ appVersion: '1.0.0', statusText: 'menu-bar-active', powerSource: 'ac', diff --git a/src/companion/platformClients.test.ts b/src/companion/platformClients.test.ts index 6e4c33a..78e248a 100644 --- a/src/companion/platformClients.test.ts +++ b/src/companion/platformClients.test.ts @@ -168,4 +168,19 @@ describe('platform companion clients', () => { capabilities: expect.any(Object), }); }); + + it('publishHeartbeat uses safe defaults for status payload', async () => { + const mock = createRuntimeMock(); + const client = new AndroidCompanionClient({ runtime: mock.runtime, nodeId: 'android-node' }); + + await client.publishHeartbeat(); + + expect(mock.setNodeStatus).toHaveBeenCalledWith( + expect.objectContaining({ + platform: 'android', + statusText: 'heartbeat', + powerSource: 'unknown', + }), + ); + }); }); diff --git a/src/companion/platformClients.ts b/src/companion/platformClients.ts index a814fa8..d784444 100644 --- a/src/companion/platformClients.ts +++ b/src/companion/platformClients.ts @@ -38,6 +38,14 @@ export type SharedStatusInput = Omit< 'platform' >; +export interface HeartbeatStatusInput { + appVersion?: SharedStatusInput['appVersion']; + deviceName?: SharedStatusInput['deviceName']; + statusText?: SharedStatusInput['statusText']; + batteryPct?: SharedStatusInput['batteryPct']; + powerSource?: SharedStatusInput['powerSource']; +} + export interface PlatformBootstrapResult { register: NodeRegisterResult; capabilities: NodeCapabilitiesResult; @@ -96,6 +104,16 @@ export class MacOSCompanionClient { }); } + publishHeartbeat(input: HeartbeatStatusInput = {}): Promise { + return this.setStatus({ + appVersion: input.appVersion, + deviceName: input.deviceName, + statusText: input.statusText ?? 'heartbeat', + batteryPct: input.batteryPct, + powerSource: input.powerSource ?? 'unknown', + }); + } + setLocation(location: SetNodeLocationInput): Promise { return this.runtime.setNodeLocation(location); } @@ -195,6 +213,16 @@ export class IOSCompanionClient { }); } + publishHeartbeat(input: HeartbeatStatusInput = {}): Promise { + return this.setStatus({ + appVersion: input.appVersion, + deviceName: input.deviceName, + statusText: input.statusText ?? 'heartbeat', + batteryPct: input.batteryPct, + powerSource: input.powerSource ?? 'unknown', + }); + } + setLocation(location: SetNodeLocationInput): Promise { return this.runtime.setNodeLocation(location); } @@ -294,6 +322,16 @@ export class AndroidCompanionClient { }); } + publishHeartbeat(input: HeartbeatStatusInput = {}): Promise { + return this.setStatus({ + appVersion: input.appVersion, + deviceName: input.deviceName, + statusText: input.statusText ?? 'heartbeat', + batteryPct: input.batteryPct, + powerSource: input.powerSource ?? 'unknown', + }); + } + setLocation(location: SetNodeLocationInput): Promise { return this.runtime.setNodeLocation(location); }