From 4a3c9e7fac15ed9a8b4966d2482d334bcf7b7366 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 19:09:13 -0800 Subject: [PATCH] feat(companion): add heartbeat loop success callback --- README.md | 2 +- docs/plans/state.json | 13 +++++++++++++ src/companion/heartbeatLoop.test.ts | 15 +++++++++++++++ src/companion/heartbeatLoop.ts | 6 +++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd28fd1..93eb538 100644 --- a/README.md +++ b/README.md @@ -1200,7 +1200,7 @@ Companion runtime helper: - `createHeartbeatLoop()` convenience helper that returns a bound `CompanionHeartbeatLoop` - optional `defaultSessionId` for canvas helper calls so `sessionId` can be omitted per call - `dispose()` lifecycle helper for unified runtime teardown -- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load, `tickNow()` for manual sends, error hooks, failure observability (`failureCount`, `lastFailure`), and optional auto-stop after repeated failures. +- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load, `tickNow()` for manual sends, success/error hooks, failure observability (`failureCount`, `lastFailure`), and optional auto-stop after repeated failures. ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index 1e59929..7198a23 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -487,6 +487,19 @@ ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/heartbeatLoop.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-heartbeat-loop-success-hook": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added `onSuccess` callback support to `CompanionHeartbeatLoop` so callers can observe successful heartbeat responses for telemetry and health instrumentation.", + "files_modified": [ + "src/companion/heartbeatLoop.ts", + "src/companion/heartbeatLoop.test.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/runtimeClient.test.ts 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/heartbeatLoop.test.ts b/src/companion/heartbeatLoop.test.ts index 431dca5..883533a 100644 --- a/src/companion/heartbeatLoop.test.ts +++ b/src/companion/heartbeatLoop.test.ts @@ -81,6 +81,21 @@ describe('CompanionHeartbeatLoop', () => { loop.stop(); }); + it('calls onSuccess with heartbeat result payload', async () => { + const result = buildStatusResult(); + const publishHeartbeat = vi.fn(async () => result); + const onSuccess = vi.fn(); + const loop = new CompanionHeartbeatLoop( + { publishHeartbeat }, + { intervalMs: 200, onSuccess }, + ); + + loop.start(); + await Promise.resolve(); + expect(onSuccess).toHaveBeenCalledWith(result); + loop.stop(); + }); + it('passes buildHeartbeat payload into publishHeartbeat', async () => { const publishHeartbeat = vi.fn(async () => buildStatusResult()); const buildHeartbeat = vi.fn(() => ({ statusText: 'loop', powerSource: 'ac' as const })); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts index 88c186f..c897b21 100644 --- a/src/companion/heartbeatLoop.ts +++ b/src/companion/heartbeatLoop.ts @@ -9,6 +9,7 @@ export interface CompanionHeartbeatLoopOptions { intervalMs?: number; jitterRatio?: number; buildHeartbeat?: () => HeartbeatStatusInput | Promise; + onSuccess?: (result: NodeStatusSetResult) => void; onError?: (error: Error) => void; maxConsecutiveFailures?: number; onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void; @@ -24,6 +25,7 @@ export class CompanionHeartbeatLoop { private readonly intervalMs: number; private readonly jitterRatio: number; private readonly buildHeartbeat?: () => HeartbeatStatusInput | Promise; + private readonly onSuccess?: (result: NodeStatusSetResult) => void; private readonly onError?: (error: Error) => void; private readonly maxConsecutiveFailures: number; private readonly onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void; @@ -56,6 +58,7 @@ export class CompanionHeartbeatLoop { this.intervalMs = intervalMs; this.jitterRatio = jitterRatio; this.buildHeartbeat = options.buildHeartbeat; + this.onSuccess = options.onSuccess; this.onError = options.onError; this.maxConsecutiveFailures = maxConsecutiveFailures; this.onFailureLimitReached = options.onFailureLimitReached; @@ -129,9 +132,10 @@ export class CompanionHeartbeatLoop { this.inFlight = true; try { const payload = this.buildHeartbeat ? await this.buildHeartbeat() : undefined; - await this.publisher.publishHeartbeat(payload); + const result = await this.publisher.publishHeartbeat(payload); this.consecutiveFailures = 0; this.lastError = null; + this.onSuccess?.(result); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.consecutiveFailures += 1;