diff --git a/README.md b/README.md index 3653cb8..5076da1 100644 --- a/README.md +++ b/README.md @@ -1190,7 +1190,7 @@ Methods: - `system.capabilities` returns gateway protocol and node policy snapshot. Companion runtime helper: -- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus convenience helpers (`bootstrapNode`, optional `autoConnect`, `dispose()`, `waitForIdle()` for pending-work drain synchronization) and event helpers (`subscribeEvents()`, `subscribeEvent()`, `subscribeAgentStream()`, `subscribeAgentTyping()`, `subscribeContextWarning()`, `waitForEvent()` with timeout/predicate/abort support plus event-name/timeout validation and deterministic teardown cancellation including socket-close rejection, `waitForAnyEvent()` with event-list/timeout validation, `waitForAgentStream()`, `waitForAgentTyping()`, `waitForContextWarning()`, `clearEventSubscriptions()` returning cleared-subscription/cancelled-wait counts, `cancelPendingEventWaits()` returning cancelled waiter count, `listKnownEventNames()`, `eventSubscriptionCount`) plus in-flight observability via `pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, and `getConnectionSnapshot()`. +- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus convenience helpers (`bootstrapNode`, optional `autoConnect`, `dispose()`, `waitForIdle()` for pending-work drain synchronization) and event helpers (`subscribeEvents()`, `subscribeEvent()`, `subscribeAgentStream()`, `subscribeAgentTyping()`, `subscribeContextWarning()`, `waitForEvent()` with timeout/predicate/abort support plus event-name/timeout validation and deterministic teardown cancellation including socket-close rejection, `waitForAnyEvent()` with event-list/timeout validation, `waitForAgentStream()`, `waitForAgentTyping()`, `waitForContextWarning()`, `clearEventSubscriptions()` returning cleared-subscription/cancelled-wait counts, `cancelPendingEventWaits()` returning cancelled waiter count, `listKnownEventNames()`, `eventSubscriptionCount`) plus in-flight observability via `pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, and `getConnectionSnapshot()` (including `lastDisconnectCode`/`lastDisconnectReason`). - `src/companion/platformClients.ts` provides platform-focused wrappers: - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) diff --git a/docs/plans/state.json b/docs/plans/state.json index 2a19ead..aad831a 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1195,6 +1195,21 @@ ], "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-runtime-connection-snapshot-disconnect-metadata": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Extended runtime/platform connection snapshots with disconnect metadata (`lastDisconnectCode`, `lastDisconnectReason`) captured on both manual `disconnect()` and transport close events for clearer companion lifecycle diagnostics.", + "files_modified": [ + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "src/companion/platformClients.test.ts", + "src/companion/platformClients.integration.test.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts src/companion/heartbeatLoop.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/platformClients.integration.test.ts b/src/companion/platformClients.integration.test.ts index 177a3fe..958c7b6 100644 --- a/src/companion/platformClients.integration.test.ts +++ b/src/companion/platformClients.integration.test.ts @@ -271,6 +271,8 @@ describe('platform clients integration', () => { pendingEventWaitCount: 0, hasPendingWork: false, idle: true, + lastDisconnectCode: undefined, + lastDisconnectReason: undefined, }); const unsubscribe = client.subscribeEvents(() => undefined); @@ -283,6 +285,8 @@ describe('platform clients integration', () => { pendingEventWaitCount: 1, hasPendingWork: true, idle: false, + lastDisconnectCode: undefined, + lastDisconnectReason: undefined, }); client.clearEventSubscriptions(); @@ -294,6 +298,8 @@ describe('platform clients integration', () => { pendingEventWaitCount: 0, hasPendingWork: false, idle: true, + lastDisconnectCode: undefined, + lastDisconnectReason: undefined, }); unsubscribe(); diff --git a/src/companion/platformClients.test.ts b/src/companion/platformClients.test.ts index f3a3ffc..85d10c9 100644 --- a/src/companion/platformClients.test.ts +++ b/src/companion/platformClients.test.ts @@ -100,6 +100,8 @@ function createRuntimeMock(): { pendingEventWaitCount: 1, hasPendingWork: true, idle: false, + lastDisconnectCode: 1000, + lastDisconnectReason: 'normal closure', })); const waitForAgentStream = vi.fn(async () => ({ token: 'streamed' })); const waitForAgentTyping = vi.fn(async () => ({ active: true })); @@ -443,6 +445,8 @@ describe('platform companion clients', () => { pendingEventWaitCount: 1, hasPendingWork: true, idle: false, + lastDisconnectCode: 1000, + lastDisconnectReason: 'normal closure', }); }); diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts index 1224c42..bf09fd2 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -762,6 +762,8 @@ describe('CompanionRuntimeClient', () => { pendingEventWaitCount: 1, hasPendingWork: true, idle: false, + lastDisconnectCode: undefined, + lastDisconnectReason: undefined, }); client.clearEventSubscriptions(); @@ -773,6 +775,70 @@ describe('CompanionRuntimeClient', () => { pendingEventWaitCount: 0, hasPendingWork: false, idle: true, + lastDisconnectCode: undefined, + lastDisconnectReason: undefined, + }); + }); + + it('connection snapshot tracks manual disconnect code and reason', () => { + const client = new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + }); + + client.disconnect(4100, 'manual stop'); + + expect(client.getConnectionSnapshot()).toEqual({ + connected: false, + eventSubscriptionCount: 0, + pendingRequestCount: 0, + pendingEventWaitCount: 0, + hasPendingWork: false, + idle: true, + lastDisconnectCode: 4100, + lastDisconnectReason: 'manual stop', + }); + }); + + it('connection snapshot tracks transport close code and reason', async () => { + 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', 4001, Buffer.from('transport closed')); + } + } + + const client = new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + websocketFactory: () => new FakeWebSocket() as unknown as WebSocket, + }); + await client.connect(); + + const ws = (client as unknown as { ws: WebSocket | null }).ws; + ws?.close(); + + expect(client.getConnectionSnapshot()).toEqual({ + connected: false, + eventSubscriptionCount: 0, + pendingRequestCount: 0, + pendingEventWaitCount: 0, + hasPendingWork: false, + idle: true, + lastDisconnectCode: 4001, + lastDisconnectReason: 'transport closed', }); }); diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index 16200b6..60abb25 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -66,6 +66,8 @@ export interface ConnectionSnapshot { pendingEventWaitCount: number; hasPendingWork: boolean; idle: boolean; + lastDisconnectCode?: number; + lastDisconnectReason?: string; } export interface ClearEventSubscriptionsResult { @@ -313,6 +315,8 @@ export class CompanionRuntimeClient { private pending = new Map(); private readonly eventHandlers = new Set(); private readonly pendingEventWaits = new Set<(error: Error) => void>(); + private lastDisconnectCode: number | undefined; + private lastDisconnectReason: string | undefined; constructor(options: CompanionRuntimeClientOptions) { const requestTimeoutMs = options.requestTimeoutMs ?? 15_000; @@ -374,6 +378,8 @@ export class CompanionRuntimeClient { pendingEventWaitCount: this.pendingEventWaitCount, hasPendingWork: this.hasPendingWork, idle: this.idle, + lastDisconnectCode: this.lastDisconnectCode, + lastDisconnectReason: this.lastDisconnectReason, }; } @@ -405,7 +411,10 @@ export class CompanionRuntimeClient { settled = true; this.ws = ws; this.ws.on('message', (raw) => this.handleMessage(raw.toString())); - this.ws.on('close', () => { + this.ws.on('close', (code, reason) => { + this.lastDisconnectCode = code; + const reasonText = reason?.toString().trim(); + this.lastDisconnectReason = reasonText ? reasonText : undefined; if (this.ws === ws) { this.ws = null; } @@ -445,6 +454,8 @@ export class CompanionRuntimeClient { } disconnect(code?: number, reason?: string): void { + this.lastDisconnectCode = code; + this.lastDisconnectReason = reason; if (!this.ws) { this.rejectEventWaits(new Error('Disconnected')); return;