From 56d06e4827bac076483c1808341e6e1c026d9af2 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:39:34 -0800 Subject: [PATCH] feat(companion): add reusable heartbeat loop utility --- README.md | 1 + docs/plans/state.json | 14 ++++ src/companion/heartbeatLoop.test.ts | 103 ++++++++++++++++++++++++++++ src/companion/heartbeatLoop.ts | 85 +++++++++++++++++++++++ src/companion/index.ts | 5 ++ 5 files changed, 208 insertions(+) create mode 100644 src/companion/heartbeatLoop.test.ts create mode 100644 src/companion/heartbeatLoop.ts diff --git a/README.md b/README.md index 09db157..94e23b4 100644 --- a/README.md +++ b/README.md @@ -1197,6 +1197,7 @@ Companion runtime helper: - `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 +- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety and error hooks. ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index e36d1a2..8aaba63 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -257,6 +257,20 @@ ], "test_status": "pnpm test:run src/companion/platformClients.integration.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts + pnpm typecheck passing" }, + "companion-heartbeat-loop-utility": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added `CompanionHeartbeatLoop`, a reusable periodic heartbeat scheduler for companion runtimes that invokes `publishHeartbeat` with optional payload builders, idempotent start/stop behavior, and error hooks.", + "files_modified": [ + "src/companion/heartbeatLoop.ts", + "src/companion/heartbeatLoop.test.ts", + "src/companion/index.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/platformClients.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 new file mode 100644 index 0000000..e237cf6 --- /dev/null +++ b/src/companion/heartbeatLoop.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CompanionHeartbeatLoop } from './heartbeatLoop.js'; +import type { NodeStatusSetResult } from './runtimeClient.js'; + +function buildStatusResult(): NodeStatusSetResult { + return { + updated: true, + node: { id: 'node-1', role: 'companion' }, + status: { + platform: 'macos', + powerSource: 'unknown', + reportedAt: Date.now(), + }, + }; +} + +describe('CompanionHeartbeatLoop', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs immediately by default and continues on interval', async () => { + const publishHeartbeat = vi.fn(async () => buildStatusResult()); + const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 1000 }); + + loop.start(); + await Promise.resolve(); + expect(publishHeartbeat).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(publishHeartbeat).toHaveBeenCalledTimes(2); + + loop.stop(); + }); + + it('supports delayed first run when runImmediately=false', async () => { + const publishHeartbeat = vi.fn(async () => buildStatusResult()); + const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 500 }); + + loop.start(false); + expect(publishHeartbeat).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(500); + expect(publishHeartbeat).toHaveBeenCalledTimes(1); + + 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 })); + const loop = new CompanionHeartbeatLoop( + { publishHeartbeat }, + { intervalMs: 500, buildHeartbeat }, + ); + + loop.start(); + await Promise.resolve(); + + expect(buildHeartbeat).toHaveBeenCalledTimes(1); + expect(publishHeartbeat).toHaveBeenCalledWith({ statusText: 'loop', powerSource: 'ac' }); + + loop.stop(); + }); + + it('reports errors through onError and keeps scheduling', async () => { + const publishHeartbeat = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue(buildStatusResult()); + const onError = vi.fn(); + const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 400, onError }); + + loop.start(); + await Promise.resolve(); + expect(onError).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(400); + expect(publishHeartbeat).toHaveBeenCalledTimes(2); + + loop.stop(); + }); + + it('is idempotent for repeated start and stop calls', async () => { + const publishHeartbeat = vi.fn(async () => buildStatusResult()); + const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 250 }); + + loop.start(); + loop.start(); + await Promise.resolve(); + expect(publishHeartbeat).toHaveBeenCalledTimes(1); + + loop.stop(); + loop.stop(); + await vi.advanceTimersByTimeAsync(1000); + expect(publishHeartbeat).toHaveBeenCalledTimes(1); + expect(loop.running).toBe(false); + }); +}); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts new file mode 100644 index 0000000..c165085 --- /dev/null +++ b/src/companion/heartbeatLoop.ts @@ -0,0 +1,85 @@ +import type { HeartbeatStatusInput } from './platformClients.js'; +import type { NodeStatusSetResult } from './runtimeClient.js'; + +export interface HeartbeatPublisher { + publishHeartbeat(input?: HeartbeatStatusInput): Promise; +} + +export interface CompanionHeartbeatLoopOptions { + intervalMs?: number; + buildHeartbeat?: () => HeartbeatStatusInput | Promise; + onError?: (error: Error) => void; +} + +/** + * Lightweight periodic heartbeat loop for companion runtimes. + * Calls `publishHeartbeat` on a fixed interval with optional dynamic payload generation. + */ +export class CompanionHeartbeatLoop { + private readonly publisher: HeartbeatPublisher; + private readonly intervalMs: number; + private readonly buildHeartbeat?: () => HeartbeatStatusInput | Promise; + private readonly onError?: (error: Error) => void; + + private timer: NodeJS.Timeout | null = null; + private started = false; + private inFlight = false; + + constructor(publisher: HeartbeatPublisher, options: CompanionHeartbeatLoopOptions = {}) { + this.publisher = publisher; + this.intervalMs = options.intervalMs ?? 30_000; + this.buildHeartbeat = options.buildHeartbeat; + this.onError = options.onError; + } + + get running(): boolean { + return this.started; + } + + start(runImmediately = true): void { + if (this.started) { + return; + } + this.started = true; + + if (runImmediately) { + void this.tick(); + return; + } + this.scheduleNext(); + } + + stop(): void { + this.started = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private scheduleNext(): void { + if (!this.started) { + return; + } + this.timer = setTimeout(() => { + void this.tick(); + }, this.intervalMs); + } + + private async tick(): Promise { + if (!this.started || this.inFlight) { + return; + } + this.inFlight = true; + try { + const payload = this.buildHeartbeat ? await this.buildHeartbeat() : undefined; + await this.publisher.publishHeartbeat(payload); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.onError?.(err); + } finally { + this.inFlight = false; + this.scheduleNext(); + } + } +} diff --git a/src/companion/index.ts b/src/companion/index.ts index 2204a9c..0bc7c37 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -7,6 +7,7 @@ export { IOSCompanionClient, AndroidCompanionClient, } from './platformClients.js'; +export { CompanionHeartbeatLoop } from './heartbeatLoop.js'; export type { CompanionRuntimeClientOptions, @@ -44,3 +45,7 @@ export type { HeartbeatStatusInput, PlatformBootstrapResult, } from './platformClients.js'; +export type { + HeartbeatPublisher, + CompanionHeartbeatLoopOptions, +} from './heartbeatLoop.js';