diff --git a/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts index cadd5f2f4b..ba11f74f2e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-observation-preload.test.ts @@ -18,30 +18,127 @@ */ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; import test from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; import { build } from 'esbuild'; import type { MakaBridge } from '../../preload/bridge-contract.js'; +const owner = { + hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', + profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', +}; + test('observation readiness includes its active seed even when the invoke reply overtakes event IPC', async () => { - const owner = { - hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', - profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', - }; + // Deliberately never deliver event IPC: only the invoke response arrives. + const { bridge } = await preloadHarness(async (channel) => { + if (channel === 'sessions:observe') return { + kind: 'ready', + value: [{ + type: 'text_delta', id: 'seed-1', turnId: 'turn-1', messageId: 'message-1', + ts: 1, startOffset: 0, text: 'All output accumulated while away', + }], + }; + throw new Error('Unexpected channel: ' + channel); + }); + const order: string[] = []; + let unsubscribe = () => {}; + await new Promise((resolve, reject) => { + unsubscribe = bridge.sessions.subscribeEvents( + JSON.stringify([owner.hostId, 'session-1']), + (event) => { if (event.type === 'text_delta') order.push(event.text); }, + () => { order.push('ready'); resolve(); }, + undefined, + reject, + ); + }); + assert.deepEqual(order, ['All output accumulated while away', 'ready']); + unsubscribe(); +}); + +test('cancelled Session observation removes preload listeners without publishing readiness or errors', async () => { + const started = deferred(); + const observation = deferred<{ kind: 'cancelled' }>(); + const { bridge, events } = await preloadHarness(async (channel) => { + if (channel === 'sessions:observe') { + started.resolve(); + return observation.promise; + } + throw new Error('Unexpected channel: ' + channel); + }); + const callbacks: string[] = []; + const unsubscribe = bridge.sessions.subscribeEvents( + JSON.stringify([owner.hostId, 'session-1']), + () => callbacks.push('event'), + () => callbacks.push('ready'), + () => callbacks.push('seed'), + () => callbacks.push('error'), + ); + try { + await started.promise; + assert.equal(events.listenerCount('sessions:event:session-1'), 1); + assert.equal(events.listenerCount('sessions:observation-seed'), 1); + observation.resolve({ kind: 'cancelled' }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(events.listenerCount('sessions:event:session-1'), 0); + assert.equal(events.listenerCount('sessions:observation-seed'), 0); + events.emit('sessions:event:session-1', {}, owner, { + type: 'text_delta', id: 'late-1', turnId: 'turn-1', messageId: 'message-1', + ts: 1, startOffset: 0, text: 'Late output', + }); + events.emit('sessions:observation-seed', {}, owner, { sessionId: 'session-1', phase: 'ready' }); + assert.deepEqual(callbacks, []); + } finally { + observation.resolve({ kind: 'cancelled' }); + unsubscribe(); + } +}); + +test('cancelled transcript open rejects and removes its preload listener', async () => { + const started = deferred(); + const transcript = deferred<{ kind: 'cancelled' }>(); + const { bridge, events } = await preloadHarness(async (channel, ...args) => { + if (channel === 'session-local:transcript') return null; + if (channel === 'sessions:transcript:open') { + started.resolve(`sessions:transcript:${args[2]}`); + return transcript.promise; + } + if (channel === 'sessions:transcript:close') return; + throw new Error('Unexpected channel: ' + channel); + }); + let cancel = () => {}; + const opening = bridge.transcripts.open( + JSON.stringify([owner.hostId, 'session-1']), + () => assert.fail('A cancelled transcript must not deliver a batch'), + (requestCancellation) => { cancel = requestCancellation; }, + ); + try { + const channel = await started.promise; + assert.equal(events.listenerCount(channel), 1); + const rejection = assert.rejects(opening, /Desktop transcript open was cancelled/); + transcript.resolve({ kind: 'cancelled' }); + await rejection; + assert.equal(events.listenerCount(channel), 0); + } finally { + transcript.resolve({ kind: 'cancelled' }); + cancel(); + await opening.catch(() => undefined); + } +}); + +async function preloadHarness(invoke: (channel: string, ...args: unknown[]) => Promise) { + const events = new EventEmitter(); const ipcRenderer = { - // Deliberately never deliver event IPC: only the invoke response arrives. - on() {}, off() {}, send() {}, - async invoke(channel: string) { + on: events.on.bind(events), off: events.off.bind(events), send() {}, + async invoke(channel: string, ...args: unknown[]) { if (channel === 'runtime-host:activeIdentity') return owner; if (channel === 'runtime-host:identities') return [owner]; if (channel === 'sessions:unobserve') return; - if (channel === 'sessions:observe') return [{ - type: 'text_delta', id: 'seed-1', turnId: 'turn-1', messageId: 'message-1', - ts: 1, startOffset: 0, text: 'All output accumulated while away', - }]; - throw new Error('Unexpected channel: ' + channel); + return invoke(channel, ...args); }, }; let bridge: MakaBridge | undefined; @@ -61,17 +158,5 @@ test('observation readiness includes its active seed even when the invoke reply crypto: globalThis.crypto, }); assert.ok(bridge); - const order: string[] = []; - let unsubscribe = () => {}; - await new Promise((resolve, reject) => { - unsubscribe = bridge!.sessions.subscribeEvents( - JSON.stringify([owner.hostId, 'session-1']), - (event) => { if (event.type === 'text_delta') order.push(event.text); }, - () => { order.push('ready'); resolve(); }, - undefined, - reject, - ); - }); - assert.deepEqual(order, ['All output accumulated while away', 'ready']); - unsubscribe(); -}); + return { bridge, events }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 8da53cb213..73716ce6c6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -25,6 +25,7 @@ import { join } from "node:path"; import test from "node:test"; import type { IpcMain } from "electron"; import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { deferred } from '@maka/core/test-only/async-primitives'; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type AttachmentRef } from '@maka/core/events'; import { @@ -61,7 +62,7 @@ test('registers Session observation as one reconnectable operation', () => { for (const phase of ['connecting', 'seeding'] as const) { for (const cancellation of ['unobserve', 'renderer destruction'] as const) { - test(`Session observation IPC completes normally after ${cancellation} while ${phase}`, async () => { + test(`Session observation IPC returns cancellation after ${cancellation} while ${phase}`, async () => { const errors: unknown[] = []; const observations = new RuntimeHostSessionObservationRegistry((error) => errors.push(error)); const ipc = ipcHarness(); @@ -85,7 +86,7 @@ for (const phase of ['connecting', 'seeding'] as const) { else ipc.rendererDestroyed(); finishSeed(); - assert.deepEqual(await observing, []); + assert.deepEqual(await observing, { kind: 'cancelled' }); assert.deepEqual(observations.observedSessionIds(), []); assert.deepEqual(await observations.attach(source), []); assert.equal(seeds, phase === 'seeding' ? 1 : 0); @@ -116,6 +117,195 @@ test('forward transcript paging is an observation operation scoped to the render assert.equal(calls.length, 1); }); +test('treats pending Session observation teardown as IPC cancellation', async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + const started = deferred(); + const finishInitialization = deferred(); + await observations.attach({ + async observe() { + started.resolve(); + await finishInitialization.promise; + }, + async unobserve() {}, + }); + const ipc = observationIpcHarness(observations); + + const observing = ipc.invoke('sessions:observe', 'session-1', 'observer-1'); + try { + await started.promise; + assert.deepEqual(observations.trackedSessionIds(), ['session-1']); + await observations.unobserve('observer-1'); + assert.deepEqual(observations.trackedSessionIds(), []); + finishInitialization.resolve(); + assert.deepEqual(await observing, { kind: 'cancelled' }); + } finally { + finishInitialization.resolve(); + await observations.close(); + } +}); + +test('treats pending transcript teardown as IPC cancellation', async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + const started = deferred(); + const finishInitialization = deferred(); + await observations.attach({ + async observe() {}, + async unobserve() {}, + async openTranscript() { + started.resolve(); + await finishInitialization.promise; + return { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-epoch-1', + readThroughMessageId: null, + }; + }, + async loadTranscriptBefore() {}, + async loadTranscriptAround() {}, + async loadTranscriptAfter() {}, + async closeTranscript() {}, + }); + const ipc = observationIpcHarness(observations); + + const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'); + try { + await started.promise; + assert.deepEqual(observations.trackedSessionIds(), ['session-1']); + await observations.closeTranscript('consumer-1', 9); + assert.deepEqual(observations.trackedSessionIds(), []); + finishInitialization.resolve(); + assert.deepEqual(await opening, { kind: 'cancelled' }); + } finally { + finishInitialization.resolve(); + await observations.close(); + } +}); + +for (const teardown of ['forgetSession', 'close'] as const) { + test(`${teardown} rejects pending observation IPC instead of silently cancelling`, async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + const sessionStarted = deferred(); + const transcriptStarted = deferred(); + const finishInitialization = deferred(); + await observations.attach({ + async observe() { + sessionStarted.resolve(); + await finishInitialization.promise; + }, + async unobserve() {}, + async openTranscript() { + transcriptStarted.resolve(); + await finishInitialization.promise; + return { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-epoch-1', + readThroughMessageId: null, + }; + }, + async loadTranscriptBefore() {}, + async loadTranscriptAround() {}, + async loadTranscriptAfter() {}, + async closeTranscript() {}, + }); + const ipc = observationIpcHarness(observations); + const observing = ipc.invoke('sessions:observe', 'session-1', 'observer-1'); + const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'); + // Attach rejection handlers before teardown. The late source completion + // must not turn the lost observation into readiness or silent cancellation. + const results = Promise.allSettled([observing, opening]); + try { + await Promise.all([sessionStarted.promise, transcriptStarted.promise]); + if (teardown === 'forgetSession') await observations.forgetSession('session-1'); + else await observations.close(); + assert.deepEqual(observations.trackedSessionIds(), []); + finishInitialization.resolve(); + for (const result of await results) { + assert.equal(result.status, 'rejected'); + if (result.status === 'rejected') assert.ok(result.reason instanceof Error); + } + } finally { + finishInitialization.resolve(); + await observations.close(); + } + }); +} + +test('preserves genuine Session observation initialization failures', async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + const sessionFailure = new Error('seed failed'); + const transcriptFailure = new Error('transcript open failed'); + await observations.attach({ + async observe() { + throw sessionFailure; + }, + async unobserve() {}, + async openTranscript() { + throw transcriptFailure; + }, + async loadTranscriptBefore() {}, + async loadTranscriptAround() {}, + async loadTranscriptAfter() {}, + async closeTranscript() {}, + }); + const ipc = observationIpcHarness(observations); + + await assert.rejects( + ipc.invoke('sessions:observe', 'session-1', 'observer-1'), + (error) => error === sessionFailure, + ); + await assert.rejects( + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + (error) => error === transcriptFailure, + ); + await observations.close(); +}); + +test('returns explicit ready results for Session observation IPC', async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + const transcript = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-epoch-1', + readThroughMessageId: null, + }; + await observations.attach({ + async observe() {}, + async unobserve() {}, + async openTranscript() { + return transcript; + }, + async loadTranscriptBefore() {}, + async loadTranscriptAround() {}, + async loadTranscriptAfter() {}, + async closeTranscript() {}, + }); + const ipc = observationIpcHarness(observations); + + assert.deepEqual(await ipc.invoke('sessions:observe', 'session-1', 'observer-1'), { + kind: 'ready', + value: [], + }); + assert.deepEqual(await ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), { + kind: 'ready', + value: transcript, + }); + await observations.close(); +}); + +function observationIpcHarness(observations: RuntimeHostSessionObservationRegistry) { + const ipc = ipcHarness(); + registerRuntimeHostSessionObservationIpc( + { + observations, + resolveSideConversation: async () => false, + }, + ipc, + ); + return ipc; +} + test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => { const observer = observerWithSnapshot(); const ipc = ipcHarness(); diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 959e8e1c5d..a1bc6ea29e 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -353,7 +353,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, navigationVersion: 0, durable: [] })) { deliver(batch); } - return { ...snapshot, readThroughMessageId: null }; + return { kind: 'ready', value: { ...snapshot, readThroughMessageId: null } }; } if (channel === 'sessions:transcript:load-around') { const request = args[1] as DesktopTranscriptRangeRequest; diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d6b6234f8b..e32b2d7e40 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -54,7 +54,11 @@ import { } from "./ipc-reconnect-policy.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import type { SessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; -import type { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; +import type { RuntimeHostObservationIpcResult } from '../shared/runtime-host-observation-ipc.js'; +import { + RuntimeHostObservationCancelledError, + type RuntimeHostSessionObservationRegistry, +} from "./runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, type RuntimeHostSessionObserverTarget, @@ -193,21 +197,25 @@ export function registerRuntimeHostSessionObservationIpc( 'sessions:observe', async (event, sessionId: unknown, observerId: unknown) => { const normalizedSessionId = requiredId(sessionId, 'Session'); - return deps.observations.observe( - normalizedSessionId, - requiredId(observerId, 'Session observer'), - event.sender as RuntimeHostSessionObserverTarget, - await deps.resolveSideConversation(normalizedSessionId), + return observationIpcResult( + deps.observations.observe( + normalizedSessionId, + requiredId(observerId, 'Session observer'), + event.sender as RuntimeHostSessionObserverTarget, + await deps.resolveSideConversation(normalizedSessionId), + ), ); }, ); ipcMain.handle( 'sessions:transcript:open', async (event, sessionId: unknown, consumerId: unknown) => - deps.observations.openTranscript( - requiredId(sessionId, 'Session'), - requiredId(consumerId, 'Transcript consumer'), - event.sender as RuntimeHostTranscriptTarget, + observationIpcResult( + deps.observations.openTranscript( + requiredId(sessionId, 'Session'), + requiredId(consumerId, 'Transcript consumer'), + event.sender as RuntimeHostTranscriptTarget, + ), ), ); ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => { @@ -230,6 +238,17 @@ export function registerRuntimeHostSessionObservationIpc( }); } +async function observationIpcResult( + operation: Promise, +): Promise> { + try { + return { kind: 'ready', value: await operation }; + } catch (error) { + if (error instanceof RuntimeHostObservationCancelledError) return { kind: 'cancelled' }; + throw error; + } +} + /** * Project Host-owned Session execution onto the Desktop renderer IPC contract. * The adapter owns client validation and presentation events, never Runtime diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 8b0379b5b8..86e82640a3 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -66,6 +66,10 @@ interface ObservationReadiness { reject(error: Error): void; } +export class RuntimeHostObservationCancelledError extends Error { + readonly name = 'RuntimeHostObservationCancelledError'; +} + function requireTranscriptSource( source: SessionObservationSource | undefined, ): SessionObservationSource & TranscriptSource { @@ -465,7 +469,7 @@ export class RuntimeHostSessionObservationRegistry { if (targetId !== undefined && registration.target.id !== targetId) { throw new Error('Desktop transcript consumer belongs to another renderer'); } - this.#deleteTranscript(consumerId, registration); + this.#cancelTranscript(consumerId, registration); await this.#source?.closeTranscript?.(consumerId, targetId); } @@ -477,12 +481,8 @@ export class RuntimeHostSessionObservationRegistry { this.#bindTarget = (target) => target; const registrations = [...this.#registrations]; const transcripts = [...this.#transcripts]; - this.#registrations.clear(); - for (const [, registration] of registrations) { - registration.target.off("destroyed", registration.destroyedListener); - registration.ready.reject( - new Error("Session observation ended before it became ready"), - ); + for (const [observerId, registration] of registrations) { + this.#deleteRegistration(observerId, registration); } for (const [consumerId, registration] of transcripts) { this.#deleteTranscript(consumerId, registration); @@ -498,24 +498,32 @@ export class RuntimeHostSessionObservationRegistry { async #remove(observerId: string): Promise { const registration = this.#registrations.get(observerId); if (!registration) return; - // Explicit unsubscribe and renderer destruction retire the consumer, even - // if its first seed is still pending. Complete that abandoned IPC without - // reporting cancellation as a read failure. Source failures still reject. - registration.ready.resolve([]); - this.#deleteRegistration(observerId, registration); + this.#cancelRegistration(observerId, registration); await this.#source?.unobserve(observerId); } + #cancelRegistration( + observerId: string, + registration: SessionObservationRegistration, + ): void { + this.#deleteRegistration( + observerId, + registration, + new RuntimeHostObservationCancelledError( + "Session observation was cancelled before it became ready", + ), + ); + } + #deleteRegistration( observerId: string, registration: SessionObservationRegistration, + error = new Error("Session observation ended before it became ready"), ): void { if (this.#registrations.get(observerId) !== registration) return; this.#registrations.delete(observerId); registration.target.off("destroyed", registration.destroyedListener); - registration.ready.reject( - new Error("Session observation ended before it became ready"), - ); + registration.ready.reject(error); } async #runTranscriptOperation( @@ -638,12 +646,26 @@ export class RuntimeHostSessionObservationRegistry { } } - #deleteTranscript(consumerId: string, registration: TranscriptRegistration): void { + #cancelTranscript(consumerId: string, registration: TranscriptRegistration): void { + this.#deleteTranscript( + consumerId, + registration, + new RuntimeHostObservationCancelledError( + 'Transcript observation was cancelled before it became ready', + ), + ); + } + + #deleteTranscript( + consumerId: string, + registration: TranscriptRegistration, + error = new Error('Transcript observation ended before it became ready'), + ): void { if (this.#transcripts.get(consumerId) !== registration) return; this.#transcripts.delete(consumerId); registration.restore?.resolve(); registration.target.off('destroyed', registration.destroyedListener); - registration.ready.reject(new Error('Transcript observation ended before it became ready')); + registration.ready.reject(error); } #bindTranscriptTarget(target: RuntimeHostTranscriptTarget): RuntimeHostTranscriptTarget { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2141c40c1a..25c3193945 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -76,6 +76,7 @@ import type { AppIconSelectResult, } from './bridge-contract.js'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; +import type { RuntimeHostObservationIpcResult } from '../shared/runtime-host-observation-ipc.js'; import { projectDesktopExternalSessionCatalogItem, type DesktopExternalSessionCatalogItem, @@ -2279,7 +2280,11 @@ const makaBridge = { let unsubscribeEvents = () => {}; let unsubscribeObservationSeed = () => {}; const observeDispatch = runtimeHostSessionRef(sessionId).then((session) => { - if (disposed) return { completion: Promise.resolve() }; + if (disposed) { + return { + completion: Promise.resolve({ kind: 'cancelled' } as const), + }; + } const profileId = runtimeHostMetadataFor(session.scope)?.profileId; if (!profileId) throw new Error('The Runtime Host profile for this task is unavailable'); // Keep the renderer listener across Host target epochs. The observer @@ -2309,18 +2314,26 @@ const makaBridge = { session.scope, session.sessionId, observerId, - ).then((seed: readonly SessionEvent[] | undefined) => { - if (disposed) return; - // Invoke replies can overtake event IPC. Apply the response's - // complete active snapshot before publishing renderer readiness. - for (const event of seed ?? []) handler(projectDesktopSessionEvent(session.scope, event)); + ).then((result: RuntimeHostObservationIpcResult) => { + if (!disposed && result.kind === 'ready') { + // Invoke replies can overtake event IPC. Apply the response's + // complete active snapshot before publishing renderer readiness. + for (const event of result.value) handler(projectDesktopSessionEvent(session.scope, event)); + } + return result; }), }; }); const observing = observeDispatch.then(({ completion }) => completion); void observing.then( - () => { - if (!disposed) onSeeded?.(); + (result) => { + if (result.kind === 'cancelled') { + disposed = true; + unsubscribeObservationSeed(); + unsubscribeEvents(); + } else if (!disposed) { + onSeeded?.(); + } }, (error: unknown) => { if (!disposed) onSeedError?.(error); @@ -2530,7 +2543,7 @@ const makaBridge = { session.scope, session.sessionId, consumerId, - ) as Promise, + ) as Promise>, }; }); let closeTask: Promise | undefined; @@ -2544,9 +2557,9 @@ const makaBridge = { void closeTask.catch(() => undefined); }; registerCancellation?.(requestClose); - let opened: DesktopTranscriptOpenResult; + let openResult: RuntimeHostObservationIpcResult; try { - opened = await openDispatch.then(({ completion }) => completion); + openResult = await openDispatch.then(({ completion }) => completion); } catch (error) { const cancelled = closed; closed = true; @@ -2561,6 +2574,12 @@ const makaBridge = { } throw error; } + if (openResult.kind === 'cancelled') { + closed = true; + ipcRenderer.off(channel, listener); + throw new Error('Desktop transcript open was cancelled'); + } + const opened = openResult.value; if (closed) throw new Error('Desktop transcript open was cancelled'); identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; const range = ( diff --git a/apps/desktop/src/shared/runtime-host-observation-ipc.ts b/apps/desktop/src/shared/runtime-host-observation-ipc.ts new file mode 100644 index 0000000000..fe45a3b07f --- /dev/null +++ b/apps/desktop/src/shared/runtime-host-observation-ipc.ts @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type RuntimeHostObservationIpcResult = + | { readonly kind: 'ready'; readonly value: T } + | { readonly kind: 'cancelled' };