diff --git a/src/api/ui-operation-bridge.ts b/src/api/ui-operation-bridge.ts index d2754cae..eb2dca50 100644 --- a/src/api/ui-operation-bridge.ts +++ b/src/api/ui-operation-bridge.ts @@ -29,9 +29,14 @@ interface BridgeChannel { operations: HtkOperation[]; user?: User; authenticated: boolean; + // Declared by the UI in its operations message. Higher wins the primary role, + // so a UI that knows it's the one being driven (the desktop app) is preferred + // over one that just happens to be open too (a browser tab). + priority: number; // Operations received before authentication completes are buffered here // and applied atomically once auth succeeds. pendingOperations?: HtkOperation[]; + pendingPriority?: unknown; // Monotonic sequence number for in-flight JWT validations. Only the // latest validation is applied; earlier ones are discarded if superseded. authSeq: number; @@ -77,7 +82,13 @@ export class UiOperationBridge extends EventEmitter { } private get primaryChannel(): BridgeChannel | undefined { - return this.channels[0]; + // Highest declared priority wins, and connection order breaks ties, so + // channels that declare nothing keep behaving exactly as they always have. + return this.channels.reduce((best, channel) => + !best || channel.priority > best.priority + ? channel + : best + , undefined); } get isReady(): boolean { @@ -106,6 +117,7 @@ export class UiOperationBridge extends EventEmitter { ws, operations: [], authenticated: false, + priority: 0, authSeq: 0 }; this.channels.push(channel); @@ -336,13 +348,14 @@ export class UiOperationBridge extends EventEmitter { // processed until both the token and JWT are fully validated. if (data.type === 'operations') { channel.pendingOperations = data.operations ?? []; + channel.pendingPriority = data.priority; } return; } switch (data.type) { case 'operations': - this.applyOperations(channel, data.operations ?? []); + this.applyOperations(channel, data.operations ?? [], data.priority); break; case 'response': @@ -351,17 +364,32 @@ export class UiOperationBridge extends EventEmitter { } } - private applyOperations(channel: BridgeChannel, operations: HtkOperation[]): void { + private applyOperations( + channel: BridgeChannel, + operations: HtkOperation[], + priority: unknown + ): void { const wasReady = this.isReady; - channel.operations = operations; + const previousPrimary = this.primaryChannel; - // Only emit events when the primary channel's operations change - if (channel === this.primaryChannel) { - if (!wasReady && this.isReady) { - this.emit('ready'); - } - this.emit('operations-changed', this.currentOperations); + channel.operations = operations; + // Anything that isn't a real number means 'no preference', so a malformed + // message can never take the primary role away from a well-behaved UI: + channel.priority = typeof priority === 'number' && Number.isFinite(priority) + ? priority + : 0; + + // Only emit events when the primary channel's operations change, or when + // this message handed the role to a different channel: + const primaryChanged = this.primaryChannel !== previousPrimary; + if (channel !== this.primaryChannel && !primaryChanged) return; + + if (!wasReady && this.isReady) { + this.emit('ready'); + } else if (wasReady && !this.isReady) { + this.emit('not-ready'); } + this.emit('operations-changed', this.currentOperations); } private handleAuth(channel: BridgeChannel, data: { token?: string; jwt: string | false }): void { @@ -428,8 +456,10 @@ export class UiOperationBridge extends EventEmitter { // This guarantees operations are never exposed until auth succeeds. if (channel.pendingOperations !== undefined) { const ops = channel.pendingOperations; + const priority = channel.pendingPriority; channel.pendingOperations = undefined; - this.applyOperations(channel, ops); + channel.pendingPriority = undefined; + this.applyOperations(channel, ops, priority); } } diff --git a/test/integration/ui-operation-bridge.spec.ts b/test/integration/ui-operation-bridge.spec.ts index 1b79fd18..220ae7a4 100644 --- a/test/integration/ui-operation-bridge.spec.ts +++ b/test/integration/ui-operation-bridge.spec.ts @@ -129,7 +129,8 @@ function waitForMessage(ws: WebSocket, type: string): Promise { async function connectMockUi( bridge: UiOperationBridge, ops: HtkOperation[] = TEST_OPERATIONS, - authToken?: string + authToken?: string, + priority?: number ): Promise<{ clientWs: WebSocket; wss: WebSocketServer }> { const pair = await createWsPair(bridge); const wasReady = bridge.isReady; @@ -145,7 +146,8 @@ async function connectMockUi( pair.clientWs.send(JSON.stringify({ type: 'operations', - operations: ops + operations: ops, + priority })); if (!wasReady) { @@ -183,8 +185,12 @@ describe("UiOperationBridge", () => { done(); }); - async function setupMockUi(ops: HtkOperation[] = TEST_OPERATIONS, authToken?: string) { - const pair = await connectMockUi(bridge, ops, authToken); + async function setupMockUi( + ops: HtkOperation[] = TEST_OPERATIONS, + authToken?: string, + priority?: number + ) { + const pair = await connectMockUi(bridge, ops, authToken, priority); activePairs.push(pair); return pair; } @@ -857,4 +863,89 @@ describe("UiOperationBridge", () => { expect(bridge.isReady).to.be.true; }); }); + + describe("Channel priority", () => { + + function answerRequests(pair: { clientWs: WebSocket }, result: any) { + pair.clientWs.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type !== 'request') return; + pair.clientWs.send(JSON.stringify({ type: 'response', id: msg.id, result })); + }); + } + + const OTHER_OPERATIONS: HtkOperation[] = [{ + name: 'other.op', + description: 'Different operation', + category: 'other', + tiers: ['free', 'pro'], + inputSchema: { type: 'object', properties: {} } + }]; + + it("should prefer a higher-priority channel over an earlier one", async () => { + await setupMockUi(); + await setupMockUi(OTHER_OPERATIONS, undefined, 1); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('other.op'); + }); + + it("should prefer a higher-priority channel that connected first", async () => { + await setupMockUi(OTHER_OPERATIONS, undefined, 1); + await setupMockUi(); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result).to.have.length(1); + expect(result[0].name).to.equal('other.op'); + }); + + it("should keep connection order between equal priorities", async () => { + await setupMockUi(TEST_OPERATIONS, undefined, 1); + await setupMockUi(OTHER_OPERATIONS, undefined, 1); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result).to.have.length(2); + expect(result[0].name).to.equal('proxy.get-config'); + }); + + it("should route execute requests to the higher-priority channel", async () => { + const first = await setupMockUi(); + const preferred = await setupMockUi(TEST_OPERATIONS, undefined, 1); + + answerRequests(first, { from: 'first' }); + answerRequests(preferred, { from: 'preferred' }); + + const result = await makeApiRequest('POST', '/api/execute', { + name: 'proxy.get-config', + args: {} + }); + expect(result).to.deep.equal({ from: 'preferred' }); + }); + + it("should fall back to a lower-priority channel when the preferred one goes", async () => { + const fallback = await setupMockUi(); + const preferred = await setupMockUi(TEST_OPERATIONS, undefined, 1); + + answerRequests(fallback, { from: 'fallback' }); + + preferred.clientWs.close(); + await new Promise(resolve => bridge.once('operations-changed', () => resolve())); + + expect(bridge.isReady).to.be.true; + const result = await makeApiRequest('POST', '/api/execute', { + name: 'proxy.get-config', + args: {} + }); + expect(result).to.deep.equal({ from: 'fallback' }); + }); + + it("should ignore a priority that is not a finite number", async () => { + await setupMockUi(); + await setupMockUi(OTHER_OPERATIONS, undefined, 'highest' as any); + + const result = await makeApiRequest('GET', '/api/operations'); + expect(result[0].name).to.equal('proxy.get-config'); + }); + }); });