feat(companion): add reusable heartbeat loop utility

This commit is contained in:
William Valentin
2026-02-16 18:39:34 -08:00
parent 017c5a6b0c
commit 56d06e4827
5 changed files with 208 additions and 0 deletions
+1
View File
@@ -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
+14
View File
@@ -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",
+103
View File
@@ -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<NodeStatusSetResult>>()
.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);
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { HeartbeatStatusInput } from './platformClients.js';
import type { NodeStatusSetResult } from './runtimeClient.js';
export interface HeartbeatPublisher {
publishHeartbeat(input?: HeartbeatStatusInput): Promise<NodeStatusSetResult>;
}
export interface CompanionHeartbeatLoopOptions {
intervalMs?: number;
buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>;
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<HeartbeatStatusInput>;
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<void> {
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();
}
}
}
+5
View File
@@ -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';