feat(companion): add reconnect resilience
This commit is contained in:
@@ -1067,6 +1067,9 @@ Flynn now propagates a run-level abort signal into model/tool execution, so prov
|
|||||||
|
|
||||||
Register node role/capabilities for the current WebSocket connection.
|
Register node role/capabilities for the current WebSocket connection.
|
||||||
|
|
||||||
|
Registration is scoped to the connection. If a companion reconnects it must call `node.register` again
|
||||||
|
to restore node identity, capabilities, and access to `node.*` methods.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1840,5 +1843,5 @@ For more implementation details, see:
|
|||||||
- Protocol types: `src/gateway/protocol.ts`
|
- Protocol types: `src/gateway/protocol.ts`
|
||||||
- Handlers: `src/gateway/handlers/`
|
- Handlers: `src/gateway/handlers/`
|
||||||
- Gateway server: `src/gateway/server.ts`
|
- Gateway server: `src/gateway/server.ts`
|
||||||
- Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers, optional `autoConnect`)
|
- Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers, optional `autoConnect`/`autoReconnect`, connection event subscriptions)
|
||||||
- Platform companion wrappers: `src/companion/platformClients.ts`
|
- Platform companion wrappers: `src/companion/platformClients.ts`
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ Gateway streaming UX signals:
|
|||||||
|
|
||||||
- WebSocket `agent.send` emits `run_state` lifecycle events (`start`, `cancel_requested`, `cancelled`, `complete`, `error`) for UI/state rendering.
|
- WebSocket `agent.send` emits `run_state` lifecycle events (`start`, `cancel_requested`, `cancelled`, `complete`, `error`) for UI/state rendering.
|
||||||
- Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing.
|
- Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing.
|
||||||
|
- Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect and surface connection events.
|
||||||
|
|
||||||
Key files:
|
Key files:
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`.
|
|||||||
- Backend routing outcomes are auditable via `backend.route` / `backend.success` / `backend.fallback`, which enables offline canary evaluation without changing gateway protocol methods.
|
- Backend routing outcomes are auditable via `backend.route` / `backend.success` / `backend.fallback`, which enables offline canary evaluation without changing gateway protocol methods.
|
||||||
- Run lifecycle/cancel intent and reaction decisions are emitted to audit logs, and aggregated into `system.metrics` counters (runStates, cancelLatencyMs, reactions) for dashboards.
|
- Run lifecycle/cancel intent and reaction decisions are emitted to audit logs, and aggregated into `system.metrics` counters (runStates, cancelLatencyMs, reactions) for dashboards.
|
||||||
- Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing.
|
- Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing.
|
||||||
|
- Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods.
|
||||||
|
|
||||||
## Component Map
|
## Component Map
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -6699,10 +6699,28 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/automation/reactions.test.ts src/config/schema.test.ts src/daemon/routing.test.ts passing"
|
"test_status": "pnpm test:run src/automation/reactions.test.ts src/config/schema.test.ts src/daemon/routing.test.ts passing"
|
||||||
|
},
|
||||||
|
"deeper-surfaces-phase3-companion-reconnect": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-25",
|
||||||
|
"updated": "2026-02-25",
|
||||||
|
"summary": "Hardened companion runtime connectivity with auto-reconnect support, connection event subscriptions, CLI re-registration/heartbeat resilience, and updated protocol/architecture notes plus targeted tests.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/companion/runtimeClient.ts",
|
||||||
|
"src/companion/runtimeClient.test.ts",
|
||||||
|
"src/companion/index.ts",
|
||||||
|
"src/cli/companion.ts",
|
||||||
|
"src/cli/companion.test.ts",
|
||||||
|
"docs/api/PROTOCOL.md",
|
||||||
|
"docs/architecture/AGENT_DIAGRAM.md",
|
||||||
|
"docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/cli/companion.test.ts passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 2018,
|
"total_test_count": 2020,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ const {
|
|||||||
mockRuntimeCtorArgs,
|
mockRuntimeCtorArgs,
|
||||||
mockRuntimeInstances,
|
mockRuntimeInstances,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const runtimeCtorArgs: Array<{ url: string; token?: string }> = [];
|
const runtimeCtorArgs: Array<{ url: string; token?: string; autoReconnect?: boolean }> = [];
|
||||||
const runtimeInstances: Array<{
|
const runtimeInstances: Array<{
|
||||||
connect: ReturnType<typeof vi.fn>;
|
connect: ReturnType<typeof vi.fn>;
|
||||||
registerNode: ReturnType<typeof vi.fn>;
|
registerNode: ReturnType<typeof vi.fn>;
|
||||||
setNodeStatus: ReturnType<typeof vi.fn>;
|
setNodeStatus: ReturnType<typeof vi.fn>;
|
||||||
subscribeAgentStream: ReturnType<typeof vi.fn>;
|
subscribeAgentStream: ReturnType<typeof vi.fn>;
|
||||||
subscribeAgentTyping: ReturnType<typeof vi.fn>;
|
subscribeAgentTyping: ReturnType<typeof vi.fn>;
|
||||||
|
subscribeConnectionEvents: ReturnType<typeof vi.fn>;
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
@@ -43,7 +44,13 @@ vi.mock('./shared.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../companion/index.js', () => ({
|
vi.mock('../companion/index.js', () => ({
|
||||||
CompanionRuntimeClient: class {
|
CompanionRuntimeClient: class {
|
||||||
connect = vi.fn(async () => undefined);
|
private connectionHandlers: Array<(event: { status: string }) => void> = [];
|
||||||
|
connect = vi.fn(async () => {
|
||||||
|
for (const handler of this.connectionHandlers) {
|
||||||
|
handler({ status: 'connected' });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
registerNode = vi.fn(async ({ nodeId, role, capabilities }: { nodeId: string; role: string; capabilities: string[] }) => ({
|
registerNode = vi.fn(async ({ nodeId, role, capabilities }: { nodeId: string; role: string; capabilities: string[] }) => ({
|
||||||
registered: true,
|
registered: true,
|
||||||
node: { id: nodeId, role },
|
node: { id: nodeId, role },
|
||||||
@@ -53,9 +60,13 @@ vi.mock('../companion/index.js', () => ({
|
|||||||
setNodeStatus = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } }));
|
setNodeStatus = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } }));
|
||||||
subscribeAgentStream = vi.fn(() => () => undefined);
|
subscribeAgentStream = vi.fn(() => () => undefined);
|
||||||
subscribeAgentTyping = vi.fn(() => () => undefined);
|
subscribeAgentTyping = vi.fn(() => () => undefined);
|
||||||
|
subscribeConnectionEvents = vi.fn((handler: (event: { status: string }) => void) => {
|
||||||
|
this.connectionHandlers.push(handler);
|
||||||
|
return () => undefined;
|
||||||
|
});
|
||||||
disconnect = vi.fn(() => undefined);
|
disconnect = vi.fn(() => undefined);
|
||||||
|
|
||||||
constructor(opts: { url: string; token?: string }) {
|
constructor(opts: { url: string; token?: string; autoReconnect?: boolean }) {
|
||||||
mockRuntimeCtorArgs.push(opts);
|
mockRuntimeCtorArgs.push(opts);
|
||||||
mockRuntimeInstances.push(this);
|
mockRuntimeInstances.push(this);
|
||||||
}
|
}
|
||||||
@@ -89,7 +100,7 @@ describe('companion command', () => {
|
|||||||
await program.parseAsync(['node', 'test', 'companion', '--once']);
|
await program.parseAsync(['node', 'test', 'companion', '--once']);
|
||||||
|
|
||||||
expect(mockGetConfigPath).toHaveBeenCalledOnce();
|
expect(mockGetConfigPath).toHaveBeenCalledOnce();
|
||||||
expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://127.0.0.1:18888', token: 'config-token' }]);
|
expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://127.0.0.1:18888', token: 'config-token', autoReconnect: false }]);
|
||||||
expect(mockRuntimeInstances[0]?.connect).toHaveBeenCalledOnce();
|
expect(mockRuntimeInstances[0]?.connect).toHaveBeenCalledOnce();
|
||||||
expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledOnce();
|
expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledOnce();
|
||||||
expect(mockRuntimeInstances[0]?.setNodeStatus).toHaveBeenCalledOnce();
|
expect(mockRuntimeInstances[0]?.setNodeStatus).toHaveBeenCalledOnce();
|
||||||
@@ -124,7 +135,7 @@ describe('companion command', () => {
|
|||||||
'node.push.register',
|
'node.push.register',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://10.0.0.5:19000', token: 'override-token' }]);
|
expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://10.0.0.5:19000', token: 'override-token', autoReconnect: false }]);
|
||||||
expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
nodeId: 'test-node',
|
nodeId: 'test-node',
|
||||||
capabilities: ['ui.canvas', 'node.push.register'],
|
capabilities: ['ui.canvas', 'node.push.register'],
|
||||||
@@ -149,4 +160,3 @@ describe('companion command', () => {
|
|||||||
errSpy.mockRestore();
|
errSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+75
-19
@@ -96,10 +96,13 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
|
|||||||
const runtime = new CompanionRuntimeClient({
|
const runtime = new CompanionRuntimeClient({
|
||||||
url: gatewayUrl,
|
url: gatewayUrl,
|
||||||
token: gatewayToken,
|
token: gatewayToken,
|
||||||
|
autoReconnect: !options.once,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
|
const stopSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
|
||||||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
|
let registrationPromise: Promise<void> | null = null;
|
||||||
|
let skipConnectRegistration = true;
|
||||||
|
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
if (heartbeatTimer) {
|
if (heartbeatTimer) {
|
||||||
@@ -109,6 +112,53 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
|
|||||||
runtime.disconnect(1000, 'Companion shutting down');
|
runtime.disconnect(1000, 'Companion shutting down');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startHeartbeat = (): void => {
|
||||||
|
if (options.once || heartbeatTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
void publishHeartbeat(runtime, platform).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Heartbeat failed: ${message}`);
|
||||||
|
});
|
||||||
|
}, heartbeatSeconds * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = (): void => {
|
||||||
|
if (!heartbeatTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
heartbeatTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerAndHeartbeat = async (label: 'connected' | 'reconnected'): Promise<void> => {
|
||||||
|
if (registrationPromise) {
|
||||||
|
return registrationPromise;
|
||||||
|
}
|
||||||
|
registrationPromise = (async () => {
|
||||||
|
const register = await runtime.registerNode({
|
||||||
|
nodeId,
|
||||||
|
role,
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
await publishHeartbeat(runtime, platform);
|
||||||
|
|
||||||
|
const verb = label === 'connected' ? 'Connected' : 'Reconnected';
|
||||||
|
console.log(`${verb} companion node ${register.node.id} (${platform}, role=${role})`);
|
||||||
|
console.log(`Gateway: ${gatewayUrl}`);
|
||||||
|
console.log(`Capabilities: ${capabilities.join(', ') || '(none)'}`);
|
||||||
|
|
||||||
|
startHeartbeat();
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await registrationPromise;
|
||||||
|
} finally {
|
||||||
|
registrationPromise = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const signal of stopSignals) {
|
for (const signal of stopSignals) {
|
||||||
process.once(signal, cleanup);
|
process.once(signal, cleanup);
|
||||||
}
|
}
|
||||||
@@ -128,32 +178,39 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
|
|||||||
console.log(`[agent.typing${session}] ${phase}`);
|
console.log(`[agent.typing${session}] ${phase}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
runtime.subscribeConnectionEvents((event) => {
|
||||||
|
if (event.status === 'connected') {
|
||||||
|
if (skipConnectRegistration) {
|
||||||
|
skipConnectRegistration = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void registerAndHeartbeat('reconnected').catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Companion re-registration failed: ${message}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'reconnecting') {
|
||||||
|
console.log(`Gateway disconnected. Reconnecting in ${Math.ceil(event.delayMs / 1000)}s (attempt ${event.attempt})...`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHeartbeat();
|
||||||
|
const reason = event.reason ? ` (${event.reason})` : '';
|
||||||
|
console.log(`Gateway disconnected${reason}.`);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runtime.connect();
|
await runtime.connect();
|
||||||
const register = await runtime.registerNode({
|
await registerAndHeartbeat('connected');
|
||||||
nodeId,
|
skipConnectRegistration = false;
|
||||||
role,
|
|
||||||
capabilities,
|
|
||||||
});
|
|
||||||
|
|
||||||
await publishHeartbeat(runtime, platform);
|
|
||||||
|
|
||||||
console.log(`Connected companion node ${register.node.id} (${platform}, role=${role})`);
|
|
||||||
console.log(`Gateway: ${gatewayUrl}`);
|
|
||||||
console.log(`Capabilities: ${capabilities.join(', ') || '(none)'}`);
|
|
||||||
|
|
||||||
if (options.once) {
|
if (options.once) {
|
||||||
cleanup();
|
cleanup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatTimer = setInterval(() => {
|
|
||||||
void publishHeartbeat(runtime, platform).catch((error: unknown) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error(`Heartbeat failed: ${message}`);
|
|
||||||
});
|
|
||||||
}, heartbeatSeconds * 1000);
|
|
||||||
|
|
||||||
await new Promise<void>(() => {
|
await new Promise<void>(() => {
|
||||||
// Keep process alive until interrupted.
|
// Keep process alive until interrupted.
|
||||||
});
|
});
|
||||||
@@ -186,4 +243,3 @@ export function registerCompanionCommand(program: Command): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type {
|
|||||||
CompanionEventName,
|
CompanionEventName,
|
||||||
CompanionEventPredicate,
|
CompanionEventPredicate,
|
||||||
CompanionEventEnvelope,
|
CompanionEventEnvelope,
|
||||||
|
CompanionConnectionEvent,
|
||||||
|
CompanionConnectionHandler,
|
||||||
RegisterNodeInput,
|
RegisterNodeInput,
|
||||||
ListNodesInput,
|
ListNodesInput,
|
||||||
SetNodeStatusInput,
|
SetNodeStatusInput,
|
||||||
|
|||||||
@@ -108,6 +108,22 @@ describe('CompanionRuntimeClient', () => {
|
|||||||
}).toThrow('requestTimeoutMs must be a positive number');
|
}).toThrow('requestTimeoutMs must be a positive number');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('validates reconnect delay options', () => {
|
||||||
|
expect(() => {
|
||||||
|
new CompanionRuntimeClient({
|
||||||
|
url: 'ws://127.0.0.1:1',
|
||||||
|
reconnectDelayMs: 0,
|
||||||
|
});
|
||||||
|
}).toThrow('reconnectDelayMs must be a positive number');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
new CompanionRuntimeClient({
|
||||||
|
url: 'ws://127.0.0.1:1',
|
||||||
|
reconnectMaxDelayMs: 0,
|
||||||
|
});
|
||||||
|
}).toThrow('reconnectMaxDelayMs must be a positive number');
|
||||||
|
});
|
||||||
|
|
||||||
it('dispatches gateway events to subscribed handlers and supports unsubscribe', () => {
|
it('dispatches gateway events to subscribed handlers and supports unsubscribe', () => {
|
||||||
const client = new CompanionRuntimeClient({
|
const client = new CompanionRuntimeClient({
|
||||||
url: 'ws://127.0.0.1:1',
|
url: 'ws://127.0.0.1:1',
|
||||||
@@ -845,6 +861,65 @@ describe('CompanionRuntimeClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits connection events and reconnects when enabled', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const events: Array<{ status: string }> = [];
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
class FakeWebSocket extends EventEmitter {
|
||||||
|
readyState: number = WebSocket.CONNECTING;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.readyState = WebSocket.OPEN;
|
||||||
|
this.emit('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(_payload: string, callback?: (error?: Error) => void): void {
|
||||||
|
callback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(_code?: number, _reason?: string): void {
|
||||||
|
this.readyState = WebSocket.CLOSED;
|
||||||
|
this.emit('close', 1006, Buffer.from('drop'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new CompanionRuntimeClient({
|
||||||
|
url: 'ws://127.0.0.1:1',
|
||||||
|
autoReconnect: true,
|
||||||
|
reconnectDelayMs: 10,
|
||||||
|
reconnectMaxDelayMs: 10,
|
||||||
|
websocketFactory: () => {
|
||||||
|
created += 1;
|
||||||
|
return new FakeWebSocket() as unknown as WebSocket;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.subscribeConnectionEvents((event) => {
|
||||||
|
events.push({ status: event.status });
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
expect(created).toBe(1);
|
||||||
|
expect(events.map((event) => event.status)).toEqual(['connected']);
|
||||||
|
|
||||||
|
const ws = (client as unknown as { ws: WebSocket | null }).ws;
|
||||||
|
ws?.close();
|
||||||
|
|
||||||
|
expect(events.map((event) => event.status)).toEqual(['connected', 'disconnected', 'reconnecting']);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(created).toBe(2);
|
||||||
|
expect(events.map((event) => event.status)).toEqual(['connected', 'disconnected', 'reconnecting', 'connected']);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('manual disconnect metadata is not overwritten by local close event', async () => {
|
it('manual disconnect metadata is not overwritten by local close event', async () => {
|
||||||
class FakeWebSocket extends EventEmitter {
|
class FakeWebSocket extends EventEmitter {
|
||||||
readyState: number = WebSocket.CONNECTING;
|
readyState: number = WebSocket.CONNECTING;
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export interface CompanionRuntimeClientOptions {
|
|||||||
token?: string;
|
token?: string;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
autoConnect?: boolean;
|
autoConnect?: boolean;
|
||||||
|
autoReconnect?: boolean;
|
||||||
|
reconnectDelayMs?: number;
|
||||||
|
reconnectMaxDelayMs?: number;
|
||||||
websocketFactory?: (url: string) => WebSocket;
|
websocketFactory?: (url: string) => WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +86,13 @@ export type CompanionEventEnvelope<TData = unknown> = {
|
|||||||
data: TData;
|
data: TData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CompanionConnectionEvent =
|
||||||
|
| { status: 'connected' }
|
||||||
|
| { status: 'disconnected'; code?: number; reason?: string }
|
||||||
|
| { status: 'reconnecting'; attempt: number; delayMs: number };
|
||||||
|
|
||||||
|
export type CompanionConnectionHandler = (event: CompanionConnectionEvent) => void;
|
||||||
|
|
||||||
export const COMPANION_EVENT_NAMES = {
|
export const COMPANION_EVENT_NAMES = {
|
||||||
agentStream: 'agent.stream',
|
agentStream: 'agent.stream',
|
||||||
agentTyping: 'agent.typing',
|
agentTyping: 'agent.typing',
|
||||||
@@ -307,6 +317,9 @@ export class CompanionRuntimeClient {
|
|||||||
private readonly token?: string;
|
private readonly token?: string;
|
||||||
private readonly requestTimeoutMs: number;
|
private readonly requestTimeoutMs: number;
|
||||||
private readonly autoConnect: boolean;
|
private readonly autoConnect: boolean;
|
||||||
|
private readonly autoReconnect: boolean;
|
||||||
|
private readonly reconnectInitialDelayMs: number;
|
||||||
|
private readonly reconnectMaxDelayMs: number;
|
||||||
private readonly websocketFactory: (url: string) => WebSocket;
|
private readonly websocketFactory: (url: string) => WebSocket;
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
@@ -314,19 +327,36 @@ export class CompanionRuntimeClient {
|
|||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private pending = new Map<number, PendingRequest>();
|
private pending = new Map<number, PendingRequest>();
|
||||||
private readonly eventHandlers = new Set<CompanionEventHandler>();
|
private readonly eventHandlers = new Set<CompanionEventHandler>();
|
||||||
|
private readonly connectionHandlers = new Set<CompanionConnectionHandler>();
|
||||||
private readonly pendingEventWaits = new Set<(error: Error) => void>();
|
private readonly pendingEventWaits = new Set<(error: Error) => void>();
|
||||||
private _lastDisconnectCode: number | undefined;
|
private _lastDisconnectCode: number | undefined;
|
||||||
private _lastDisconnectReason: string | undefined;
|
private _lastDisconnectReason: string | undefined;
|
||||||
|
private reconnectDelayMs: number;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private reconnectAttempt = 0;
|
||||||
|
private shouldReconnect = false;
|
||||||
|
|
||||||
constructor(options: CompanionRuntimeClientOptions) {
|
constructor(options: CompanionRuntimeClientOptions) {
|
||||||
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
||||||
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
||||||
throw new Error('requestTimeoutMs must be a positive number');
|
throw new Error('requestTimeoutMs must be a positive number');
|
||||||
}
|
}
|
||||||
|
const reconnectDelayMs = options.reconnectDelayMs ?? 1_000;
|
||||||
|
if (!Number.isFinite(reconnectDelayMs) || reconnectDelayMs <= 0) {
|
||||||
|
throw new Error('reconnectDelayMs must be a positive number');
|
||||||
|
}
|
||||||
|
const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 30_000;
|
||||||
|
if (!Number.isFinite(reconnectMaxDelayMs) || reconnectMaxDelayMs <= 0) {
|
||||||
|
throw new Error('reconnectMaxDelayMs must be a positive number');
|
||||||
|
}
|
||||||
this.url = options.url;
|
this.url = options.url;
|
||||||
this.token = options.token;
|
this.token = options.token;
|
||||||
this.requestTimeoutMs = requestTimeoutMs;
|
this.requestTimeoutMs = requestTimeoutMs;
|
||||||
this.autoConnect = options.autoConnect ?? false;
|
this.autoConnect = options.autoConnect ?? false;
|
||||||
|
this.autoReconnect = options.autoReconnect ?? false;
|
||||||
|
this.reconnectInitialDelayMs = reconnectDelayMs;
|
||||||
|
this.reconnectMaxDelayMs = Math.max(reconnectDelayMs, reconnectMaxDelayMs);
|
||||||
|
this.reconnectDelayMs = this.reconnectInitialDelayMs;
|
||||||
this.websocketFactory = options.websocketFactory ?? ((url) => new WebSocket(url));
|
this.websocketFactory = options.websocketFactory ?? ((url) => new WebSocket(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +430,8 @@ export class CompanionRuntimeClient {
|
|||||||
return this.connectPromise;
|
return this.connectPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
this.clearReconnectTimer();
|
||||||
this.connectPromise = this.openConnection();
|
this.connectPromise = this.openConnection();
|
||||||
try {
|
try {
|
||||||
await this.connectPromise;
|
await this.connectPromise;
|
||||||
@@ -418,6 +450,7 @@ export class CompanionRuntimeClient {
|
|||||||
cleanup();
|
cleanup();
|
||||||
settled = true;
|
settled = true;
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
|
this.resetReconnectDelay();
|
||||||
this._lastDisconnectCode = undefined;
|
this._lastDisconnectCode = undefined;
|
||||||
this._lastDisconnectReason = undefined;
|
this._lastDisconnectReason = undefined;
|
||||||
this.ws.on('message', (raw) => this.handleMessage(raw.toString()));
|
this.ws.on('message', (raw) => this.handleMessage(raw.toString()));
|
||||||
@@ -429,11 +462,18 @@ export class CompanionRuntimeClient {
|
|||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.rejectAllPending(new Error('WebSocket closed'));
|
this.rejectAllPending(new Error('WebSocket closed'));
|
||||||
this.rejectEventWaits(new Error('WebSocket closed'));
|
this.rejectEventWaits(new Error('WebSocket closed'));
|
||||||
|
this.emitConnectionEvent({
|
||||||
|
status: 'disconnected',
|
||||||
|
code,
|
||||||
|
reason: this._lastDisconnectReason,
|
||||||
|
});
|
||||||
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.ws.on('error', () => {
|
this.ws.on('error', () => {
|
||||||
// close event handles pending rejection
|
// close event handles pending rejection
|
||||||
});
|
});
|
||||||
|
this.emitConnectionEvent({ status: 'connected' });
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -463,11 +503,66 @@ export class CompanionRuntimeClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitConnectionEvent(event: CompanionConnectionEvent): void {
|
||||||
|
for (const handler of this.connectionHandlers) {
|
||||||
|
try {
|
||||||
|
handler(event);
|
||||||
|
} catch {
|
||||||
|
// Connection handlers are userland callbacks; isolate failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetReconnectDelay(): void {
|
||||||
|
this.reconnectDelayMs = this.reconnectInitialDelayMs;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearReconnectTimer(): void {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (!this.autoReconnect || !this.shouldReconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delayMs = this.reconnectDelayMs;
|
||||||
|
this.reconnectAttempt += 1;
|
||||||
|
this.emitConnectionEvent({
|
||||||
|
status: 'reconnecting',
|
||||||
|
attempt: this.reconnectAttempt,
|
||||||
|
delayMs,
|
||||||
|
});
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
if (!this.shouldReconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connect()
|
||||||
|
.then(() => {
|
||||||
|
this.resetReconnectDelay();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxDelayMs);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
disconnect(code?: number, reason?: string): void {
|
disconnect(code?: number, reason?: string): void {
|
||||||
this._lastDisconnectCode = code;
|
this._lastDisconnectCode = code;
|
||||||
this._lastDisconnectReason = reason;
|
this._lastDisconnectReason = reason;
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.clearReconnectTimer();
|
||||||
if (!this.ws) {
|
if (!this.ws) {
|
||||||
this.rejectEventWaits(new Error('Disconnected'));
|
this.rejectEventWaits(new Error('Disconnected'));
|
||||||
|
this.emitConnectionEvent({ status: 'disconnected', code, reason });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,11 +571,13 @@ export class CompanionRuntimeClient {
|
|||||||
this.rejectAllPending(new Error('Disconnected'));
|
this.rejectAllPending(new Error('Disconnected'));
|
||||||
this.rejectEventWaits(new Error('Disconnected'));
|
this.rejectEventWaits(new Error('Disconnected'));
|
||||||
ws.close(code, reason);
|
ws.close(code, reason);
|
||||||
|
this.emitConnectionEvent({ status: 'disconnected', code, reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(code?: number, reason?: string): void {
|
dispose(code?: number, reason?: string): void {
|
||||||
this.disconnect(code, reason);
|
this.disconnect(code, reason);
|
||||||
this.clearEventSubscriptions();
|
this.clearEventSubscriptions();
|
||||||
|
this.connectionHandlers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeEvents(handler: CompanionEventHandler): () => void {
|
subscribeEvents(handler: CompanionEventHandler): () => void {
|
||||||
@@ -490,6 +587,13 @@ export class CompanionRuntimeClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribeConnectionEvents(handler: CompanionConnectionHandler): () => void {
|
||||||
|
this.connectionHandlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
this.connectionHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
clearEventSubscriptions(): ClearEventSubscriptionsResult {
|
clearEventSubscriptions(): ClearEventSubscriptionsResult {
|
||||||
const clearedSubscriptions = this.eventHandlers.size;
|
const clearedSubscriptions = this.eventHandlers.size;
|
||||||
this.eventHandlers.clear();
|
this.eventHandlers.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user