From 3d6d595d794ba715c60ff850f044cc02e6c53d40 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:10:02 +0800 Subject: [PATCH] feat(api-gateway): unify Remote streams and events --- .../tests/fixtures/child-question-tripwire.ts | 12 +- packages/api/gateway/src/client/index.ts | 289 ++- .../api/gateway/src/client/journal-stream.ts | 476 +++++ .../api/gateway/src/client/remote-events.ts | 339 ++++ .../api/gateway/src/client/remote-stream.ts | 210 +++ .../api/gateway/src/client/snapshot-stream.ts | 88 + .../api/gateway/src/client/stream-client.ts | 348 ++++ packages/api/gateway/src/index.ts | 616 ++++++- packages/api/gateway/src/stream-protocol.ts | 399 ++++ packages/api/gateway/src/stream-server.ts | 188 ++ packages/api/gateway/src/types.ts | 98 + .../tests/control-retry.client.spec.ts | 303 ++++ .../gateway/tests/gateway-stream.host.spec.ts | 1046 +++++++++++ .../api/gateway/tests/gateway.client.spec.ts | 1615 ++++++++++++++++- .../api/gateway/tests/gateway.host.spec.ts | 62 + .../tests/journal-stream.client.spec.ts | 335 ++++ .../tests/remote-event-protocol.host.spec.ts | 287 +++ .../tests/stream-protocol.host.spec.ts | 68 + .../gateway/tests/stream-server.host.spec.ts | 246 +++ packages/core/agent/src/dispatch.ts | 2 +- packages/core/agent/src/index.ts | 15 +- packages/core/agent/src/runtime-types.ts | 61 +- packages/core/agent/src/types.ts | 19 + packages/core/agent/tests/agent.spec.ts | 6 +- .../src/client/api-client.ts | 8 +- .../webworker-runtime/src/client/client.ts | 205 ++- .../webworker-runtime/src/client/index.ts | 2 + .../webworker-runtime/src/index.ts | 4 +- .../webworker-runtime/src/transport/frames.ts | 53 +- .../webworker-runtime/src/transport/tunnel.ts | 75 +- .../webworker-runtime/src/worker-host.ts | 7 + .../tests/transport/tunnel-client.spec.ts | 111 +- .../tests/transport/tunnel-server.spec.ts | 113 ++ .../interaction/user-approval/src/index.ts | 39 +- .../interaction/user-approval/src/types.ts | 61 + .../interaction/user-questions/src/index.ts | 6 +- .../interaction/user-questions/src/types.ts | 36 +- packages/typert/generator/src/analyzer.ts | 65 +- packages/typert/generator/src/emitter.ts | 9 +- packages/typert/generator/src/model.ts | 1 + .../remote-model/packages/remote/src/index.ts | 6 + .../remote-model/typert-protocol.d.ts | 2 +- .../generator/tests/remote-model.spec.ts | 64 +- packages/typert/protocol/src/index.ts | 95 +- packages/typert/protocol/src/types.ts | 210 ++- .../typert/protocol/tests/protocol.spec.ts | 74 +- packages/typert/registry/src/service.ts | 59 +- packages/typert/registry/tests/typert.spec.ts | 39 +- 48 files changed, 7926 insertions(+), 546 deletions(-) create mode 100644 packages/api/gateway/src/client/journal-stream.ts create mode 100644 packages/api/gateway/src/client/remote-events.ts create mode 100644 packages/api/gateway/src/client/remote-stream.ts create mode 100644 packages/api/gateway/src/client/snapshot-stream.ts create mode 100644 packages/api/gateway/src/client/stream-client.ts create mode 100644 packages/api/gateway/src/stream-protocol.ts create mode 100644 packages/api/gateway/src/stream-server.ts create mode 100644 packages/api/gateway/tests/control-retry.client.spec.ts create mode 100644 packages/api/gateway/tests/gateway-stream.host.spec.ts create mode 100644 packages/api/gateway/tests/journal-stream.client.spec.ts create mode 100644 packages/api/gateway/tests/remote-event-protocol.host.spec.ts create mode 100644 packages/api/gateway/tests/stream-protocol.host.spec.ts create mode 100644 packages/api/gateway/tests/stream-server.host.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts diff --git a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts index 27efcf5e55..26905eaade 100644 --- a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts +++ b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts @@ -1,17 +1,15 @@ import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-user-questions' -/** Snapshot-only provider whose invocation means the child guard failed. */ +/** Snapshot-only answerer whose invocation means the child guard failed. */ export const name = 'child-question-tripwire' -/** User-interaction service required by the tripwire provider. */ +/** User-interaction service required by the tripwire answerer. */ export const inject = ['userQuestions'] -/** Register a provider that must remain unreachable for the delegated call. */ +/** Register an answerer that must remain unreachable for the delegated call. */ export function apply(ctx: Context): void { - ctx.userQuestions.registerProvider({ - async ask() { - throw new Error('snapshot tripwire: delegated question reached the UI provider') - }, + ctx.on('user-questions/request', async () => { + throw new Error('snapshot tripwire: delegated question reached the UI answerer') }) } diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 31e3db6711..ee87914633 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,10 +5,13 @@ */ import { Service } from '@deepseek-ai/cordis' -import type { Context, Events } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { Context } from '@deepseek-ai/cordis' +import type { + ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypertClientEventListener, TypertClientRemote, RemoteResult, TypertCodec, @@ -16,6 +19,29 @@ import type { TypertRemoteContribution, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteStreamCarrierError, + RemoteStreamError, + RemoteStreamMuxClient, +} from './stream-client.ts' +import { ClientRemoteEvents } from './remote-events.ts' +import { + RemoteStream, + type RemoteStreamOptions, +} from './remote-stream.ts' + +export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts' +export { RemoteJournalStream } from './journal-stream.ts' +export type { + RemoteJournalChange, + RemoteJournalFrame, + RemoteJournalStreamOptions, + RemoteStreamFactory, +} from './journal-stream.ts' +export { RemoteStream } from './remote-stream.ts' +export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts' +export { RemoteSnapshotStream } from './snapshot-stream.ts' +export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts' interface MountToken { active: boolean @@ -47,11 +73,21 @@ interface BoundContextIdentity { readonly value: unknown } +interface PreparedClientInvocation { + readonly endpoint: string + readonly args: Readonly> + readonly signal: AbortSignal +} + interface RemoteNamespaceHandle { readonly service: RemoteNamespaceService readonly dispose: TypertDisposer } +interface LoaderReadiness { + await(): Promise +} + /** One descriptor's mounted variants, for the group disposer to unwind. */ interface InstalledMethod { readonly descriptor: InvocationDescriptor @@ -60,8 +96,15 @@ interface InstalledMethod { scoped: boolean } -/** Typed Remote service augmented by generated direct namespaces. */ -export type ClientRemote = TypertClientRemote +/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ +export interface ClientRemote extends TypertClientRemote { + /** + * Create one independently cancellable, reconnecting logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a single-consumer stream annotated with physical generation ids. + */ + $stream(options: RemoteStreamOptions): RemoteStream +} declare module '@deepseek-ai/cordis' { interface Context { @@ -81,28 +124,46 @@ export function apply(ctx: Context): void { new ClientRemoteService(ctx) } -/** One subscribed listener after `$on` erased its per-event argument list. */ -type RemoteEventListener = (...args: never[]) => void - -/** - * One subscription, identified by the registration rather than by its listener: - * two fibers may subscribe the same function object to the same event, and each - * disposer must retire only its own registration. - */ -interface RemoteEventSubscription { - readonly listener: RemoteEventListener -} - -class ClientRemoteService extends Service implements TypertClientRemote { +class ClientRemoteService extends Service implements ClientRemote { private readonly ownerCtx: Context + private readonly connection: ConnectionHandle private readonly namespaces = new Map() - private readonly subscriptions = new Map() + private readonly streams = new RemoteStreamMuxClient() + private readonly events: ClientRemoteEvents private mutations = Promise.resolve() constructor(ctx: Context) { super(ctx, 'remote') this.ownerCtx = ctx - ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions') + const connection = ctx.get('connection') as ConnectionHandle + this.connection = connection + this.events = new ClientRemoteEvents( + ctx, + connection, + (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), + ) + if (connection.rpc.open === undefined) this.streams.start() + let disposed = false + let loop: ReturnType | undefined + const start = (): void => { + if (disposed) return + loop = connection.start({ + onConnected: () => { this.ownerCtx.emit('connection/reset') }, + }) + } + const loader = ctx.get('loader') as LoaderReadiness | undefined + if (loader === undefined) start() + else void loader.await().then(start, () => {}) + ctx.effect(() => async () => { + disposed = true + loop?.stop() + await this.events.dispose() + await this.streams.close() + }, 'api-gateway.client.transport') + } + + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(this.connection, options) } async $mount(contribution: TypertRemoteContribution): ReturnType { @@ -117,60 +178,24 @@ class ClientRemoteService extends Service implements TypertClientRemote { $on( event: Event, - listener: Events[Event], - ): ReturnType { - // The table is keyed by the runtime event name, so the argument list this - // signature pins per event cannot survive in it; `$deliver` restores it - // from the frame the Host emitted for that same name. - const subscription: RemoteEventSubscription = { listener } - const owned = this.ctx.effect(() => { - const listeners = this.listeners(event) - listeners.push(subscription) - return () => { - const at = listeners.indexOf(subscription) - /* v8 ignore next -- listener */ - if (at >= 0) listeners.splice(at, 1) - } - }, `api-gateway.client.$on(${JSON.stringify(event)})`) - return () => { void owned() } + listener: TypertClientEventListener, + ): () => void { + return this.events.subscribe(this.ctx, event, listener) } - /** - * Deliver one forwarded event in registration order, isolating a listener - * that fails either synchronously or by rejecting a returned promise; see - * {@link TypertClientRemote.$dispatch} for the caller contract. - */ - $dispatch(event: string, args: readonly unknown[]): void { - const listeners = this.subscriptions.get(event) - if (listeners === undefined) return - // Snapshot: a listener may subscribe or dispose during delivery, and this - // round's recipients are the ones registered when the frame arrived. - for (const { listener } of [...listeners]) { - const report = (error: unknown): void => { - console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) - } - try { - /* oxlint-disable-next-line typescript/no-confusing-void-expression -- - * The declared return is void, so nobody awaits an async listener; the - * runtime value is still a promise, and reading it is the only way to - * keep its rejection inside this containment instead of surfacing as an - * unhandled one. */ - const settled: unknown = listener(...args as never[]) - if (settled instanceof Promise) settled.catch(report) - } catch (error) { - report(error) - } - } - } - - /** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */ - private listeners(event: string): RemoteEventSubscription[] { - let listeners = this.subscriptions.get(event) - if (listeners === undefined) { - listeners = [] - this.subscriptions.set(event, listeners) - } - return listeners + /** Open one Remote stream and normalize a worker-local carrier's structural failures. */ + private openRemoteStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + noConnection = `client api: ${endpoint} has no active Connection`, + ): AsyncIterable { + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(noConnection) + const local = connection.rpc.open?.('/api', endpoint, payload, signal) + return local === undefined + ? this.streams.open(endpoint, payload, signal) + : normalizeConnectionStream(local) } private enqueue(operation: () => T | Promise): Promise { @@ -331,12 +356,12 @@ class ClientRemoteService extends Service implements TypertClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise> { + ): Promise> | AsyncIterable { if (scoped !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) - const identity = binder?.identity(callerCtx) + const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = adapter?.identity(callerCtx) if (identity !== undefined) { - return this.invoke( + return this.invokeSelected( scoped.descriptor, scoped.projection, scoped.token, @@ -347,14 +372,28 @@ class ClientRemoteService extends Service implements TypertClientRemote { } } if (direct !== undefined) { - return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values) } if (scoped !== undefined) { - return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + return this.invokeSelected(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) } throw new Error('client api: Remote method is no longer mounted') } + private invokeSelected( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> | AsyncIterable { + if (descriptor.mode === 'stream') { + return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) + } + return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, @@ -365,6 +404,48 @@ class ClientRemoteService extends Service implements TypertClientRemote { ): Promise> { const endpoint = endpointOf(descriptor) if (!token.active) return withdrawn(endpoint) + const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) + try { + const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) + if (!mountActive(token)) return withdrawn(endpoint) + if (!result.ok) return { ok: false, error: result.error } + return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') } + } catch (error) { + // Carrier throws (offline, abort, a rejected result payload) are outcomes + // of the call, not assembly faults, so they join the same error branch. + return carrierFailure(endpoint, error) + } + } + + private async *invokeStream( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): AsyncGenerator { + const endpoint = endpointOf(descriptor) + if (!token.active) throw new Error(withdrawn(endpoint).error.message) + const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) + const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal) + for await (const value of stream) { + if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message) + yield parse(descriptor.result, value, endpoint, 'result') + } + } + + private prepareInvocation( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): PreparedClientInvocation { + const endpoint = endpointOf(descriptor) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 if (values.length !== expected && !hasCallerSignal) { @@ -377,14 +458,14 @@ class ClientRemoteService extends Service implements TypertClientRemote { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = boundIdentity === undefined + const adapter = boundIdentity === undefined ? this.ownerCtx.typert.contexts.getClient(projection.context) : undefined - if (boundIdentity === undefined && binder === undefined) { - throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) + if (boundIdentity === undefined && adapter === undefined) { + throw new Error(`client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`) } const identity = boundIdentity === undefined - ? binder?.identity(callerCtx) + ? adapter?.identity(callerCtx) : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) @@ -398,22 +479,11 @@ class ClientRemoteService extends Service implements TypertClientRemote { if (value !== undefined) args[parameter.wire] = value valueIndex += 1 }) - const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined const signal = callerSignal === undefined ? token.abort.signal : AbortSignal.any([token.abort.signal, callerSignal]) - try { - const result = await connection.rpc.call('/api', endpoint, { args }, signal) - if (!mountActive(token)) return withdrawn(endpoint) - if (!result.ok) return { ok: false, error: result.error } - return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') } - } catch (error) { - // Carrier throws (offline, abort, a rejected result payload) are outcomes - // of the call, not assembly faults, so they join the same error branch. - return carrierFailure(endpoint, error) - } + return { endpoint, args, signal } } } @@ -422,7 +492,7 @@ type InvokeRemote = ( scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise> +) => Promise> | AsyncIterable class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -477,7 +547,7 @@ class RemoteNamespaceService extends Service { Object.defineProperty(this, method, { configurable: true, enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise> { + get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown { const callerCtx = this.ctx const current = this.methods.get(method) const direct = current?.direct @@ -620,14 +690,37 @@ function parse(codec: TypertCodec, value: unknown, endpoint: string, field: stri } /** The namespace retired before or during the call, so no request outcome exists. */ -function withdrawn(endpoint: string): RemoteResult { +function withdrawn(endpoint: string): Extract, { readonly ok: false }> { return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) } -function carrierFailure(endpoint: string, error: unknown): RemoteResult { +function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) } -function internalFailure(message: string): RemoteResult { +function internalFailure(message: string): Extract, { readonly ok: false }> { return { ok: false, error: { code: 'internal', message, details: {} } } } + +type MarkedConnectionStreamFailure = Error & { + readonly dshRemoteStreamFailure?: + | { readonly kind: 'remote'; readonly code: string; readonly details: object } + | { readonly kind: 'carrier' } +} + +/** Preserve Gateway error classes across a worker transport's separately bundled page half. */ +async function *normalizeConnectionStream(source: AsyncIterable): AsyncGenerator { + try { + yield * source + } catch (error) { + if (!(error instanceof Error)) throw error + const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure + if (marker?.kind === 'remote') { + throw new RemoteStreamError(marker.code, error.message, marker.details) + } + if (marker?.kind === 'carrier') { + throw new RemoteStreamCarrierError(error.message, { cause: error }) + } + throw error + } +} diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts new file mode 100644 index 0000000000..a76b398924 --- /dev/null +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -0,0 +1,476 @@ +/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ + +import { RemoteStreamCarrierError } from './stream-client.ts' +import type { + RemoteStream, + RemoteStreamItem, + RemoteStreamOptions, +} from './remote-stream.ts' + +/** Transport-neutral opening cursor or journal entry. */ +export type RemoteJournalFrame = + | { readonly type: 'opened'; readonly cursor: Cursor } + | { readonly type: 'entry'; readonly entry: Entry } + +/** One committed journal-window update. */ +export type RemoteJournalChange = + | { + readonly type: 'replace' + readonly page: Page + readonly entries: readonly Entry[] + readonly hasMore: boolean + } + | { + readonly type: 'prepend' + readonly page: Page + readonly entries: readonly Entry[] + readonly hasMore: boolean + } + | { readonly type: 'append'; readonly entry: Entry } + +type JournalStreamItem = RemoteStreamItem> + +/** Gateway capability used to create one reconnecting Remote stream. */ +export interface RemoteStreamFactory { + /** + * Create one independently cancellable logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a reconnecting single-consumer stream. + */ + $stream(options: RemoteStreamOptions): RemoteStream +} + +/** Domain publication and cursor operations for one addressed journal stream. */ +export interface RemoteJournalStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string + /** Cursor representing a journal with no entries. */ + readonly emptyCursor: Cursor + /** Read the ordered entries carried by a page. */ + readonly entries: (page: Page) => readonly Entry[] + /** Read whether an older page exists. */ + readonly hasMore: (page: Page) => boolean + /** Read one entry's durable cursor. */ + readonly cursor: (entry: Entry) => Cursor + /** Compare two cursors. */ + readonly compare: (left: Cursor, right: Cursor) => number + /** Test whether the right cursor immediately follows the left cursor. */ + readonly follows: (left: Cursor, right: Cursor) => boolean + /** Apply one complete journal-window change. */ + readonly publish: (change: RemoteJournalChange) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void +} + +/** + * Owns follow-before-page opening, ordered live delivery, pagination, and repair. + * + * The domain retains its published window during reconnection. A replacement is + * published only after a tail page reaches the generation's opening cursor. + */ +export abstract class RemoteJournalStream { + private readonly stream: RemoteStream> + private initialRequest!: PageRequest + private hasInitialRequest = false + private resumeCursor: Cursor | undefined + private hasResumeCursor = false + private generation = 0 + private firstCursor: Cursor | undefined + private lastCursor: Cursor | undefined + private started = false + private opened = false + private disposed = false + private done: Promise | undefined + private closing: Promise | undefined + private pendingNext: Promise>> | undefined + + /** + * @param remote - Gateway factory for the reconnecting physical-generation stream. + * @param options - cursor algebra and domain publication sinks. + */ + protected constructor( + remote: RemoteStreamFactory, + private readonly options: RemoteJournalStreamOptions, + ) { + this.stream = remote.$stream>({ + name: options.name, + open: signal => this.follow( + this.hasResumeCursor ? this.resumeCursor : undefined, + signal, + ), + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) + : new Error( + `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`, + ), + ...(options.carrierFailed === undefined + ? {} + : { carrierFailed: options.carrierFailed }), + }) + } + + /** + * Open one physical journal generation after the last accepted cursor. + * @param after - last accepted cursor, or `undefined` for the initial generation. + * @param signal - cancellation lifetime of the physical generation. + * @returns opening cursor followed by live entries. + */ + protected abstract follow( + after: Cursor | undefined, + signal: AbortSignal, + ): AsyncIterable> + + /** + * Read one journal page through the addressed domain source. + * @param request - domain page request. + * @param signal - cancellation lifetime shared with the logical stream. + * @returns the requested page. + */ + protected abstract readPage(request: PageRequest, signal: AbortSignal): Promise + + /** + * Derive an unbounded-tail request from the initial page request. + * @param initial - request used to open the journal window. + * @returns request suitable for reconnect and gap repair. + */ + protected abstract repairRequest(initial: PageRequest): PageRequest + + /** Cancellation lifetime shared by follow and page calls. */ + get signal(): AbortSignal { + return this.stream.signal + } + + /** + * Establish follow before reading and publishing the initial page. + * @param request - initial tail-page request. + * @returns after the first complete window is published. + */ + async open(request: PageRequest): Promise { + if (this.started) throw new Error(`${this.options.name} already opened`) + this.started = true + this.initialRequest = request + this.hasInitialRequest = true + const iterator = this.stream[Symbol.asyncIterator]() + try { + const first = await this.takeNext(iterator) + if (first.done) throw new Error(`${this.options.name} ended before its opening cursor`) + await this.replaceGeneration(request, first.value, iterator, false) + this.opened = true + this.done = this.consume(iterator) + } catch (error) { + await this.stream.dispose() + throw error + } + } + + /** + * Read and prepend one older page after a successful open. + * @param request - domain page request bound to this stream's address. + * @returns after the page is applied or rejected as discontinuous. + */ + async prepend(request: PageRequest): Promise { + if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`) + const page = await this.readPage(request, this.stream.signal) + this.stream.signal.throwIfAborted() + const entries = this.options.entries(page) + this.assertPage(entries) + const before = this.firstCursor + const accepted = before === undefined + ? [...entries] + : entries.filter(entry => this.options.compare(this.options.cursor(entry), before) < 0) + const tail = accepted.at(-1) + if (tail !== undefined && before !== undefined + && !this.options.follows(this.options.cursor(tail), before)) { + this.options.publish({ type: 'prepend', page, entries: [], hasMore: false }) + throw new Error(`${this.options.name} history page is discontinuous`) + } + const first = accepted[0] + if (first !== undefined) this.firstCursor = this.options.cursor(first) + this.options.publish({ + type: 'prepend', + page, + entries: accepted, + hasMore: this.options.hasMore(page), + }) + } + + /** Replace the active physical generation while retaining the published window. */ + restart(): void { + this.stream.restart() + } + + /** + * Permanently stop follow, page requests, and the background consumer. + * @returns when no stream work or publication callback can still run. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing + this.disposed = true + const done = this.done + const closing = (async () => { + await this.stream.dispose() + await done + })() + this.closing = closing + return closing + } + + private async consume( + iterator: AsyncIterator>, + ): Promise { + try { + while (true) { + const next = await this.takeNext(iterator) + if (next.done) return + const item = next.value + if (item.generation !== this.generation) { + await this.replaceGeneration(this.repairPageRequest(), item, iterator, true) + continue + } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + await this.acceptEntry(item, iterator) + } + } catch (error) { + if (!this.disposed) this.options.failed(error) + } + } + + private async replaceGeneration( + request: PageRequest, + initial: JournalStreamItem, + iterator: AsyncIterator>, + resumed: boolean, + ): Promise { + let item = initial + let isResumed = resumed + while (true) { + const cursor = this.opening(item, isResumed) + this.setResumeCursor(cursor) + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [], + ) + if (superseded === undefined) return + item = superseded + isResumed = true + } + } + + private opening( + item: RemoteStreamItem>, + resumed: boolean, + ): Cursor { + if (item.value.type !== 'opened') { + throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`) + } + const cursor = item.value.cursor + if (resumed && this.lastCursor !== undefined + && this.options.compare(cursor, this.lastCursor) < 0) { + throw new Error( + `${this.options.name} resumed at a cursor behind the last applied entry`, + ) + } + this.generation = item.generation + item.accept() + return cursor + } + + private async acceptEntry( + item: JournalStreamItem, + iterator: AsyncIterator>, + ): Promise { + if (item.value.type !== 'entry') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + const entry = item.value.entry + const cursor = this.options.cursor(entry) + const last = this.lastCursor + if (last !== undefined) { + if (this.options.compare(cursor, last) <= 0) return + if (!this.options.follows(last, cursor)) { + const request = this.repairPageRequest() + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [entry], + ) + if (superseded !== undefined) { + await this.replaceGeneration(request, superseded, iterator, true) + } + return + } + } + if (this.firstCursor === undefined) this.firstCursor = cursor + this.lastCursor = cursor + this.setResumeCursor(cursor) + this.options.publish({ type: 'append', entry }) + } + + private async replaceThrough( + request: PageRequest, + requiredCursor: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + ): Promise | undefined> { + let read = await this.readPageWhileFollowing(request, generation, signal, iterator, queued) + if (read.type === 'superseded') return read.item + let page = read.page + let entries = this.mergeReplacement(page, queued) + let target = this.maxCursor(requiredCursor, queued) + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + read = await this.readPageWhileFollowing( + this.repairPageRequest(), + generation, + signal, + iterator, + queued, + ) + if (read.type === 'superseded') return read.item + page = read.page + entries = this.mergeReplacement(page, queued) + target = this.maxCursor(requiredCursor, queued) + } + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + throw new Error(`${this.options.name} page did not reach its opening cursor`) + } + const first = entries[0] + this.firstCursor = first === undefined ? undefined : this.options.cursor(first) + this.lastCursor = this.tailCursor(entries) + this.setResumeCursor(this.lastCursor) + this.options.publish({ + type: 'replace', + page, + entries, + hasMore: this.options.hasMore(page), + }) + return undefined + } + + private async readPageWhileFollowing( + request: PageRequest, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + ): Promise< + | { readonly type: 'page'; readonly page: Page } + | { readonly type: 'superseded'; readonly item: JournalStreamItem } + > { + const page = this.readPage(request, signal).then( + value => ({ type: 'page' as const, value }), + (error: unknown) => ({ type: 'page-error' as const, error }), + ) + while (true) { + const pending = this.nextResult(iterator) + const next = pending.then( + value => ({ type: 'next' as const, value }), + (error: unknown) => ({ type: 'next-error' as const, error }), + ) + const result = await Promise.race([page, next]) + if (result.type === 'page') { + signal.throwIfAborted() + return { type: 'page', page: result.value } + } + if (result.type === 'page-error') throw result.error + this.releaseNext(pending) + if (result.type === 'next-error') throw result.error + if (result.value.done) { + signal.throwIfAborted() + throw new Error(`${this.options.name} ended while reading its replacement page`) + } + const item = result.value.value + if (item.generation !== generation) return { type: 'superseded', item } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + queued.push(item.value.entry) + } + } + + private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined { + const entries = [...this.options.entries(page)] + this.assertPage(entries) + const sorted = [...queued].sort((left, right) => ( + this.options.compare(this.options.cursor(left), this.options.cursor(right)) + )) + let tail = this.tailCursor(entries) + for (const entry of sorted) { + const cursor = this.options.cursor(entry) + if (this.options.compare(cursor, tail) <= 0) continue + if (!this.options.follows(tail, cursor)) return undefined + entries.push(entry) + tail = cursor + } + return entries + } + + private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor { + let result = cursor + for (const entry of entries) { + const candidate = this.options.cursor(entry) + if (this.options.compare(candidate, result) > 0) result = candidate + } + return result + } + + private nextResult( + iterator: AsyncIterator>, + ): Promise>> { + this.pendingNext ??= iterator.next() + return this.pendingNext + } + + private async takeNext( + iterator: AsyncIterator>, + ): Promise>> { + const pending = this.nextResult(iterator) + try { + return await pending + } finally { + this.releaseNext(pending) + } + } + + private releaseNext(pending: Promise>>): void { + if (this.pendingNext === pending) this.pendingNext = undefined + } + + private repairPageRequest(): PageRequest { + if (!this.hasInitialRequest) throw new Error(`${this.options.name} has no initial page request`) + return this.repairRequest(this.initialRequest) + } + + private setResumeCursor(cursor: Cursor): void { + this.resumeCursor = cursor + this.hasResumeCursor = true + } + + private tailCursor(entries: readonly Entry[]): Cursor { + const tail = entries.at(-1) + return tail === undefined ? this.options.emptyCursor : this.options.cursor(tail) + } + + private assertPage(entries: readonly Entry[]): void { + for (let index = 1; index < entries.length; index++) { + const previous = entries[index - 1] + const entry = entries[index] + if (previous === undefined || entry === undefined) continue + if (!this.options.follows(this.options.cursor(previous), this.options.cursor(entry))) { + throw new Error(`${this.options.name} page contains discontinuous entries`) + } + } + } +} diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts new file mode 100644 index 0000000000..341c1f3f4d --- /dev/null +++ b/packages/api/gateway/src/client/remote-events.ts @@ -0,0 +1,339 @@ +/** Client owner for forwarded Remote Event subscriptions and deliveries. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { + ConnectionGenerationSource, + ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + TypertClientEventListener, + TypertRemoteEvent, +} from '@deepseek-ai/dsh-typert-protocol' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + isRemoteEventAgentId, + isRemoteEventClientId, + isRemoteEventId, + isRemoteJsonValue, + projectRemoteEventRejection, + type RemoteEventClientId, + type RemoteEventDownlinkFrame, + type RemoteEventEmitFrame, + type RemoteEventInvocationFrame, + type RemoteEventResult, +} from '../stream-protocol.ts' + +/** Open the Gateway-internal forwarded-event stream on the selected carrier. */ +export type RemoteEventStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable + +/** One subscribed listener after its event-specific signature is erased. */ +type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown + +/** Untyped access used only for instance-private Cordis event keys. */ +interface PrivateEventContext { + on(name: string, listener: RemoteEventListener): () => boolean + parallel(name: string, ...args: unknown[]): Promise + waterfall( + thisArg: Context, + name: string, + request: Readonly>, + next: () => Promise, + ): unknown +} + +/** Transport outcome after one Client listener chain either claims or delegates. */ +type RemoteEventReplyOutcome = + | { readonly kind: 'result'; readonly value: unknown } + | { readonly kind: 'next' } + | { readonly kind: 'rejected'; readonly error: ReturnType } + +/** Private end-of-chain marker that cannot collide with a JSON listener result. */ +const REMOTE_EVENT_NEXT = Symbol('api-gateway.remote-event.next') + +/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */ +export class ClientRemoteEvents { + private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/` + private readonly unregisterGeneration: () => void + private activeGeneration: Promise | undefined + + /** + * @param ownerCtx - Client Gateway root used for Agent Context resolution. + * @param connection - Connection carrier used for HTTP result calls. + * @param openStream - selected in-process or WebSocket stream opener. + */ + constructor( + private readonly ownerCtx: Context, + private readonly connection: ConnectionHandle, + private readonly openStream: RemoteEventStreamOpener, + ) { + this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration) + } + + /** + * Register one typed Remote Event listener in its calling fiber. + * @param callerCtx - fiber Context owning the registration. + * @param event - selected forwarded event. + * @param listener - listener derived from that event's declaration. + * @returns disposer for this exact registration. + */ + subscribe( + callerCtx: Context, + event: Event, + listener: TypertClientEventListener, + ): () => void { + const dispose = privateEvents(callerCtx).on( + this.eventKey(event), + listener as unknown as RemoteEventListener, + ) + return () => { dispose() } + } + + /** Withdraw the generation source and wait for active listener work to quiesce. */ + async dispose(): Promise { + this.unregisterGeneration() + await Promise.allSettled([this.activeGeneration]) + } + + /** Track the current generation so plugin disposal waits for listener work to stop. */ + private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => { + const tracked = this.pumpEvents(signal, ready).finally(() => { + if (this.activeGeneration === tracked) this.activeGeneration = undefined + }) + this.activeGeneration = tracked + return tracked + } + + /** Deliver one notification through Cordis while containing listener failures. */ + private deliver(frame: RemoteEventEmitFrame): void { + void privateEvents(this.ownerCtx) + .parallel(this.eventKey(frame.event), ...frame.args) + .catch((error: unknown) => { this.reportError(frame.event, error) }) + } + + /** Run one Connection generation over the forwarded-event logical stream. */ + private async pumpEvents(signal: AbortSignal, ready: () => void): Promise { + let clientId: RemoteEventClientId | undefined + const failed = new AbortController() + const generationSignal = AbortSignal.any([signal, failed.signal]) + const active = new Map() + const tasks = new Set>() + const source = this.openStream( + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + generationSignal, + ) + let streamFailed = false + let streamError: unknown + try { + for await (const value of source) { + if (clientId === undefined) { + clientId = parseRemoteEventReady(value) + ready() + continue + } + const frame = parseRemoteEventFrame(value) + if (frame.type === 'cancel') { + active.get(frame.eventId)?.abort(new Error('client api: Remote event was cancelled by the Host')) + continue + } + if (frame.type === 'emit') { + this.deliver(frame) + continue + } + const controller = new AbortController() + active.set(frame.eventId, controller) + const deliverySignal = AbortSignal.any([generationSignal, controller.signal]) + const task = this.answer(frame, clientId, deliverySignal) + .catch((error: unknown) => { + if (!deliverySignal.aborted) failed.abort(error) + }) + .finally(() => { + active.delete(frame.eventId) + tasks.delete(task) + }) + tasks.add(task) + } + } catch (error) { + streamFailed = true + streamError = error + } finally { + for (const controller of active.values()) { + controller.abort(new Error('client api: Remote event generation ended')) + } + await Promise.allSettled(tasks) + } + if (failed.signal.aborted) { + throw toError(failed.signal.reason, 'client api: Remote event result delivery failed') + } + if (signal.aborted) return + if (streamFailed) throw streamError + throw new Error('client api: forwarded Remote event stream ended unexpectedly') + } + + private async answer( + frame: RemoteEventInvocationFrame, + clientId: RemoteEventClientId, + signal: AbortSignal, + ): Promise { + const adapter = this.ownerCtx.typert.contexts.getClient('agent') + let target: Context | undefined + try { + target = adapter?.resolve(frame.agentId) + } catch (error) { + this.reportError(frame.event, error) + } + let outcome: RemoteEventReplyOutcome = { kind: 'next' } + if (target !== undefined) { + try { + outcome = await this.dispatchWaterfall(target, frame, signal) + } catch (error) { + if (signal.aborted) return + outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) } + } + } + if (signal.aborted) return + const result: RemoteEventResult = { + clientId, + eventId: frame.eventId, + outcome: outcome.kind === 'result' && outcome.value === undefined + ? { kind: 'result' } + : outcome, + } + const response = await this.connection.rpc.call( + '/api', + REMOTE_EVENT_RESULT_ENDPOINT, + { args: result }, + signal, + ) + if (!response.ok) throw new Error(response.error.message) + } + + private async dispatchWaterfall( + target: Context, + frame: RemoteEventInvocationFrame, + signal: AbortSignal, + ): Promise { + const request = { + ...frame.request, + agent: target, + signal, + } + const value = await abortable( + Promise.resolve(privateEvents(target).waterfall( + target, + this.eventKey(frame.event), + request, + () => Promise.resolve(REMOTE_EVENT_NEXT), + )), + signal, + ) + if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) { + throw new TypeError('Remote event listener result is not lossless JSON data') + } + return value === REMOTE_EVENT_NEXT + ? { kind: 'next' } + : { kind: 'result', value } + } + + private eventKey(event: string): string { + return `${this.eventPrefix}${event}` + } + + private reportError(event: string, error: unknown): void { + console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) + } +} + +/** Validate and return the Client identity from one generation's opening item. */ +function parseRemoteEventReady(value: unknown): RemoteEventClientId { + if (!isRemoteEventRecord(value) + || !hasExactRemoteEventKeys(value, ['type', 'clientId']) + || value.type !== 'ready' + || !isRemoteEventClientId(value.clientId)) { + throw new TypeError('client api: forwarded Remote event stream did not begin with ready') + } + return value.clientId +} + +/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ +function parseRemoteEventFrame(value: unknown): Exclude { + if (!isRemoteEventRecord(value)) invalidRemoteEventFrame() + if (value.type === 'cancel' + && hasExactRemoteEventKeys(value, ['type', 'eventId']) + && isRemoteEventId(value.eventId)) { + return { type: 'cancel', eventId: value.eventId } + } + if (value.type === 'emit' + && hasExactRemoteEventKeys(value, ['type', 'event', 'args']) + && validRemoteEventName(value.event) + && Array.isArray(value.args) + && isRemoteJsonValue(value.args)) { + return { type: 'emit', event: value.event, args: value.args } + } + if (value.type === 'waterfall' + && hasExactRemoteEventKeys(value, ['type', 'event', 'eventId', 'agentId', 'request']) + && validRemoteEventName(value.event) + && isRemoteEventId(value.eventId) + && isRemoteEventAgentId(value.agentId) + && isRemoteEventRecord(value.request) + && !Object.hasOwn(value.request, 'agent') + && !Object.hasOwn(value.request, 'signal') + && isRemoteJsonValue(value.request)) { + return { + type: 'waterfall', + event: value.event, + eventId: value.eventId, + agentId: value.agentId, + request: value.request, + } + } + invalidRemoteEventFrame() +} + +function isRemoteEventRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function hasExactRemoteEventKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value) + return ownKeys.length === keys.length && keys.every(key => Object.hasOwn(value, key)) +} + +function validRemoteEventName(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function invalidRemoteEventFrame(): never { + throw new TypeError('client api: invalid forwarded Remote event frame') +} + +/** Race listener completion against its delivery lifetime. */ +async function abortable(value: T | PromiseLike, signal: AbortSignal): Promise { + signal.throwIfAborted() + let rejectAbort: ((reason: unknown) => void) | undefined + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort?.(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([Promise.resolve(value), aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +function privateEvents(ctx: Context): PrivateEventContext { + return ctx +} + +function toError(reason: unknown, message: string): Error { + return reason instanceof Error ? reason : new Error(message, { cause: reason }) +} diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts new file mode 100644 index 0000000000..799a371ef5 --- /dev/null +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -0,0 +1,210 @@ +/** Reconnecting lifecycle for one single-consumer Remote stream. */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStreamCarrierError } from './stream-client.ts' + +/** One item annotated with the physical Remote-stream generation that delivered it. */ +export interface RemoteStreamItem { + /** Monotone physical generation number within this logical stream. */ + readonly generation: number + /** Decoded item yielded by the generated Remote method. */ + readonly value: Item + /** Cancellation lifetime of the generation that delivered this item. */ + readonly signal: AbortSignal + /** Mark this generation's opening baseline or cursor as accepted. */ + accept(): void +} + +/** Domain-owned operations used by {@link RemoteStream}. */ +export interface RemoteStreamOptions { + /** Diagnostic owner name used for cancellation failures. */ + readonly name: string + /** Open one physical generation of the logical stream. */ + readonly open: (signal: AbortSignal) => AsyncIterable + /** Classify a normal generation end after or before its opening item was accepted. */ + readonly ended: (accepted: boolean) => Error + /** Observe a retryable carrier loss before the supervisor waits or reopens. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void +} + +/** + * Reopens one logical Remote stream across carrier generations. + * + * The Gateway owns physical retry timing, cancellation, and replacement. The + * domain consumer owns its opening item and every later item, and calls + * {@link RemoteStreamItem.accept} only after validating the opening + * baseline or cursor. + */ +export class RemoteStream implements AsyncIterable> { + private readonly lifetime = new AbortController() + private generationAbort: AbortController | undefined + private iterator: AsyncGenerator> | undefined + private closing: Promise | undefined + private revision = 0 + private taken = false + + /** + * @param connection - observable Host generation source used to pace retries. + * @param options - domain stream opener, end classification, and diagnostics. + */ + constructor( + private readonly connection: Pick, + private readonly options: RemoteStreamOptions, + ) {} + + /** Cancellation lifetime shared by the stream and sibling page requests. */ + get signal(): AbortSignal { + return this.lifetime.signal + } + + /** Interrupt the current generation and immediately request a replacement. */ + restart(): void { + if (this.lifetime.signal.aborted) return + this.revision++ + this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`)) + } + + /** + * Permanently stop this stream and wait for its iterator to close. + * @returns when the active generation and consumer iterator are quiescent. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing + if (!this.lifetime.signal.aborted) { + const reason = new Error(`${this.options.name} disposed`) + this.lifetime.abort(reason) + this.generationAbort?.abort(reason) + } + const iterator = this.iterator + if (iterator === undefined) return Promise.resolve() + const closing = closeRemoteStreamIterator(iterator) + this.closing = closing + return closing + } + + /** @inheritdoc */ + [Symbol.asyncIterator](): AsyncIterator> { + if (this.taken) throw new Error(`${this.options.name} already has a consumer`) + this.taken = true + const iterator = this.read() + this.iterator = iterator + return iterator + } + + private async * read(): AsyncGenerator> { + let attempt = 0 + let generation = 0 + let observedRevision = this.revision + try { + while (!isAborted(this.lifetime.signal)) { + if (observedRevision !== this.revision) { + observedRevision = this.revision + attempt = 0 + } + const revision = this.revision + const generationAbort = new AbortController() + this.generationAbort = generationAbort + const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal]) + const generationId = ++generation + let accepted = false + try { + for await (const value of this.options.open(signal)) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) break + yield { + generation: generationId, + value, + signal, + accept: () => { + if (this.generationAbort !== generationAbort || revision !== this.revision) return + accepted = true + attempt = 0 + }, + } + } + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + throw this.options.ended(accepted) + } catch (error) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + if (!(error instanceof RemoteStreamCarrierError)) throw error + this.options.carrierFailed?.(error) + if (revision !== this.revision) continue + attempt++ + try { + await waitForRemoteStreamRetry(this.connection, error, attempt, signal) + } catch (retryError) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + throw retryError + } + } finally { + this.generationAbort = undefined + if (!generationAbort.signal.aborted) { + generationAbort.abort(new Error(`${this.options.name} generation ended`)) + } + } + } + } finally { + if (!this.lifetime.signal.aborted) { + this.lifetime.abort(new Error(`${this.options.name} consumer closed`)) + } + this.generationAbort?.abort(this.lifetime.signal.reason) + this.generationAbort = undefined + } + } +} + +async function waitForRemoteStreamRetry( + connection: Pick, + error: RemoteStreamCarrierError, + attempt: number, + signal: AbortSignal, +): Promise { + signal.throwIfAborted() + if (connection.hostDescription.getSnapshot() !== undefined) { + if (attempt === 1) return + throw error + } + await new Promise((resolve, reject) => { + const subscription: { + dispose?: () => void + finished: boolean + } = { finished: false } + const finish = (failure?: Error): void => { + if (subscription.finished) return + subscription.finished = true + subscription.dispose?.() + signal.removeEventListener('abort', aborted) + if (failure === undefined) resolve() + else reject(failure) + } + const inspect = (): void => { + if (connection.hostDescription.getSnapshot() !== undefined) finish() + } + const aborted = (): void => { + finish(new Error('Remote stream retry aborted', { cause: signal.reason })) + } + const dispose = connection.hostDescription.subscribe(inspect) + subscription.dispose = dispose + if (subscription.finished) dispose() + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) aborted() + else inspect() + }) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +async function closeRemoteStreamIterator( + iterator: AsyncIterator>, +): Promise { + try { + await iterator.return?.() + } catch { + // The disposed logical stream has no remaining consumer for cancellation failures. + } +} diff --git a/packages/api/gateway/src/client/snapshot-stream.ts b/packages/api/gateway/src/client/snapshot-stream.ts new file mode 100644 index 0000000000..daa9660df9 --- /dev/null +++ b/packages/api/gateway/src/client/snapshot-stream.ts @@ -0,0 +1,88 @@ +/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */ + +import type { RemoteStream } from './remote-stream.ts' + +/** Domain operations for one snapshot stream. */ +export interface RemoteSnapshotStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string + /** Distinguish the opening snapshot from later deltas. */ + readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot + /** Atomically replace the domain model from a complete snapshot. */ + readonly replace: (snapshot: Snapshot) => void + /** Apply one incremental update after the generation snapshot. */ + readonly update: (delta: Delta) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** + * Consumes generations that each contain exactly one opening snapshot followed by deltas. + * + * The previous domain snapshot remains published while the underlying stream retries. A + * replacement becomes accepted only after the domain owner applies it successfully. + */ +export class RemoteSnapshotStream { + private started = false + private disposed = false + private done: Promise | undefined + + /** + * @param stream - reconnecting physical-generation stream. + * @param options - frame discriminator and domain state destinations. + */ + constructor( + private readonly stream: RemoteStream, + private readonly options: RemoteSnapshotStreamOptions, + ) {} + + /** Start the single consumer; repeated calls are inert. */ + start(): void { + if (this.started) return + this.started = true + this.done = this.consume() + } + + /** Replace the active physical generation without discarding the published snapshot. */ + restart(): void { + this.stream.restart() + } + + /** + * Permanently stop the stream and wait for its consumer to become quiescent. + * @returns when no generation or callback can still run. + */ + async dispose(): Promise { + this.disposed = true + await this.stream.dispose() + await this.done + } + + private async consume(): Promise { + let generation = 0 + let snapshotSeen = false + try { + for await (const item of this.stream) { + if (item.generation !== generation) { + generation = item.generation + snapshotSeen = false + } + if (this.options.isSnapshot(item.value)) { + if (snapshotSeen) { + throw new Error(`${this.options.name} emitted more than one opening snapshot`) + } + this.options.replace(item.value) + snapshotSeen = true + item.accept() + continue + } + if (!snapshotSeen) { + throw new Error(`${this.options.name} emitted an update before its opening snapshot`) + } + this.options.update(item.value) + } + } catch (error) { + if (!this.disposed) this.options.failed(error) + } + } +} diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts new file mode 100644 index 0000000000..0dd56cc72d --- /dev/null +++ b/packages/api/gateway/src/client/stream-client.ts @@ -0,0 +1,348 @@ +/** Browser owner for the Gateway multiplexed Remote stream socket. */ + +import { + parseRemoteStreamServerMessage, + REMOTE_STREAM_MUX_PATH, + type RemoteStreamClientMessage, + type RemoteStreamServerMessage, +} from '../stream-protocol.ts' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' + +const INTERNAL_BASE = 'http://dsh.internal' +const RECONNECT_BASE_MS = 500 +const RECONNECT_FACTOR = 2 +const RECONNECT_MAX_MS = 10_000 + +/** One Host-reported Remote stream failure. */ +export class RemoteStreamError extends Error { + /** Stable carrier or Gateway error category. */ + readonly code: string + /** Host-provided structured failure context. */ + readonly details: object + + /** + * @param code - stable Gateway or business error category. + * @param message - Host-provided failure description. + * @param details - Host-provided structured failure context. + */ + constructor(code: string, message: string, details: object) { + super(message) + this.name = 'RemoteStreamError' + this.code = code + this.details = details + } +} + +/** Physical Remote stream socket failure that may be retried by a domain transport. */ +export class RemoteStreamCarrierError extends Error { + /** + * @param message - physical carrier failure description. + * @param options - optional causal error. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'RemoteStreamCarrierError' + } +} + +interface SocketWaiter { + resolve(socket: WebSocket): void + reject(error: unknown): void +} + +/** Keep one physical WebSocket and share it among independently cancellable Remote streams. */ +export class RemoteStreamMuxClient { + private socket: WebSocket | undefined + private cancelCandidate: ((error: Error) => void) | undefined + private keepAlive: Promise | undefined + private keepAliveAbort: AbortController | undefined + private readonly streams = new Map() + private readonly waiters = new Set() + private running = false + private disposed = false + + /** Start the persistent physical connection; repeated calls are inert. */ + start(): void { + if (this.running || this.disposed) return + this.running = true + this.maintain() + } + + /** + * Open one logical stream on the persistent physical connection. + * @param endpoint - Typert Remote stream endpoint. + * @param payload - endpoint request encoded on the wire. + * @param signal - cancellation for this logical stream. + * @returns Host items until completion, cancellation, or failure. + */ + async *open( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator { + this.start() + signal.throwIfAborted() + const streamId = randomUUID() + const inbox = new StreamInbox() + let carrier: WebSocket | undefined + let opened = false + let terminal = false + const abort = (): void => { inbox.fail(signal.reason) } + signal.addEventListener('abort', abort, { once: true }) + try { + const socket = await this.waitForSocket(signal) + signal.throwIfAborted() + carrier = socket + this.streams.set(streamId, inbox) + this.send(socket, { type: 'open', streamId, endpoint, payload }) + opened = true + while (true) { + const frame = await inbox.next() + signal.throwIfAborted() + if (frame.type === 'item') { + yield frame.value + continue + } + terminal = true + if (frame.type === 'error') { + throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details) + } + return + } + } finally { + signal.removeEventListener('abort', abort) + this.streams.delete(streamId) + if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { + this.send(carrier, { type: 'cancel', streamId }) + } + } + } + + /** + * Permanently stop reconnecting, close the physical socket, and fail every active logical stream. + * @returns once the background connection loop has stopped. + */ + async close(): Promise { + if (!this.disposed) { + this.disposed = true + this.running = false + const error = new Error('api gateway: Remote stream client disposed') + this.keepAliveAbort?.abort(error) + this.keepAliveAbort = undefined + this.failAll(error) + for (const waiter of [...this.waiters]) waiter.reject(error) + this.cancelCandidate?.(error) + const socket = this.socket + this.socket = undefined + socket?.close(1000, 'disposed') + } + await this.keepAlive + } + + private connect(): Promise { + const socket = new WebSocket(remoteStreamUrl()) + const connecting = new Promise((resolve, reject) => { + let settled = false + const rejectCandidate = (error: Error): void => { + settled = true + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('message', received) + socket.removeEventListener('close', closed) + this.cancelCandidate = undefined + socket.close() + reject(error) + } + const opened = (): void => { + settled = true + this.cancelCandidate = undefined + this.socket = socket + for (const waiter of [...this.waiters]) waiter.resolve(socket) + resolve(socket) + } + const failed = (): void => { + if (!settled) { + rejectCandidate(new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket failed to open', + )) + return + } + const error = new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed') + this.lost(socket, error) + socket.close() + } + const closed = (): void => { + if (!settled) { + rejectCandidate(new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket closed before opening', + )) + return + } + this.lost(socket) + } + const received = (event: MessageEvent): void => { this.receive(socket, event.data) } + this.cancelCandidate = rejectCandidate + socket.addEventListener('open', opened, { once: true }) + socket.addEventListener('error', failed, { once: true }) + socket.addEventListener('message', received) + socket.addEventListener('close', closed, { once: true }) + }) + return connecting + } + + private waitForSocket(signal: AbortSignal): Promise { + signal.throwIfAborted() + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket) + if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed')) + this.start() + return new Promise((resolve, reject) => { + const aborted = (): void => { waiter.reject(signal.reason) } + const cleanup = (): void => { + this.waiters.delete(waiter) + signal.removeEventListener('abort', aborted) + } + const waiter: SocketWaiter = { + resolve: (socket) => { + cleanup() + resolve(socket) + }, + reject: (error) => { + cleanup() + // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + reject(error) + }, + } + this.waiters.add(waiter) + signal.addEventListener('abort', aborted, { once: true }) + }) + } + + private receive(socket: WebSocket, data: unknown): void { + if (socket !== this.socket) return + try { + if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') + const frame = parseRemoteStreamServerMessage(data) + this.streams.get(frame.streamId)?.push(frame) + } catch (error) { + const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error }) + this.failAll(failure) + this.lost(socket, failure) + socket.close(4002, 'invalid Remote stream frame') + } + } + + private lost( + socket: WebSocket, + error: RemoteStreamCarrierError = new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket closed', + ), + ): void { + if (this.socket !== socket) return + this.socket = undefined + this.failAll(error) + this.maintain(error) + } + + private maintain(previousFailure?: Error): void { + if (!this.running) return + if (this.keepAlive !== undefined) { + void this.keepAlive.then(() => { this.maintain(previousFailure) }) + return + } + const abort = new AbortController() + this.keepAliveAbort = abort + const task = this.reconnect(abort.signal, previousFailure) + this.keepAlive = task + void task.then(() => { + this.keepAlive = undefined + this.keepAliveAbort = undefined + }) + } + + private async reconnect(signal: AbortSignal, previousFailure?: Error): Promise { + let attempt = 0 + let failure = previousFailure + while (this.isRunning(signal) && this.socket?.readyState !== WebSocket.OPEN) { + if (failure !== undefined) { + attempt += 1 + console.warn(`[api-gateway] Remote stream connection unavailable, retry #${String(attempt)}`, failure) + await sleep(backoffDelay(attempt), signal) + if (!this.isRunning(signal)) return + } + try { + await this.connect() + return + } catch (error) { + if (!this.isRunning(signal)) return + failure = error as Error + } + } + } + + private isRunning(signal: AbortSignal): boolean { + return this.running && !signal.aborted + } + + private failAll(error: unknown): void { + for (const stream of this.streams.values()) stream.fail(error) + } + + private send(socket: WebSocket, message: RemoteStreamClientMessage): void { + socket.send(JSON.stringify(message)) + } +} + +function backoffDelay(attempt: number): number { + const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * RECONNECT_FACTOR ** Math.max(0, attempt - 1)) + return cap / 2 + Math.random() * (cap / 2) +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + }) +} + +class StreamInbox { + private readonly frames: RemoteStreamServerMessage[] = [] + private wake: (() => void) | undefined + private failure: Error | undefined + + push(frame: RemoteStreamServerMessage): void { + if (this.failure !== undefined) return + this.frames.push(frame) + this.wake?.() + this.wake = undefined + } + + fail(error: unknown): void { + if (this.failure !== undefined) return + this.failure = error instanceof Error ? error : new Error(String(error), { cause: error }) + this.frames.length = 0 + this.wake?.() + this.wake = undefined + } + + async next(): Promise { + while (this.frames.length === 0) { + if (this.failure !== undefined) throw this.failure + await new Promise((resolve) => { this.wake = resolve }) + } + return this.frames.shift() as RemoteStreamServerMessage + } +} + +function remoteStreamUrl(): string { + const location = (globalThis as { location?: { origin?: string } }).location + const base = location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE + const url = new URL(REMOTE_STREAM_MUX_PATH, base) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 9edb09d9b5..add93adc8b 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1,14 +1,18 @@ /** * Live Typert Remote dispatch over Cordis Services and registered providers. - * Transport, request correlation, and response envelopes belong to Connection. + * Unary transport and response envelopes belong to Connection; live Remote + * streams use the Gateway-owned WebSocket mux. * @module @deepseek-ai/dsh-api-gateway */ +import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { remoteMethods, TypertLookupFailure, + TypertRemoteFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypertCodec, @@ -18,12 +22,47 @@ import type { InvokeRemoteRequest, TypertGateway, TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, } from './types.ts' +import { + RemoteStreamMuxServer, + rejectRemoteStreamUpgrade, +} from './stream-server.ts' +import { + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_READY, + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_STREAM_MUX_PATH, + isRemoteEventAgentId, + isRemoteJsonValue, + parseRemoteEventResult, + projectRemoteEventRequest, + restoreRemoteEventRejection, + type RemoteEventCancellationFrame, + type RemoteEventClientId, + type RemoteEventEmitFrame, + type RemoteEventId, + type RemoteEventInvocationFrame, + type RemoteEventReadyFrame, + type RemoteStreamFailure, +} from './stream-protocol.ts' export type { InvokeRemoteRequest, TypertGateway, TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventContext, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, } from './types.ts' interface GatewayErrorOptions { @@ -36,6 +75,34 @@ interface ResolvedBinding { readonly original: object } +interface PreparedInvocation { + readonly endpoint: string + readonly descriptor: InvocationDescriptor + readonly receiver: object + readonly args: readonly unknown[] + readonly method: (...args: never[]) => unknown +} + +interface RegisteredRemoteEventSource { + readonly lifetime: AbortController + readonly done: Promise +} + +interface RemoteEventClient { + readonly id: RemoteEventClientId + readonly queue: RemoteEventQueue + readonly deliveries: Map +} + +interface PendingRemoteEvent { + readonly id: RemoteEventId + readonly source: TypertRemoteEventInvocation + readonly frame: RemoteEventInvocationFrame + readonly deliveries: Set + releaseContext: () => void + releaseSignal: () => void +} + type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal @@ -90,7 +157,16 @@ class RemoteInvocationCancelled extends Error { export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + /** Carrier adapter shared by the WebSocket mux and local Host transports. */ + readonly wireStream: TypertGatewayWireStream = { + open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + failure: error => rpcError(error), + } + private srcClaims: ReadonlySet | undefined + private remoteEvents: RegisteredRemoteEventSource | undefined + private readonly remoteEventClients = new Map() + private readonly pendingRemoteEvents = new Map() /** * Register the Gateway against the active Typert registry. @@ -109,9 +185,63 @@ export class TypertGatewayService extends Service implements TypertGateway { { authority: 'trusted-host' }, ) }) + ctx.inject(['connection', 'webServer'], (webCtx) => { + const mux = new RemoteStreamMuxServer( + (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + this.wireStream.failure, + ) + webCtx.effect(() => { + const route: WebUpgradeRoute = { + path: REMOTE_STREAM_MUX_PATH, + handler: (req, socket, head) => { + if (!webCtx.connection.isTrustedRequest(req, 'trusted-host')) { + rejectRemoteStreamUpgrade(socket) + return + } + mux.handleUpgrade(req, socket, head) + }, + } + const unregister = webCtx.webServer.registerUpgrade(route) + return async () => { + unregister() + await mux.close() + } + }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`) + }) + } + + /** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise { + if (this.remoteEvents !== undefined) { + throw new Error('typert gateway: forwarded Remote event source is already registered') + } + const lifetime = new AbortController() + const stream = source(lifetime.signal) + const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => { + if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return + this.closeRemoteEvents(error) + this.remoteEvents = undefined + lifetime.abort(error) + }) + const registration: RegisteredRemoteEventSource = { lifetime, done } + this.remoteEvents = registration + return async () => { + if (this.remoteEvents === registration) { + this.remoteEvents = undefined + const error = new Error('typert gateway: forwarded Remote event source was removed') + registration.lifetime.abort(error) + this.closeRemoteEvents(error) + } + await registration.done + } } private claimsEndpoint(endpoint: string): boolean { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true @@ -143,6 +273,315 @@ export class TypertGatewayService extends Service implements TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { + const prepared = await this.prepareInvocation(request) + if (prepared.descriptor.mode === 'stream') { + throw new TypertGatewayError( + 'signature-invalid', + prepared.endpoint, + 'stream Remote methods must be opened through the stream carrier', + ) + } + + let result: unknown + try { + result = await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + throw error + } + // A weak descriptor declares no return type, so nothing returned is a void + // result and rides the wire as an absent value field. A strict descriptor + // keeps its schema: there, undefined has to be a declared result. + if (result === undefined && prepared.descriptor.result.mode !== 'strict') return result + return decode(prepared.descriptor.result, result, 'result-invalid', prepared.endpoint, 'result') + } + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns an iterable whose items have passed the generated result codec. + */ + async stream(request: InvokeRemoteRequest): Promise> { + const prepared = await this.prepareInvocation(request) + if (prepared.descriptor.mode !== 'stream') { + throw new TypertGatewayError( + 'signature-invalid', + prepared.endpoint, + 'unary Remote methods cannot be opened through the stream carrier', + ) + } + let source: unknown + try { + source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + throw error + } + if (!isIterable(source)) { + throw new TypertGatewayError( + 'result-invalid', + prepared.endpoint, + 'stream Remote method did not return Iterable or AsyncIterable', + { field: 'result' }, + ) + } + return validatedStream( + source, + prepared.descriptor.result, + prepared.endpoint, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): Promise { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { + try { + const result = parseRemoteEventResultPayload(payload) + const client = this.remoteEventClients.get(result.clientId) + if (client === undefined) { + throw new Error('typert gateway: Remote event result identifies no active event stream') + } + this.receiveRemoteEventResult(client, result) + return { ok: true, value: undefined } + } catch (error) { + return rpcFailure(error) + } + } + return this.invokeRpc(endpoint, payload, signal) + } + + private async openWireStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): Promise> { + if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + return this.openRemoteEvents(payload, signal) + } + return this.stream(remoteRequest(endpoint, payload, signal)) + } + + private async *openRemoteEvents( + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator< + RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame + | RemoteEventReadyFrame + > { + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args) + || Reflect.ownKeys(payload.args).length !== 0) { + throw new TypertGatewayError( + 'arguments-invalid', + REMOTE_EVENT_STREAM_ENDPOINT, + 'forwarded Remote event stream requires an empty args object', + ) + } + const registration = this.remoteEvents + if (registration === undefined) { + throw new TypertGatewayError( + 'service-unavailable', + REMOTE_EVENT_STREAM_ENDPOINT, + 'forwarded Remote event source is unavailable', + ) + } + const lifetime = AbortSignal.any([signal, registration.lifetime.signal]) + let clientId = randomUUID() as RemoteEventClientId + while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId + const client: RemoteEventClient = { + id: clientId, + queue: new RemoteEventQueue(), + deliveries: new Map(), + } + this.remoteEventClients.set(clientId, client) + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + try { + yield { ...REMOTE_EVENT_STREAM_READY, clientId } + yield* client.queue.iterate(lifetime) + } finally { + this.removeRemoteEventClient(client) + } + } + + private async consumeRemoteEvents( + source: AsyncIterable, + signal: AbortSignal, + ): Promise { + for await (const dispatch of source) { + if (signal.aborted) { + if ('context' in dispatch) dispatch.reject(signal.reason) + return + } + if ('context' in dispatch) this.startRemoteEvent(dispatch) + else this.broadcastRemoteEvent(dispatch) + } + if (!signal.aborted) { + throw new Error('typert gateway: forwarded Remote event source ended unexpectedly') + } + } + + private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void { + assertRemoteEventFrame(frame) + const wire: RemoteEventEmitFrame = { + type: 'emit', + event: frame.event, + args: frame.args, + } + for (const client of this.remoteEventClients.values()) client.queue.push(wire) + } + + private startRemoteEvent(source: TypertRemoteEventInvocation): void { + try { + assertRemoteEventName(source) + const context = this.ctx.typert.contexts.identifyHost(source.context.value) + if (context === undefined) { + source.resolve({ kind: 'next' }) + return + } + if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) { + throw new TypeError( + 'typert gateway: scoped Remote events require a non-empty Agent identity', + ) + } + const projected = projectRemoteEventRequest(source.request, source.context.subject) + let id = randomUUID() as RemoteEventId + while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId + let releaseContext: () => void + try { + const dispose = source.context.value.effect( + () => () => { + this.cancelRemoteEvent( + pending, + new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`), + ) + }, + `api-gateway: Remote event ${JSON.stringify(source.event)}`, + ) + releaseContext = () => { void dispose() } + } catch { + source.resolve({ kind: 'next' }) + return + } + const signals = new Set(projected.signal === undefined ? [] : [projected.signal]) + const abort = (): void => { + const reason = [...signals].find(signal => signal.aborted)?.reason as unknown + this.cancelRemoteEvent(pending, reason instanceof Error + ? reason + : new Error('typert gateway: Remote event was cancelled', { cause: reason })) + } + const pending: PendingRemoteEvent = { + id, + source, + frame: { + type: 'waterfall', + event: source.event, + eventId: id, + agentId: context.identity, + request: projected.request, + }, + deliveries: new Set(), + releaseContext, + releaseSignal: () => { + for (const signal of signals) signal.removeEventListener('abort', abort) + }, + } + this.pendingRemoteEvents.set(id, pending) + for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) + if ([...signals].some(signal => signal.aborted)) abort() + else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + } catch (error) { + source.reject(error) + } + } + + private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.add(client) + client.deliveries.set(pending.id, pending) + client.queue.push(pending.frame) + } + + private receiveRemoteEventResult( + client: RemoteEventClient, + result: ReturnType, + ): void { + const pending = this.pendingRemoteEvents.get(result.eventId) + if (pending === undefined || !pending.deliveries.has(client)) return + this.removeRemoteEventDelivery(pending, client) + if (result.outcome.kind === 'result') { + this.settleRemoteEvent(pending, { + kind: 'result', + value: result.outcome.value, + }) + } else if (result.outcome.kind === 'rejected') { + this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error)) + } else if (pending.deliveries.size === 0) { + this.settleRemoteEvent(pending, { kind: 'next' }) + } + } + + private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.delete(client) + client.deliveries.delete(pending.id) + } + + private removeRemoteEventClient(client: RemoteEventClient): void { + this.remoteEventClients.delete(client.id) + for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client) + client.queue.end() + } + + private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void { + this.finishRemoteEvent(pending) + pending.source.resolve(outcome) + } + + private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void { + if (this.pendingRemoteEvents.get(pending.id) !== pending) return + this.finishRemoteEvent(pending) + pending.source.reject(reason) + } + + private finishRemoteEvent(pending: PendingRemoteEvent): void { + this.pendingRemoteEvents.delete(pending.id) + pending.releaseSignal() + pending.releaseContext() + const clients = new Set(pending.deliveries) + for (const client of clients) this.removeRemoteEventDelivery(pending, client) + const cancellation: RemoteEventCancellationFrame = { + type: 'cancel', + eventId: pending.id, + } + for (const client of clients) client.queue.push(cancellation) + } + + private closeRemoteEvents(reason: unknown): void { + for (const pending of [...this.pendingRemoteEvents.values()]) { + this.cancelRemoteEvent(pending, reason) + } + for (const client of [...this.remoteEventClients.values()]) client.queue.end() + } + + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + try { + const value = await this.invoke(remoteRequest(endpoint, payload, signal)) + // A void or explicitly absent business result carries no `value` field; + // JSON has no `undefined`, and the envelope's optional slot is the one + // representation of absence that both args and results already use. + return { ok: true, value } + } catch (error) { + return rpcFailure(error) + } + } + + private async prepareInvocation(request: InvokeRemoteRequest): Promise { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) @@ -168,57 +607,7 @@ export class TypertGatewayService extends Service implements TypertGateway { `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } - - let result: unknown - try { - result = await Reflect.apply(method, receiver, args) as unknown - } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error) - throw error - } - // A weak descriptor declares no return type, so nothing returned is a void - // result and rides the wire as an absent value field. A strict descriptor - // keeps its schema: there, undefined has to be a declared result. - if (result === undefined && descriptor.result.mode !== 'strict') return result - return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') - } - - private async dispatchRpc( - endpoint: string, - payload: unknown, - signal: AbortSignal, - ): Promise { - return this.invokeRpc(endpoint, payload, signal) - } - - private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { - try { - const segments = endpoint.split('/') - if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { - throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) - } - const [namespace, method] = segments as [string, string] - if (!isObject(payload) - || !isPlainObject(payload) - || Reflect.ownKeys(payload).length !== 1 - || !Object.hasOwn(payload, 'args') - || !isObject(payload.args) - || !isPlainObject(payload.args)) { - throw new Error('Remote payload must contain exactly one plain-object args field') - } - const value = await this.invoke({ - namespace, - method, - args: payload.args, - signal, - }) - // A void or explicitly absent business result carries no `value` field; - // JSON has no `undefined`, and the envelope's optional slot is the one - // representation of absence that both args and results already use. - return { ok: true, value } - } catch (error) { - return rpcFailure(error) - } + return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { @@ -349,6 +738,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace: binding.namespace, method, ...(marker.method === method ? {} : { implementation: marker.method }), + ...(marker.mode === undefined ? {} : { mode: marker.mode }), invocation: receiver, parameters, ...(cancellation === undefined ? {} : { cancellation }), @@ -468,6 +858,121 @@ export class TypertGatewayService extends Service implements TypertGateway { } } +type RemoteEventWireFrame = + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + +/** Pull-driven queue owned by one connected Client event generation. */ +class RemoteEventQueue { + private readonly frames: RemoteEventWireFrame[] = [] + private waiter: (() => void) | undefined + private closed = false + + push(frame: RemoteEventWireFrame): void { + if (this.closed) return + this.frames.push(frame) + this.waiter?.() + } + + end(): void { + if (this.closed) return + this.closed = true + this.waiter?.() + } + + async *iterate(signal: AbortSignal): AsyncGenerator { + const abort = (): void => { this.end() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (true) { + while (this.frames.length > 0) yield this.frames.shift() as RemoteEventWireFrame + if (this.closed || signal.aborted) return + await new Promise((resolve) => { this.waiter = resolve }) + this.waiter = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + } +} + +function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void { + assertRemoteEventName(frame) + if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) { + throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`) + } +} + +function assertRemoteEventName(frame: { readonly event: unknown }): void { + if (typeof frame.event !== 'string' || frame.event.length === 0) { + throw new TypeError('typert gateway: Remote event name must be a nonempty string') + } +} + +function parseRemoteEventResultPayload(payload: unknown): ReturnType { + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args')) { + throw new Error('typert gateway: Remote event result requires exactly one plain-object args field') + } + return parseRemoteEventResult(payload.args) +} + +function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest { + const segments = endpoint.split('/') + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) + } + const [namespace, method] = segments as [string, string] + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args)) { + throw new Error('Remote payload must contain exactly one plain-object args field') + } + return { namespace, method, args: payload.args, signal } +} + +function isIterable(value: unknown): value is Iterable | AsyncIterable { + return isObject(value) + && (typeof Reflect.get(value, Symbol.iterator) === 'function' + || typeof Reflect.get(value, Symbol.asyncIterator) === 'function') +} + +async function *validatedStream( + source: Iterable | AsyncIterable, + codec: TypertCodec, + endpoint: string, + signal: AbortSignal, +): AsyncGenerator { + const asyncFactory = Reflect.get(source, Symbol.asyncIterator) as unknown + const syncFactory = Reflect.get(source, Symbol.iterator) as unknown + const iterator = typeof asyncFactory === 'function' + ? Reflect.apply(asyncFactory, source, []) as AsyncIterator + : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []) + let rejectAbort: ((error: unknown) => void) | undefined + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { + rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason)) + } + signal.addEventListener('abort', onAbort, { once: true }) + try { + if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason) + while (true) { + const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) + if (next.done === true) return + yield decode(codec, next.value, 'result-invalid', endpoint, 'result') + } + } finally { + signal.removeEventListener('abort', onAbort) + await iterator.return?.() + } +} + function rpcFailure(error: unknown): ConnectionRpcResult { if (error instanceof RemoteInvocationCancelled) { return { @@ -478,6 +983,9 @@ function rpcFailure(error: unknown): ConnectionRpcResult { if (error instanceof TypertLookupFailure) { return { ok: false, error: error.failure as ConnectionRpcError } } + if (error instanceof TypertRemoteFailure) { + return { ok: false, error: error.failure } + } return { ok: false, error: { @@ -488,6 +996,10 @@ function rpcFailure(error: unknown): ConnectionRpcResult { } } +function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure { + return (rpcFailure(error) as Extract).error +} + function endpointOf(namespace: string, method: string): string { return `${namespace}/${method}` } diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts new file mode 100644 index 0000000000..2c6e8ebf70 --- /dev/null +++ b/packages/api/gateway/src/stream-protocol.ts @@ -0,0 +1,399 @@ +/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Exact WebSocket route carrying every Typert Remote stream. */ +export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' + +/** Gateway-internal logical stream carrying application-selected Cordis events. */ +export const REMOTE_EVENT_STREAM_ENDPOINT = '$events' + +/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */ +export const REMOTE_EVENT_RESULT_ENDPOINT = '$events/result' + +/** Empty standard Remote payload used to open the forwarded-event stream. */ +export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const + +/** Discriminator for the first item proving the Host event source is ready. */ +export const REMOTE_EVENT_STREAM_READY = { type: 'ready' } as const + +/** Opaque identity for one active Client Remote Event generation. */ +export type RemoteEventClientId = Branded<'RemoteEventClientId'> + +/** Opaque correlation id for one pending Host-to-Client Remote Event. */ +export type RemoteEventId = Branded<'RemoteEventId'> + +/** Opening item that binds later HTTP results to this active event stream. */ +export interface RemoteEventReadyFrame { + readonly type: 'ready' + readonly clientId: RemoteEventClientId +} + +/** Opaque Agent identity carried by one scoped Remote Event. */ +export type RemoteEventAgentId = Branded<'RemoteEventAgentId'> + +/** One Host notification delivered to a Client generation. */ +export interface RemoteEventEmitFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +/** One pending Agent-scoped waterfall delivered to a Client generation. */ +export interface RemoteEventInvocationFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: RemoteEventId + readonly agentId: RemoteEventAgentId + readonly request: Readonly> +} + +/** Cancellation of a pending waterfall previously delivered under the same id. */ +export interface RemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: RemoteEventId +} + +/** Every item carried by the Gateway-internal forwarded-event stream. */ +export type RemoteEventDownlinkFrame = + | RemoteEventReadyFrame + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + +/** JSON request fields plus the Host cancellation lifetime removed for transport. */ +export interface ProjectedRemoteEventRequest { + readonly request: Readonly> + readonly signal?: AbortSignal +} + +/** Error fields retained when a Client listener rejects a Host waterfall. */ +export interface RemoteEventRejection { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown +} + +/** Client response to one scoped Remote Event delivery. */ +export interface RemoteEventResult { + readonly clientId: RemoteEventClientId + readonly eventId: RemoteEventId + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { readonly kind: 'rejected'; readonly error: RemoteEventRejection } +} + +/** + * Parse one result sent through the Client's `$events/result` HTTP RPC. + * @param value - untrusted result payload. + * @returns validated event correlation and outcome fields. + */ +export function parseRemoteEventResult(value: unknown): RemoteEventResult { + if (!isRecord(value) + || !exactKeys(value, ['clientId', 'eventId', 'outcome']) + || !isRemoteEventClientId(value.clientId) + || !isRemoteEventId(value.eventId) + || !isRecord(value.outcome)) { + throw new Error('api gateway: invalid Remote event result') + } + const outcome = value.outcome + if (outcome.kind === 'next' && exactKeys(outcome, ['kind'])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: 'next' }, + } + } + if (outcome.kind === 'result' + && (exactKeys(outcome, ['kind']) || exactKeys(outcome, ['kind', 'value'])) + && (!Object.hasOwn(outcome, 'value') || isRemoteJsonValue(outcome.value))) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: Object.hasOwn(outcome, 'value') + ? { kind: 'result', value: outcome.value } + : { kind: 'result' }, + } + } + if (outcome.kind === 'rejected' + && exactKeys(outcome, ['kind', 'error'])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: 'rejected', error: parseRemoteEventRejection(outcome.error) }, + } + } + throw new Error('api gateway: invalid Remote event result') +} + +/** + * Remove the direct Agent and cancellation fields from one waterfall request. + * @param value - request object before the waterfall's `next` callback. + * @param subject - Agent used by the Cordis scope carrier. + * @returns JSON-safe request fields and the optional Host cancellation signal. + */ +export function projectRemoteEventRequest( + value: unknown, + subject: object, +): ProjectedRemoteEventRequest { + if (!isPlainRecord(value) || !Object.hasOwn(value, 'agent') || value.agent !== subject) { + throw new TypeError('api gateway: Remote event request must carry its scoped Agent directly') + } + const signal = value.signal + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError('api gateway: Remote event request signal must be an AbortSignal') + } + const request: Record = Object.create(null) as Record + for (const key of Reflect.ownKeys(value)) { + if (key === 'agent' || key === 'signal') continue + const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined + if (typeof key !== 'string' || descriptor?.enumerable !== true) { + throw new TypeError('api gateway: Remote event request has a non-JSON property') + } + request[key] = Reflect.get(value, key) + } + if (!isRemoteJsonValue(request)) { + throw new TypeError('api gateway: Remote event request is not lossless JSON data') + } + return { + request, + ...(signal === undefined ? {} : { signal }), + } +} + +/** + * Project an arbitrary rejection to stable, JSON-safe error fields. + * @param reason - value thrown or rejected by a Client listener. + * @returns wire-safe rejection fields. + */ +export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection { + const record = typeof reason === 'object' && reason !== null ? reason : undefined + const name = stringProperty(record, 'name') ?? 'Error' + const message = stringProperty(record, 'message') ?? String(reason) + const code = stringProperty(record, 'code') + const details = record === undefined ? undefined : Reflect.get(record, 'details') as unknown + return { + name, + message, + ...(code === undefined ? {} : { code }), + ...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }), + } +} + +/** + * Recreate a Client rejection for the Host continuation. + * @param rejection - validated wire-safe error fields. + * @returns an Error preserving the remote name, code, and JSON-safe details. + */ +export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error { + const error = new Error(rejection.message) as Error & { code?: string; details?: unknown } + error.name = rejection.name + if (rejection.code !== undefined) error.code = rejection.code + if (rejection.details !== undefined) error.details = rejection.details + return error +} + +/** + * Test whether a value crosses JSON transport without coercion or omission. + * @param value - candidate boundary value. + * @returns whether the value is losslessly JSON-compatible. + */ +export function isRemoteJsonValue(value: unknown): boolean { + return visitJsonValue(value, new Set()) +} + +/** + * Recognize a non-empty Remote Event correlation id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value is a valid Remote Event id. + */ +export function isRemoteEventId(value: unknown): value is RemoteEventId { + return typeof value === 'string' && value.length > 0 +} + +/** + * Recognize a non-empty Remote Event Client id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value identifies one event-stream generation. + */ +export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId { + return typeof value === 'string' && value.length > 0 +} + +/** + * Recognize the direct Agent identity used by a scoped Remote Event. + * @param value - untrusted wire value. + * @returns whether the value is a non-empty Agent identity. + */ +export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId { + return typeof value === 'string' && value.length > 0 +} + +/** One logical stream request sent from the browser. */ +export type RemoteStreamClientMessage = + | { + readonly type: 'open' + readonly streamId: string + readonly endpoint: string + readonly payload: unknown + } + | { readonly type: 'cancel'; readonly streamId: string } + +/** Carrier-safe failure delivered by the Host. */ +export interface RemoteStreamFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** One logical stream frame sent from the Host. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } + +/** + * Parse and validate one browser-to-Host text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream request. + */ +export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { + return parseMessage(text, (value) => { + if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + return value as unknown as RemoteStreamClientMessage + } + if (value.type === 'open' + && exactKeys(value, ['type', 'streamId', 'endpoint', 'payload']) + && validId(value.streamId) + && typeof value.endpoint === 'string' + && value.endpoint.length > 0) { + return value as unknown as RemoteStreamClientMessage + } + throw new Error('api gateway: invalid Remote stream client message') + }) +} + +/** + * Parse and validate one Host-to-browser text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream frame. + */ +export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage { + return parseMessage(text, (value) => { + if (value.type === 'item' + && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value'])) + && validId(value.streamId)) { + return value as unknown as RemoteStreamServerMessage + } + if (value.type === 'end' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + return value as unknown as RemoteStreamServerMessage + } + if (value.type === 'error' + && exactKeys(value, ['type', 'streamId', 'error']) + && validId(value.streamId) + && isRecord(value.error) + && exactKeys(value.error, ['code', 'message', 'details']) + && typeof value.error.code === 'string' + && typeof value.error.message === 'string' + && isRecord(value.error.details)) { + return value as unknown as RemoteStreamServerMessage + } + throw new Error('api gateway: invalid Remote stream server message') + }) +} + +function parseMessage(text: string, validate: (value: Record) => T): T { + let decoded: unknown + try { + decoded = JSON.parse(text) as unknown + } catch (cause) { + throw new Error('api gateway: Remote stream message is not JSON', { cause }) + } + if (!isRecord(decoded)) throw new Error('api gateway: Remote stream message must be an object') + return validate(decoded) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value) + return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key)) +} + +function validId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function parseRemoteEventRejection(value: unknown): RemoteEventRejection { + if (!isRecord(value) + || !hasOnlyKeys(value, ['name', 'message'], ['code', 'details']) + || typeof value.name !== 'string' + || value.name.length === 0 + || typeof value.message !== 'string' + || (Object.hasOwn(value, 'code') && typeof value.code !== 'string') + || (Object.hasOwn(value, 'details') && !isRemoteJsonValue(value.details))) { + throw new Error('api gateway: invalid Remote event rejection') + } + return { + name: value.name, + message: value.message, + ...(typeof value.code === 'string' ? { code: value.code } : {}), + ...(Object.hasOwn(value, 'details') ? { details: value.details } : {}), + } +} + +function hasOnlyKeys( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Reflect.ownKeys(value) + return required.every(key => Object.hasOwn(value, key)) + && keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key))) +} + +function stringProperty(value: object | undefined, key: string): string | undefined { + if (value === undefined) return undefined + const candidate: unknown = Reflect.get(value, key) + return typeof candidate === 'string' ? candidate : undefined +} + +function visitJsonValue(value: unknown, ancestors: Set): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object') return false + if (ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype + || Reflect.ownKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false + } + return true + } + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts new file mode 100644 index 0000000000..bf7711b81b --- /dev/null +++ b/packages/api/gateway/src/stream-server.ts @@ -0,0 +1,188 @@ +/** Host WebSocket owner for multiplexed Typert Remote streams. */ + +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import WebSocket, { WebSocketServer, type RawData } from 'ws' +import { + parseRemoteStreamClientMessage, + type RemoteStreamFailure, + type RemoteStreamServerMessage, +} from './stream-protocol.ts' + +/** Open one validated Remote stream for a decoded wire request. */ +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +/** Convert an invocation or carrier failure to a stable wire value. */ +export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure + +/** Own the no-server WebSocket acceptor and every active logical stream. */ +export class RemoteStreamMuxServer { + private readonly server = new WebSocketServer({ noServer: true }) + private readonly connections = new Set>() + + /** + * @param open - Gateway stream dispatcher. + * @param failure - Gateway error-to-wire mapper. + */ + constructor( + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + ) {} + + /** + * Upgrade one trusted request and begin serving its logical streams. + * @param req - authenticated HTTP upgrade request. + * @param socket - carrier socket transferred to the WebSocket server. + * @param head - bytes already read after the HTTP upgrade headers. + */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) + const done = connection.run() + this.connections.add(done) + void done.then(() => { this.connections.delete(done) }) + }) + } + + /** Terminate all sockets and wait until every iterator has returned. */ + async close(): Promise { + for (const socket of this.server.clients) socket.terminate() + const closed = Promise.withResolvers() + this.server.close((error) => { + if (error === undefined) closed.resolve() + else closed.reject(error) + }) + await closed.promise + await Promise.all(this.connections) + } +} + +interface ActiveStream { + readonly abort: AbortController + done: Promise +} + +class RemoteStreamMuxConnection { + private readonly streams = new Map() + private writes = Promise.resolve() + + constructor( + private readonly socket: WebSocket, + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + ) {} + + async run(): Promise { + const closed = new Promise((resolve) => { + this.socket.once('close', resolve) + this.socket.once('error', () => { this.socket.terminate() }) + this.socket.on('message', (data, isBinary) => { + if (isBinary) { + this.socket.close(1003, 'text messages required') + return + } + try { + this.receive(rawText(data)) + } catch { + this.socket.close(1008, 'invalid Remote stream request') + } + }) + }) + await closed + const active = [...this.streams.values()] + for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed')) + await Promise.all(active.map(stream => stream.done)) + } + + private receive(text: string): void { + const message = parseRemoteStreamClientMessage(text) + if (message.type === 'cancel') { + this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled')) + return + } + if (this.streams.has(message.streamId)) { + throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) + } + const abort = new AbortController() + const active: ActiveStream = { + abort, + done: Promise.resolve(), + } + this.streams.set(message.streamId, active) + const done = this.pump(message.streamId, message.endpoint, message.payload, active) + active.done = done + const remove = (): void => { this.streams.delete(message.streamId) } + void done.then(remove, remove) + } + + private async pump( + streamId: string, + endpoint: string, + payload: unknown, + active: ActiveStream, + ): Promise { + try { + const source = await this.open(endpoint, payload, active.abort.signal) + for await (const value of source) { + await this.send({ type: 'item', streamId, value }) + } + if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId }) + } catch (error) { + if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) { + try { + await this.send({ type: 'error', streamId, error: this.failure(error) }) + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, 'Remote stream failure could not be delivered') + } + } + } + } + + private send(message: RemoteStreamServerMessage): Promise { + let text: string + try { + text = JSON.stringify(message) + } catch (cause) { + return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause })) + } + const delivery = this.writes.then(() => new Promise((resolve, reject) => { + if (this.socket.readyState !== WebSocket.OPEN) { + reject(new Error('api gateway: Remote stream socket is closed')) + return + } + this.socket.send(text, (error) => { + if (error) reject(error) + else resolve() + }) + })) + this.writes = delivery.catch(() => undefined) + return delivery + } +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +/** + * Reject an upgrade without transferring socket ownership to ws. + * @param socket - carrier socket that receives the HTTP rejection. + */ +export function rejectRemoteStreamUpgrade(socket: Duplex): void { + socket.end([ + 'HTTP/1.1 403 Forbidden', + 'Connection: close', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Length: 9', + '', + 'forbidden', + ].join('\r\n')) +} diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 581e1aa2ce..615eaf90ea 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -3,6 +3,8 @@ * @module @deepseek-ai/dsh-api-gateway/types */ +import type { Context } from '@deepseek-ai/cordis' + /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { /** Remote namespace selected by the generated descriptor. */ @@ -15,6 +17,85 @@ export interface InvokeRemoteRequest { readonly signal?: AbortSignal } +/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */ +export interface TypertRemoteEventFrame { + /** Original Host Cordis event name. */ + readonly event: string + /** Original event argument list after the owner validates it for JSON transport. */ + readonly args: readonly unknown[] +} + +/** Live Host values used to project one scoped Remote Event. */ +export interface TypertRemoteEventContext { + /** Live Host Context identified by the registered Host adapters. */ + readonly value: Context + /** Agent object carried directly by the waterfall request. */ + readonly subject: object +} + +/** Result returned from a Client waterfall, or delegation back to the Host chain. */ +export type TypertRemoteEventOutcome = + | { readonly kind: 'result'; readonly value: unknown } + | { readonly kind: 'next' } + +/** + * One scoped waterfall invocation yielded by the application event source. + * The Gateway alone assigns transport ids and resolves the continuation after + * a Client result or explicit delegation. + */ +export interface TypertRemoteEventInvocation { + /** Original Host Cordis event name. */ + readonly event: string + /** Sole request argument before the waterfall's `next()` callback. */ + readonly request: object + readonly context: TypertRemoteEventContext + /** Resume the source's Cordis listener with a Client result or `next()`. */ + readonly resolve: (outcome: TypertRemoteEventOutcome) => void + /** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */ + readonly reject: (reason: unknown) => void +} + +/** Notification or scoped waterfall accepted from the sole Remote Event source. */ +export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation + +/** + * Open the application-selected event stream for one Client carrier. The + * factory must attach all incremental Host listeners before it returns; the + * Gateway publishes its readiness item immediately afterward. + * @param signal - cancellation shared with the Client stream and registration. + * @returns the long-lived stream of notifications and scoped waterfall invocations. + */ +export type TypertRemoteEventSource = ( + signal: AbortSignal, +) => AsyncIterable + +/** Carrier-facing access to decoded Remote streams and their stable failures. */ +export interface TypertGatewayWireStream { + /** + * Open one logical stream from its wire endpoint and payload. + * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. + * @param payload - decoded carrier payload. + * @param signal - logical-stream cancellation. + * @returns validated stream values. + */ + readonly open: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => Promise> + + /** + * Convert a stream failure to the carrier-safe Remote failure fields. + * @param error - failure raised while opening or consuming a stream. + * @returns stable code, message, and details for the Client. + */ + readonly failure: (error: unknown) => { + readonly code: string + readonly message: string + readonly details: object + } +} + /** Stable infrastructure and boundary failures emitted before or after business execution. */ export type TypertGatewayErrorCode = | 'ambiguous-endpoint' @@ -37,6 +118,16 @@ export type TypertGatewayErrorCode = /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream + + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -44,6 +135,13 @@ export interface TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns an iterable whose items have passed the generated result codec. + */ + stream(request: InvokeRemoteRequest): Promise> } declare module '@deepseek-ai/cordis' { diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts new file mode 100644 index 0000000000..ac3e1db4ed --- /dev/null +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import { + RemoteStreamCarrierError, + RemoteStream, +} from '../src/client/index.ts' + +const DESCRIPTION = { + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, +} + +function hostSource(initiallyAvailable: boolean): { + connection: Pick + publish(available: boolean): void +} { + let current = initiallyAvailable ? DESCRIPTION : undefined + const listeners = new Set<() => void>() + return { + connection: { + hostDescription: { + getSnapshot: () => current, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + }, + publish: (available) => { + current = available ? DESCRIPTION : undefined + for (const listener of listeners) listener() + }, + } +} + +interface Generation { + readonly values?: readonly Item[] + readonly terminal?: Error + readonly hold?: boolean + readonly afterAbortError?: Error + readonly close?: () => Promise +} + +function scripted(generations: Generation[], opened?: () => void) { + return (signal: AbortSignal): AsyncIterable => ({ + async * [Symbol.asyncIterator](): AsyncIterator { + const generation = generations.shift() + if (generation === undefined) throw new Error('fixture has no stream generation') + opened?.() + try { + for (const value of generation.values ?? []) yield value + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + if (generation.afterAbortError !== undefined) throw generation.afterAbortError + } finally { + await generation.close?.() + } + }, + }) +} + +function supervisor( + connection: Pick, + generations: Generation[], + carrierFailed?: (error: RemoteStreamCarrierError) => void, +): RemoteStream { + return new RemoteStream(connection, { + name: 'fixture stream', + open: scripted(generations), + ended: accepted => accepted + ? new RemoteStreamCarrierError('accepted generation ended') + : new Error('generation ended before acceptance'), + ...(carrierFailed === undefined ? {} : { carrierFailed }), + }) +} + +describe('RemoteStream', () => { + it('annotates replacement generations and resets retry state after acceptance', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first'], terminal: new RemoteStreamCarrierError('first lost') }, + { values: ['second'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { generation: 1, value: 'first' } }) + if (first.done) throw new Error('fixture generation ended early') + first.value.accept() + const second = await iterator.next() + expect(second).toMatchObject({ done: false, value: { generation: 2, value: 'second' } }) + if (second.done) throw new Error('fixture replacement ended early') + second.value.accept() + + await stream.dispose() + }) + + it('permits one isolated retry while the Host remains available', async () => { + const source = hostSource(true) + const first = new RemoteStreamCarrierError('first carrier failure') + const repeated = new RemoteStreamCarrierError('isolated retry failed') + const carrierFailed = vi.fn<(error: RemoteStreamCarrierError) => void>() + const stream = supervisor(source.connection, [ + { terminal: first }, + { terminal: repeated }, + ], carrierFailed) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated) + expect(carrierFailed).toHaveBeenNthCalledWith(1, first) + expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) + }) + + it('waits for a replacement Host generation after observing unavailability', async () => { + const source = hostSource(false) + let opened = 0 + const stream = new RemoteStream(source.connection, { + name: 'fixture stream', + open: scripted([ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ], () => { opened++ }), + ended: () => new Error('ended'), + }) + const pending = stream[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(opened).toBe(1) }) + + source.publish(false) + expect(opened).toBe(1) + source.publish(true) + await expect(pending).resolves.toMatchObject({ + done: false, + value: { generation: 2, value: 'ready' }, + }) + await stream.dispose() + }) + + it('contains a Host publication during subscription setup', async () => { + let reads = 0 + let disposed = 0 + const connection = { + hostDescription: { + getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION, + subscribe: (listener: () => void) => { + listener() + return () => { disposed++ } + }, + }, + } + const stream = supervisor(connection, [ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ]) + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(disposed).toBe(1) + await stream.dispose() + }) + + it('restarts with a fresh physical generation', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first'], hold: true }, + { values: ['second'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 1, value: 'first' } }) + + stream.restart() + + await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 2, value: 'second' } }) + await stream.dispose() + }) + + it('drops values and cancellation failures from a replaced generation', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first', 'stale'] }, + { + values: ['second'], + hold: true, + afterAbortError: new Error('replaced generation cancelled'), + }, + { values: ['third'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + const first = await iterator.next() + if (first.done) throw new Error('fixture generation ended early') + + stream.restart() + first.value.accept() + await expect(iterator.next()).resolves.toMatchObject({ + value: { generation: 2, value: 'second' }, + }) + + stream.restart() + await expect(iterator.next()).resolves.toMatchObject({ + value: { generation: 3, value: 'third' }, + }) + await stream.dispose() + }) + + it('honors replacement requested by carrier diagnostics', async () => { + const source = hostSource(true) + const holder: { stream?: RemoteStream } = {} + const carrierFailed = vi.fn(() => { holder.stream?.restart() }) + const stream = supervisor(source.connection, [ + { terminal: new RemoteStreamCarrierError('replace this generation') }, + { values: ['ready'], hold: true }, + ], carrierFailed) + holder.stream = stream + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(carrierFailed).toHaveBeenCalledOnce() + await stream.dispose() + }) + + it('contains replacement during Host-readiness subscription setup', async () => { + const holder: { stream?: RemoteStream } = {} + let subscriptions = 0 + const connection = { + hostDescription: { + getSnapshot: () => undefined, + subscribe: () => { + subscriptions++ + holder.stream?.restart() + return () => {} + }, + }, + } + const stream = supervisor(connection, [ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ]) + holder.stream = stream + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(subscriptions).toBe(1) + await stream.dispose() + }) + + it('waits for generation cleanup during disposal', async () => { + const source = hostSource(true) + const release = Promise.withResolvers() + let closed = false + const stream = supervisor(source.connection, [{ + values: ['ready'], + hold: true, + close: async () => { + await release.promise + closed = true + }, + }]) + const iterator = stream[Symbol.asyncIterator]() + await iterator.next() + const pending = iterator.next() + + const disposing = stream.dispose() + expect(stream.dispose()).toBe(disposing) + await Promise.resolve() + expect(closed).toBe(false) + release.resolve(undefined) + + await expect(disposing).resolves.toBeUndefined() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(closed).toBe(true) + }) + + it('uses the domain normal-end classification and permits one consumer', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [{}]) + const iterator = stream[Symbol.asyncIterator]() + + expect(() => stream[Symbol.asyncIterator]()).toThrow('already has a consumer') + await expect(iterator.next()).rejects.toThrow('generation ended before acceptance') + await stream.dispose() + }) + + it('can be disposed before consumption and ignores later restart', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, []) + + await stream.dispose() + expect(stream.signal.aborted).toBe(true) + stream.restart() + await expect(stream[Symbol.asyncIterator]().next()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts new file mode 100644 index 0000000000..5bfb29e973 --- /dev/null +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -0,0 +1,1046 @@ +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { Context, Service, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { + bindTypertRemote, + Remote, + type InvocationDescriptor, + type TypertContextMap, + type TypertContextWire, + TypertRemoteFailure, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService, { + TypertGatewayError, + type TypertRemoteEventDispatch, + type TypertRemoteEventInvocation, + type TypertRemoteEventOutcome, +} from '@deepseek-ai/dsh-api-gateway' +import { z } from 'zod' +import type { + RemoteEventClientId, + RemoteEventInvocationFrame, +} from '../src/stream-protocol.ts' + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, randomUUID: vi.fn(actual.randomUUID) } +}) + +const randomUuid = vi.mocked(randomUUID) +type AgentWireId = TypertContextWire +const agentId = (value: string): AgentWireId => value as AgentWireId + +class FeedService extends Service { + readonly typertRemote = bindTypertRemote(this, 'feed') + readonly signals: AbortSignal[] = [] + returns = 0 + + constructor(ctx: Context) { + super(ctx, 'feed') + } + + @Remote({ mode: 'stream' }) + async *follow(label: string, signal: AbortSignal): AsyncIterable { + this.signals.push(signal) + try { + yield `${label}:ready` + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + this.returns += 1 + } + } + + @Remote({ mode: 'stream' }) + *sync(label: string): Iterable { + yield `${label}:one` + yield `${label}:two` + } + + @Remote({ mode: 'stream' }) + *invalid(): Iterable { + yield 42 as unknown as string + } + + @Remote({ mode: 'stream' }) + *nonJson(): Iterable { + yield 1n + } + + @Remote({ mode: 'stream' }) + missing(): Iterable { + return null as unknown as Iterable + } + + @Remote({ mode: 'stream' }) + *src(label: string): Iterable { + yield `${label}:src` + } + + @Remote({ mode: 'stream' }) + abortBeforeOpen(signal: AbortSignal): Iterable { + if (signal.aborted) throw new Error('fixture observed pre-open cancellation') + return [] + } + + @Remote({ mode: 'stream' }) + reject(): Iterable { + throw new TypertRemoteFailure({ + code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false }, + }) + } + + @Remote({ mode: 'stream' }) + rejectWithNonJsonDetails(): Iterable { + throw new TypertRemoteFailure({ + code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n }, + }) + } + + unary(label: string): string { + return label + } +} + +const roots: Context[] = [] + +class RemoteEventSourceProbe { + readonly source = (signal: AbortSignal): AsyncIterable => { + this.signal = signal + return this.iterate(signal) + } + + signal: AbortSignal | undefined + private readonly dispatches: TypertRemoteEventDispatch[] = [] + private wake: (() => void) | undefined + + push(dispatch: TypertRemoteEventDispatch): void { + this.dispatches.push(dispatch) + this.wake?.() + this.wake = undefined + } + + private async *iterate(signal: AbortSignal): AsyncGenerator { + const aborted = (): void => { + this.wake?.() + this.wake = undefined + } + signal.addEventListener('abort', aborted, { once: true }) + try { + while (!signal.aborted) { + while (this.dispatches.length > 0) { + yield this.dispatches.shift() as TypertRemoteEventDispatch + } + if (signal.aborted) return + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', aborted) + } + } +} + +interface PendingInvocationProbe { + readonly dispatch: TypertRemoteEventInvocation + readonly outcome: Promise + readonly resolve: (outcome: TypertRemoteEventOutcome) => void + readonly reject: (reason: unknown) => void +} + +function pendingInvocation( + context: Context, + signal?: AbortSignal, + prompt = 'ship', +): PendingInvocationProbe { + const subject = { ctx: context } + const settled = Promise.withResolvers() + const resolve = vi.fn((outcome: TypertRemoteEventOutcome) => { + settled.resolve(outcome) + }) + const reject = vi.fn((reason: unknown) => { + settled.reject(reason) + }) + return { + dispatch: { + event: 'fixture/approval', + request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) }, + context: { value: context, subject }, + resolve, + reject, + }, + outcome: settled.promise, + resolve, + reject, + } +} + +afterEach(async () => { + randomUuid.mockClear() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('Typert Remote streams', () => { + it('opens decoded carrier payloads through the in-process wire adapter', async () => { + const { ctx } = await setup(false) + const source = await ctx.typertGateway.wireStream.open( + 'feed/sync', + { args: { label: 'wire' } }, + new AbortController().signal, + ) + + await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) + }) + + it('validates Iterable and AsyncIterable items and returns the iterator on cancellation', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', + method: 'follow', + args: { label: 'a' }, + signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'a:ready' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') + expect(service.signals).toEqual([abort.signal]) + expect(service.returns).toBe(1) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'b' }, + }))).resolves.toEqual(['b:one', 'b:two']) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'invalid', args: {}, + }))).rejects.toMatchObject({ code: 'result-invalid' }) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'missing', args: {}, + })).rejects.toMatchObject({ code: 'result-invalid' }) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'src', args: { label: 'c' }, + }))).resolves.toEqual(['c:src']) + + const abortedBeforeOpen = new AbortController() + abortedBeforeOpen.abort(new Error('cancelled before open')) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, + })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') + + const abortedBeforeIteration = new AbortController() + abortedBeforeIteration.abort(new Error('cancelled before iteration')) + const preCancelled = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, + }) + await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') + }) + + it('keeps unary and stream invocation modes distinct', async () => { + const { ctx } = await setup(false) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'sync', args: { label: 'a' }, + })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'unary', args: { label: 'a' }, + })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + }) + + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { + const { ctx, service } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'a', 'feed/follow', { label: 'a' }) + sendOpen(socket, 'b', 'feed/follow', { label: 'b' }) + await vi.waitFor(() => { + expect(frames).toEqual(expect.arrayContaining([ + { type: 'item', streamId: 'a', value: 'a:ready' }, + { type: 'item', streamId: 'b', value: 'b:ready' }, + ])) + }) + expect(service.signals.map(signal => signal.aborted)).toEqual([false, false]) + expect(service.returns).toBe(0) + + socket.send(JSON.stringify({ type: 'cancel', streamId: 'a' })) + await vi.waitFor(() => { expect(service.returns).toBe(1) }) + expect(service.signals[0]?.aborted).toBe(true) + expect(service.signals[1]?.aborted).toBe(false) + + sendOpen(socket, 'sync', 'feed/sync', { label: 's' }) + sendOpen(socket, 'invalid', 'feed/invalid', {}) + sendOpen(socket, 'non-json', 'feed/nonJson', {}) + sendOpen(socket, 'rejected', 'feed/reject', {}) + await vi.waitFor(() => { + expect(frames.filter(frame => frame.streamId === 'sync')).toEqual([ + { type: 'item', streamId: 'sync', value: 's:one' }, + { type: 'item', streamId: 'sync', value: 's:two' }, + { type: 'end', streamId: 'sync' }, + ]) + expect(frames.find(frame => frame.streamId === 'invalid')).toMatchObject({ + type: 'error', error: { code: 'internal' }, + }) + expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({ + type: 'error', error: { code: 'internal' }, + }) + expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({ + type: 'error', + streamId: 'rejected', + error: { + code: 'fixture-rejected', + message: 'fixture rejected the stream', + details: { retryable: false }, + }, + }) + }) + + const closed = once(socket, 'close') + sendOpen(socket, 'broken-error', 'feed/rejectWithNonJsonDetails', {}) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + await vi.waitFor(() => { expect(service.returns).toBe(2) }) + expect(service.signals[1]?.aborted).toBe(true) + }) + + it('carries the registered Remote event source and withdraws its active stream', async () => { + const { ctx } = await setup(true) + let sourceSignal: AbortSignal | undefined + const sourceClosed = vi.fn() + const publish = Promise.withResolvers() + const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => { + sourceSignal = signal + return (async function *() { + try { + await publish.promise + yield { event: 'fixture/changed', args: ['settings'] } + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + sourceClosed() + } + })() + } + const unregister = ctx.typertGateway.registerRemoteEvents(source) + expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + .toThrow('forwarded Remote event source is already registered') + + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, 'events', '$events', {}) + + await vi.waitFor(() => { + const eventFrames = frames.filter(frame => frame.streamId === 'events') + expect(eventFrames).toHaveLength(1) + expect(eventFrames[0]).toMatchObject({ + type: 'item', streamId: 'events', value: { type: 'ready' }, + }) + expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') + }) + publish.resolve(undefined) + await vi.waitFor(() => { + const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2) + expect(eventFrames).toHaveLength(2) + expect(eventFrames[0]).toMatchObject({ + type: 'item', streamId: 'events', value: { type: 'ready' }, + }) + expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') + expect(eventFrames[1]).toEqual({ + type: 'item', streamId: 'events', value: { + type: 'emit', event: 'fixture/changed', args: ['settings'], + }, + }) + }) + expect(sourceSignal?.aborted).toBe(false) + + await unregister() + expect(sourceClosed).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(sourceSignal?.aborted).toBe(true) + expect(frames).toContainEqual({ type: 'end', streamId: 'events' }) + }) + + const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source) + await unregister() + expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + .toThrow('forwarded Remote event source is already registered') + await unregisterReplacement() + socket.close() + }) + + it('rejects a scoped dispatch yielded after its Remote event source is withdrawn', async () => { + const { ctx } = await setup(false) + const publish = Promise.withResolvers() + const agent = ctx.extend() + const pending = pendingInvocation(agent) + const source = (): AsyncIterable => (async function* () { + await publish.promise + yield pending.dispatch + })() + const unregister = ctx.typertGateway.registerRemoteEvents(source) + const rejected = expect(pending.outcome).rejects.toThrow( + 'forwarded Remote event source was removed', + ) + + publish.resolve(undefined) + await unregister() + + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + }) + + it('cancels a pending waterfall when its source rejects during removal', async () => { + const { ctx } = await setup(true) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-removal') : undefined, + resolve: id => id === 'agent-removal' ? agent : undefined, + }) + const pending = pendingInvocation(agent) + const rejected = expect(pending.outcome).rejects.toThrow( + 'forwarded Remote event source was removed', + ) + const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () { + yield pending.dispatch + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + throw new Error('fixture source rejected during removal') + })()) + const client = await openEventClient(ctx, 'events-removal') + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + + await unregister() + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ type: 'end', streamId: client.streamId }) + }) + client.socket.close() + }) + + it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { + const { ctx } = await setup(false) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + + for (const event of [42, ''] as const) { + const invalidName = pendingInvocation(ctx) + const rejected = expect(invalidName.outcome).rejects.toThrow( + 'Remote event name must be a nonempty string', + ) + source.push({ + ...invalidName.dispatch, + event: event as unknown as string, + }) + await rejected + } + + const unavailable = pendingInvocation(ctx) + source.push(unavailable.dispatch) + await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' }) + expect(unavailable.reject).not.toHaveBeenCalled() + + let selected = ctx.extend() + let identity: unknown = 1n + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === selected ? identity as AgentWireId : undefined, + resolve: () => selected, + }) + const nonJsonIdentity = pendingInvocation(selected) + const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow( + 'require a non-empty Agent identity', + ) + source.push(nonJsonIdentity.dispatch) + await nonJsonRejected + + identity = 'agent-invalid-request' + const invalidRequest = pendingInvocation(selected) + const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow( + 'must carry its scoped Agent directly', + ) + source.push({ + ...invalidRequest.dispatch, + request: {}, + }) + await invalidRequestRejected + + const staleFiber = ctx.plugin(() => {}) + await staleFiber + selected = staleFiber.ctx + identity = 'agent-stale' + await staleFiber.dispose() + const stale = pendingInvocation(selected) + source.push(stale.dispatch) + await expect(stale.outcome).resolves.toEqual({ kind: 'next' }) + expect(stale.reject).not.toHaveBeenCalled() + + selected = ctx.extend() + identity = 'agent-cancelled' + const abort = new AbortController() + abort.abort('fixture non-error cancellation') + const cancelled = pendingInvocation(selected, abort.signal) + const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({ + message: 'typert gateway: Remote event was cancelled', + cause: 'fixture non-error cancellation', + }) + source.push(cancelled.dispatch) + await cancelledOutcome + + await unregister() + }) + + it('rejects notification arguments that are not lossless JSON arrays', async () => { + const { ctx } = await setup(false) + const frames = [ + { event: 'fixture/changed', args: {} }, + { event: 'fixture/changed', args: [1n] }, + ] + for (const frame of frames) { + let sourceSignal: AbortSignal | undefined + const unregister = ctx.typertGateway.registerRemoteEvents((signal) => { + sourceSignal = signal + return (async function* () { + yield frame as unknown as TypertRemoteEventDispatch + })() + }) + await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) + const reason: unknown = sourceSignal?.reason + if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error') + expect(reason.message).toContain('arguments are not lossless JSON data') + await unregister() + } + }) + + it('retries a colliding Remote event id before publishing the second waterfall', async () => { + const { ctx } = await setup(false) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-collision') : undefined, + resolve: id => id === 'agent-collision' ? agent : undefined, + }) + const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType + const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType + randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = pendingInvocation(agent, firstAbort.signal, 'first') + const second = pendingInvocation(agent, secondAbort.signal, 'second') + + source.push(first.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) + source.push(second.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(3) }) + + const firstReason = new Error('cancel first collision fixture') + const secondReason = new Error('cancel second collision fixture') + const firstRejected = expect(first.outcome).rejects.toBe(firstReason) + const secondRejected = expect(second.outcome).rejects.toBe(secondReason) + firstAbort.abort(firstReason) + secondAbort.abort(secondReason) + await firstRejected + await secondRejected + await unregister() + }) + + it('retries a colliding Remote event Client id before opening the second generation', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType + const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType + randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) + + const first = await openEventClient(ctx, 'events-client-id-a') + const second = await openEventClient(ctx, 'events-client-id-b') + + expect(first.clientId).toBe(firstId) + expect(second.clientId).toBe(secondId) + expect(randomUuid).toHaveBeenCalledTimes(3) + first.socket.close() + second.socket.close() + await unregister() + }) + + it('fans one scoped waterfall out and accepts the first Client result', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const first = await openEventClient(ctx, 'events-a') + const second = await openEventClient(ctx, 'events-b') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + + await vi.waitFor(() => { + expect(deliveredInvocation(first)).toBeDefined() + expect(deliveredInvocation(second)).toBeDefined() + }) + const firstFrame = deliveredInvocation(first)! + const secondFrame = deliveredInvocation(second)! + expect(firstFrame.eventId).toBe(secondFrame.eventId) + expect(firstFrame).toMatchObject({ + type: 'waterfall', + event: 'fixture/approval', + agentId: 'agent-1', + request: { prompt: 'ship' }, + }) + expect(firstFrame).not.toHaveProperty('deliveryId') + expect(secondFrame).not.toHaveProperty('deliveryId') + + await sendEventResult(second, secondFrame, { + kind: 'result', value: 'allowed', + }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + await vi.waitFor(() => { + expect(first.frames).toContainEqual({ + type: 'item', + streamId: first.streamId, + value: { type: 'cancel', eventId: firstFrame.eventId }, + }) + }) + + await sendEventResult(first, firstFrame, { + kind: 'result', value: 'rejected', + }) + expect(pending.resolve).toHaveBeenCalledTimes(1) + expect(pending.reject).not.toHaveBeenCalled() + first.socket.close() + second.socket.close() + await unregister() + }) + + it('rejects the Host waterfall with the first Client listener rejection', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined, + resolve: id => id === 'agent-rejected' ? agent : undefined, + }) + const client = await openEventClient(ctx, 'events-rejected') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const frame = deliveredInvocation(client)! + const rejected = expect(pending.outcome).rejects.toMatchObject({ + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }) + + await sendEventResult(client, frame, { + kind: 'rejected', + error: { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }, + }) + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + + client.socket.close() + await unregister() + }) + + it('delegates to the Host only after every active Client returns next', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const first = await openEventClient(ctx, 'events-next-a') + const second = await openEventClient(ctx, 'events-next-b') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { + expect(deliveredInvocation(first)).toBeDefined() + expect(deliveredInvocation(second)).toBeDefined() + }) + const firstFrame = deliveredInvocation(first)! + const secondFrame = deliveredInvocation(second)! + + await sendEventResult(first, firstFrame, { kind: 'next' }) + expect(pending.resolve).not.toHaveBeenCalled() + await sendEventResult(second, secondFrame, { kind: 'next' }) + await expect(pending.outcome).resolves.toEqual({ kind: 'next' }) + expect(pending.resolve).toHaveBeenCalledTimes(1) + expect(pending.reject).not.toHaveBeenCalled() + first.socket.close() + second.socket.close() + await unregister() + }) + + it('replays a pending event id to a replacement Client generation', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const original = await openEventClient(ctx, 'events-original') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(original)).toBeDefined() }) + const originalFrame = deliveredInvocation(original)! + const closed = once(original.socket, 'close') + original.socket.close() + await closed + + const replacement = await openEventClient(ctx, 'events-replacement') + await vi.waitFor(() => { expect(deliveredInvocation(replacement)).toBeDefined() }) + const replayed = deliveredInvocation(replacement)! + expect(replayed.eventId).toBe(originalFrame.eventId) + expect(replayed).not.toHaveProperty('deliveryId') + await sendEventResult(replacement, replayed, { + kind: 'result', value: 'allowed', + }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + + replacement.socket.close() + await unregister() + }) + + it('cancels pending deliveries when the Host signal or Context ends', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const signalAgent = ctx.extend() + const contextFiber = ctx.plugin(() => {}) + await contextFiber + const contextAgent = contextFiber.ctx + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: (candidate) => { + if (candidate === signalAgent) return agentId('agent-signal') + if (candidate === contextAgent) return agentId('agent-context') + return undefined + }, + resolve: (id) => { + if (id === 'agent-signal') return signalAgent + if (id === 'agent-context') return contextAgent + return undefined + }, + }) + const client = await openEventClient(ctx, 'events-cancel') + + const abort = new AbortController() + const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal') + source.push(signalPending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const signalFrame = deliveredInvocation(client)! + expect(signalFrame).toMatchObject({ + type: 'waterfall', + agentId: 'agent-signal', + request: { prompt: 'signal' }, + }) + const signalReason = new Error('Host caller cancelled') + const signalOutcome = expect(signalPending.outcome).rejects.toBe(signalReason) + abort.abort(signalReason) + await signalOutcome + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ + type: 'item', + streamId: client.streamId, + value: { type: 'cancel', eventId: signalFrame.eventId }, + }) + }) + + const contextPending = pendingInvocation(contextAgent, undefined, 'context') + source.push(contextPending.dispatch) + let contextFrame: RemoteEventInvocationFrame | undefined + await vi.waitFor(() => { + contextFrame = client.frames + .filter(frame => frame.type === 'item' && frame.streamId === client.streamId) + .map(frame => frame.value) + .find(value => typeof value === 'object' + && value !== null + && Reflect.get(value, 'event') === 'fixture/approval' + && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined + expect(contextFrame).toBeDefined() + }) + const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released') + await contextFiber.dispose() + await contextOutcome + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ + type: 'item', + streamId: client.streamId, + value: { type: 'cancel', eventId: contextFrame!.eventId }, + }) + }) + + client.socket.close() + await unregister() + }) + + it('validates the internal Remote event request and reports an absent source', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'missing', '$events', {}) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'missing')?.type).toBe('error') + expect(streamErrorMessage(frames, 'missing')).toContain('source is unavailable') + }) + + let sourceCalls = 0 + const unregister = ctx.typertGateway.registerRemoteEvents(() => { + sourceCalls += 1 + return (async function *(): AsyncIterable {})() + }) + const invalidPayloads: readonly unknown[] = [ + null, + [], + {}, + { other: {} }, + { args: null }, + { args: [] }, + { args: { extra: true } }, + ] + invalidPayloads.forEach((payload, index) => { + socket.send(JSON.stringify({ + type: 'open', streamId: `invalid-${String(index)}`, endpoint: '$events', payload, + })) + }) + await vi.waitFor(() => { + expect(frames.filter(frame => String(frame.streamId).startsWith('invalid-'))).toHaveLength(invalidPayloads.length) + }) + for (const [index] of invalidPayloads.entries()) { + const streamId = `invalid-${String(index)}` + expect(frames.find(frame => frame.streamId === streamId)?.type).toBe('error') + expect(streamErrorMessage(frames, streamId)).toContain('requires an empty args object') + } + expect(sourceCalls).toBe(1) + + await unregister() + socket.close() + }) + + it('applies Connection trusted-host policy before accepting the Gateway socket', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket( + `ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, + { headers: { host: 'untrusted.example' } }, + ) + socket.on('error', () => {}) + const responseEvent: unknown[] = await once(socket, 'unexpected-response') + const request = responseEvent[0] + const response = responseEvent[1] + const rejected = response as { statusCode?: number; resume(): void } + expect(rejected.statusCode).toBe(403) + rejected.resume() + ;(request as { abort(): void }).abort() + }) +}) + +async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { + const ctx = new Context() + roots.push(ctx) + if (transport) { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + } + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService) + if (transport) { + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + await ctx.plugin(FeedService) + ctx.typert.register({ + package: '@fixture/feed', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: descriptors(), + }) + const receiver = ctx.get('feed') as unknown as FeedService & { [symbols.original]?: FeedService } + return { ctx, service: receiver[symbols.original] ?? receiver } +} + +function descriptors(): InvocationDescriptor[] { + const label = { + name: 'label', + wire: 'label', + source: 'json' as const, + codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', schema: z.string() }, + } + const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({ + id: `@fixture/feed#feed/${method}`, + service: 'feed', + namespace: 'feed', + method, + mode: 'stream', + invocation: { kind: 'direct' }, + parameters, + result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema }, + }) + return [ + { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, + stream('sync', [label], z.string()), + stream('invalid', [], z.string()), + stream('nonJson', [], z.unknown()), + stream('missing', [], z.string()), + { ...stream('abortBeforeOpen', [], z.string()), cancellation: { parameter: 'signal' } }, + stream('reject', [], z.string()), + stream('rejectWithNonJsonDetails', [], z.string()), + { + id: '@fixture/feed#feed/unary', + service: 'feed', + namespace: 'feed', + method: 'unary', + invocation: { kind: 'direct' }, + parameters: [label], + result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema: z.string() }, + }, + ] +} + +interface RemoteEventTestClient { + readonly socket: WebSocket + readonly frames: Record[] + readonly streamId: string + readonly clientId: RemoteEventClientId + readonly origin: string +} + +async function openEventClient(ctx: Context, streamId: string): Promise { + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, streamId, '$events', {}) + let clientId: RemoteEventClientId | undefined + await vi.waitFor(() => { + const ready = frames.find(frame => frame.type === 'item' + && frame.streamId === streamId + && typeof frame.value === 'object' + && frame.value !== null + && Reflect.get(frame.value, 'type') === 'ready') + const candidate: unknown = ready === undefined ? undefined : Reflect.get(ready.value as object, 'clientId') + expect(typeof candidate).toBe('string') + if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId + }) + if (clientId === undefined) throw new Error('Remote event stream omitted its Client id') + return { socket, frames, streamId, clientId, origin } +} + +function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined { + for (const frame of client.frames) { + if (frame.type !== 'item' || frame.streamId !== client.streamId) continue + const value = frame.value + if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'eventId')) continue + return value as RemoteEventInvocationFrame + } + return undefined +} + +async function sendEventResult( + client: RemoteEventTestClient, + frame: RemoteEventInvocationFrame, + outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + }, +): Promise { + const rpcId = `remote-event-result-${client.streamId}` + const response = await fetch(`${client.origin}/api/$events/result`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: '$events/result', + payload: { + args: { clientId: client.clientId, eventId: frame.eventId, outcome }, + }, + }), + }) + expect(response.status).toBe(200) + const body = await response.json() as { readonly result?: { readonly ok?: boolean; readonly error?: { message?: string } } } + if (body.result?.ok !== true) { + throw new Error(body.result?.error?.message ?? 'Remote event result failed') + } +} + +function sendOpen(socket: WebSocket, streamId: string, endpoint: string, args: object): void { + socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } })) +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +function streamErrorMessage(frames: readonly Record[], streamId: string): string | undefined { + const error = frames.find(frame => frame.streamId === streamId)?.error + if (typeof error !== 'object' || error === null) return undefined + const message = Reflect.get(error, 'message') as unknown + return typeof message === 'string' ? message : undefined +} + +async function collect(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 99fe477bf2..287cff2392 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2,18 +2,38 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { + apply as applyConnection, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, RemoteResult, - TypertClientRemote, + TypertContextMap, + TypertContextWire, TypertContext, + TypertLookup, TypertRemoteScopeApi, TypertRemoteNamespace, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject } from '../src/client/index.ts' +import { apply, inject, RemoteStream } from '../src/client/index.ts' +import { + RemoteStreamCarrierError, + RemoteStreamError, + RemoteStreamMuxClient, +} from '../src/client/stream-client.ts' + +type FixtureApprovalOutcome = 'allowed' | 'unavailable' +const fixtureContextTag = Symbol('fixture-context-tag') +type AgentWireId = TypertContextWire +const agentId = (value: string): AgentWireId => value as AgentWireId + +interface FixtureAgent { + readonly agentId: string +} declare module '@deepseek-ai/cordis' { interface Events { @@ -27,6 +47,21 @@ declare module '@deepseek-ai/cordis' { * @param count - marker payload never observed. */ 'fixture/idle'(count: number): void + /** + * Test-only scoped waterfall forwarded through the existing Remote Event stream. + * @param request - JSON-safe request payload. + * @param next - delegates to the next Client listener or Host waterfall. + * @returns the claimed or delegated outcome. + */ + 'fixture/approval'( + this: Context, + request: { + readonly prompt: string + readonly agent: FixtureAgent + readonly signal?: AbortSignal + }, + next: () => Promise, + ): Promise /** * Test-only event the Host assembly does not forward. * @param flag - marker payload never delivered. @@ -36,12 +71,17 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {} + interface TypertRemoteEventSelection extends + Record<'fixture/changed' | 'fixture/idle' | 'fixture/approval', true> {} interface TypertContextMap { fixture: TypertContext } + interface TypertLookupMap { + fixture: TypertLookup + } + interface TypertRemoteMap { 'probe/create': ( agentId: string, @@ -49,6 +89,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { signal?: AbortSignal, ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> + 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable } interface TypertRemoteScopeMap { @@ -68,13 +109,19 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } type FixtureContext = Omit & { - readonly remote: TypertClientRemote & TypertRemoteScopeApi<'fixture'> + readonly remote: ClientRemote & TypertRemoteScopeApi<'fixture'> } // Compile-time contract of `$on`: the key face is the forwarding selection and // the listener signature is the owning package's own Cordis declaration. function remoteEventContracts(remote: ClientRemote): void { remote.$on('fixture/changed', (namespace) => { void namespace }) + remote.$on('fixture/approval', async function (request, next) { + expectTypeOf(this).toEqualTypeOf() + expectTypeOf(request.agent).toEqualTypeOf() + expectTypeOf(request.signal).toEqualTypeOf() + return request.prompt === '' ? next() : 'allowed' + }) // @ts-expect-error -- declared in Events but outside the forwarding selection. remote.$on('fixture/unselected', () => {}) // @ts-expect-error -- not declared in Events at all. @@ -155,22 +202,390 @@ function maybeDescriptor(): InvocationDescriptor { } } -async function bench(call: ConnectionHandle['rpc']['call']): Promise { - const { ctx } = await benchFiber(call) +function streamDescriptor(): InvocationDescriptor { + return { + id: '@fixture/probe#probe/watch', + service: 'probe', + namespace: 'probe', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'topic', + wire: 'topic', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#Topic', schema: z.string().min(1) }, + }], + cancellation: { parameter: 'signal' }, + result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', schema: z.string().min(1) }, + } +} + +type WebSocketGlobal = { WebSocket?: typeof WebSocket } + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static readonly sockets: FakeWebSocket[] = [] + static autoOpen = true + static dispatchClose = true + + readonly url: string + readonly sent: string[] = [] + readonly closedWith: { readonly code?: number; readonly reason?: string }[] = [] + readyState = FakeWebSocket.CONNECTING + + constructor(url: string | URL) { + super() + this.url = String(url) + FakeWebSocket.sockets.push(this) + queueMicrotask(() => { + if (FakeWebSocket.autoOpen) this.open() + }) + } + + open(): void { + if (this.readyState !== FakeWebSocket.CONNECTING) return + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + fail(): void { + this.dispatchEvent(new Event('error')) + } + + send(data: string): void { + if (this.readyState !== FakeWebSocket.OPEN) throw new Error('fixture socket is not open') + this.sent.push(data) + } + + close(code?: number, reason?: string): void { + this.closedWith.push({ + ...(code === undefined ? {} : { code }), + ...(reason === undefined ? {} : { reason }), + }) + if (this.readyState === FakeWebSocket.CLOSED) return + if (!FakeWebSocket.dispatchClose) { + this.readyState = FakeWebSocket.CLOSING + return + } + this.drop() + } + + drop(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + receive(value: unknown): void { + this.receiveRaw(typeof value === 'string' ? value : JSON.stringify(value)) + } + + receiveRaw(data: unknown): void { + this.dispatchEvent(new MessageEvent('message', { + data, + })) + } +} + +async function bench( + call: ConnectionHandle['rpc']['call'], + carrier: 'in-process' | 'web' = 'in-process', +): Promise { + const { ctx } = await benchFiber(call, carrier) return ctx } async function benchFiber( call: ConnectionHandle['rpc']['call'], -): Promise<{ readonly ctx: Context; readonly client: Fiber }> { + carrier: 'in-process' | 'web' = 'in-process', + open: NonNullable = () => unexpectedInProcessStream(), +): Promise<{ + readonly ctx: Context + readonly client: Fiber + readonly generation: GenerationHarness +}> { const ctx = new Context() await ctx.plugin(TypertRegistry) - ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) + const rpc = carrier === 'web' + ? { call } + : { call, open } + const generation = new GenerationHarness() + ctx.provide('connection', { + rpc, + registerGenerationSource: generation.register, + start: () => ({ stop: () => {} }), + } as unknown as ConnectionHandle) const client = ctx.plugin({ inject, apply }) await client - return { ctx, client } + return { ctx, client, generation } } +async function *unexpectedInProcessStream(): AsyncGenerator { + throw new Error('fixture did not install an in-process stream') +} + +interface GenerationRun { + readonly signal: AbortSignal + readonly ready: Promise + readonly done: Promise + abort(reason?: unknown): void +} + +class GenerationHarness { + private source: ConnectionGenerationSource | undefined + private active: AbortController | undefined + + readonly register = (source: ConnectionGenerationSource): (() => void) => { + if (this.source !== undefined) throw new Error('fixture generation source already registered') + this.source = source + return () => { + if (this.source !== source) return + this.source = undefined + this.active?.abort(new Error('fixture generation source removed')) + this.active = undefined + } + } + + start(): GenerationRun { + if (this.source === undefined) throw new Error('fixture generation source is not registered') + if (this.active !== undefined) throw new Error('fixture generation is already active') + const source = this.source + const controller = new AbortController() + this.active = controller + let reportReady!: () => void + const ready = new Promise((resolve) => { reportReady = resolve }) + const done = Promise.resolve() + .then(() => source(controller.signal, reportReady)) + .finally(() => { + if (this.active === controller) this.active = undefined + }) + void done.catch(() => undefined) + return { + signal: controller.signal, + ready, + done, + abort: (reason) => { controller.abort(reason) }, + } + } + + startOverlapping(): GenerationRun { + if (this.source === undefined) throw new Error('fixture generation source is not registered') + const controller = new AbortController() + let reportReady!: () => void + const ready = new Promise((resolve) => { reportReady = resolve }) + const done = Promise.resolve().then(() => this.source?.(controller.signal, reportReady)) + .then(() => undefined) + void done.catch(() => undefined) + return { + signal: controller.signal, + ready, + done, + abort: (reason) => { controller.abort(reason) }, + } + } +} + +function deferredReadiness(): { + readonly promise: Promise + readonly resolve: () => void + readonly reject: (error: unknown) => void +} { + let resolve!: () => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +async function loaderReadinessBench(readiness: Promise): Promise<{ + readonly client: Fiber + readonly start: ReturnType> + readonly stop: ReturnType void>> +}> { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const generation = new GenerationHarness() + const stop = vi.fn<() => void>() + const start = vi.fn(() => ({ stop })) + ctx.provide('connection', { + rpc: { + call: vi.fn(), + open: () => unexpectedInProcessStream(), + }, + registerGenerationSource: generation.register, + start, + } as unknown as ConnectionHandle) + ctx.provide('loader', { await: () => readiness }) + const client = ctx.plugin({ inject, apply }) + await client + return { client, start, stop } +} + +type EventStreamItem = + | { readonly kind: 'frame'; readonly value: unknown } + | { readonly kind: 'end' } + | { readonly kind: 'fail'; readonly error: unknown } + +interface EventStreamConnection { + readonly items: EventStreamItem[] + wake: (() => void) | undefined +} + +class RemoteEventCarrier { + readonly calls: { + readonly channel: string + readonly endpoint: string + readonly payload: unknown + readonly signal: AbortSignal + }[] = [] + private readonly connections = new Set() + private nextClient = 1 + + get activeConnections(): number { + return this.connections.size + } + + readonly open: NonNullable = (channel, endpoint, payload, signal) => { + this.calls.push({ channel, endpoint, payload, signal }) + return this.iterate(signal) + } + + emit(value: unknown): void { + this.feed({ kind: 'frame', value }) + } + + end(): void { + this.feed({ kind: 'end' }) + } + + fail(error: unknown): void { + this.feed({ kind: 'fail', error }) + } + + private feed(item: EventStreamItem): void { + for (const connection of this.connections) { + connection.items.push(item) + connection.wake?.() + } + } + + private async *iterate(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const clientId = `event-client-${String(this.nextClient++)}` + const connection: EventStreamConnection = { items: [], wake: undefined } + this.connections.add(connection) + const abort = (): void => { connection.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + yield { type: 'ready', clientId } + while (!signal.aborted) { + while (connection.items.length > 0) { + const item = connection.items.shift() as EventStreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + yield item.value + } + if (signal.aborted) return + await new Promise((resolve) => { connection.wake = resolve }) + connection.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + this.connections.delete(connection) + } + } +} + +async function eventBench( + call: ConnectionHandle['rpc']['call'] = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }), +): Promise<{ + readonly ctx: Context + readonly client: Fiber + readonly carrier: RemoteEventCarrier + readonly generation: GenerationHarness + readonly run: GenerationRun + readonly call: ConnectionHandle['rpc']['call'] +}> { + const carrier = new RemoteEventCarrier() + const { ctx, client, generation } = await benchFiber( + call, + 'in-process', + carrier.open, + ) + const run = generation.start() + await run.ready + return { ctx, client, carrier, generation, run, call } +} + +function approvalFrame(eventId: string, agentId: string, prompt: string): object { + return { + type: 'waterfall', + event: 'fixture/approval', + eventId, + agentId, + request: { prompt }, + } +} + +describe('Client Remote transport readiness', () => { + it('creates logical stream supervisors against the installed Connection', async () => { + const { ctx, client } = await benchFiber(vi.fn()) + const stream = ctx.remote.$stream({ + name: 'fixture stream', + open: () => unexpectedInProcessStream(), + ended: () => new Error('fixture stream ended'), + }) + + expect(stream).toBeInstanceOf(RemoteStream) + await stream.dispose() + await client.dispose() + }) + + it('starts after Loader settlement and stops the owned loop on disposal', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + expect(start).not.toHaveBeenCalled() + + readiness.resolve() + await vi.waitFor(() => { expect(start).toHaveBeenCalledTimes(1) }) + + await client.dispose() + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('does not start when disposal wins the Loader-settlement race', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + + await client.dispose() + readiness.resolve() + await Promise.resolve() + + expect(start).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + }) + + it('leaves the transport stopped when Loader settlement rejects', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + + readiness.reject(new Error('fixture Loader failed')) + await Promise.resolve() + await Promise.resolve() + + expect(start).not.toHaveBeenCalled() + await client.dispose() + expect(stop).not.toHaveBeenCalled() + }) +}) + describe('Client Typert API', () => { it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => { const call = vi.fn() @@ -271,6 +686,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), @@ -301,6 +717,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }), @@ -346,6 +763,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-remounted' ? agentCtx : undefined, }) const direct = directDescriptor() const context = contextDescriptor() @@ -432,6 +850,42 @@ describe('Client Typert API', () => { await retry() }) + it('rolls back earlier namespaces when a later namespace fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/archive#archive/store', + namespace: 'archive', + method: 'store', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'store') throw new Error('fixture later-namespace failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/failing-namespaces', + descriptors: [first, second], + })).rejects.toThrow('fixture later-namespace failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect((ctx.remote as unknown as Record).archive).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + + const retry = await ctx.remote.$mount({ + package: '@fixture/retry-namespaces', + descriptors: [first, second], + }) + expect(ctx.remote.probe.create).toBeTypeOf('function') + expect((ctx.remote as unknown as Record>).archive?.store).toBeTypeOf('function') + await retry() + }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { const ctx = await bench(vi.fn()) const disposeContext = await ctx.remote.$mount({ @@ -579,7 +1033,7 @@ describe('Client Typert API', () => { })).rejects.toThrow('scope must select its only lookup parameter') }) - it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) @@ -599,7 +1053,7 @@ describe('Client Typert API', () => { await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' })) .rejects.toThrow('expected 2 business argument(s)') await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' })) - .rejects.toThrow('no Client Context binder') + .rejects.toThrow('no Client Context adapter') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') @@ -640,6 +1094,28 @@ describe('Client Typert API', () => { expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) + it('keeps a namespace while another contribution still owns a method', async () => { + const ctx = await bench(vi.fn()) + const disposeCreate = await ctx.remote.$mount({ + package: '@fixture/create-contribution', + descriptors: [directDescriptor()], + }) + const disposeMaybe = await ctx.remote.$mount({ + package: '@fixture/maybe-contribution', + descriptors: [maybeDescriptor()], + }) + const namespace = ctx.get('remote.probe') as unknown as Record + + await disposeCreate() + + expect(ctx.get('remote.probe') !== undefined).toBe(true) + expect(namespace.create).toBeUndefined() + expect(namespace.maybe).toBeTypeOf('function') + + await disposeMaybe() + expect(ctx.get('remote.probe')).toBeUndefined() + }) + it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) @@ -804,7 +1280,7 @@ describe('Client Typert API', () => { }) it('owns each $on subscription in the calling fiber', async () => { - const { ctx, client } = await benchFiber(vi.fn()) + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] const subscriber = ctx.plugin(Object.assign( (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) }, @@ -812,103 +1288,1098 @@ describe('Client Typert API', () => { )) await subscriber - ctx.remote.$dispatch('fixture/changed', ['settings']) - expect(seen).toEqual(['settings']) + expect(carrier.calls).toEqual([expect.objectContaining({ + channel: '/api', endpoint: '$events', payload: { args: {} }, + })]) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['settings'] }) + await vi.waitFor(() => { expect(seen).toEqual(['settings']) }) await subscriber.dispose() - ctx.remote.$dispatch('fixture/changed', ['after fiber disposal']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['after fiber disposal'] }) + await Promise.resolve() expect(seen).toEqual(['settings']) await client.dispose() expect(ctx.get('remote')).toBeUndefined() }) - it('isolates a throwing listener from the rest of the same event', async () => { - const ctx = await bench(vi.fn()) + it('isolates throwing and rejected notification listeners', async () => { + const { ctx, client, carrier } = await eventBench() const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const seen: string[] = [] - const disposeFirst = ctx.remote.$on('fixture/changed', () => { - throw new Error('fixture listener failure') - }) - ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - try { - ctx.remote.$dispatch('fixture/changed', ['credentials']) - - expect(seen).toEqual(['credentials']) - expect(consoleError).toHaveBeenCalledWith( - 'client api: Remote event "fixture/changed" listener threw:', - expect.any(Error), - ) - disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['commands']) - expect(seen).toEqual(['credentials', 'commands']) - expect(consoleError).toHaveBeenCalledTimes(1) - } finally { - consoleError.mockRestore() + const failingListener = (namespace: string): unknown => { + if (namespace === 'sync') throw new Error('fixture listener failure') + return Promise.reject(new Error('fixture async failure')) } - }) - - it('contains an async listener whose promise rejects', async () => { - const ctx = await bench(vi.fn()) - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const seen: string[] = [] - // The declared return is void, so nobody awaits an async listener: the - // rejection has to be contained here or it escapes as an unhandled one. - ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises + const disposeThrowing = ctx.remote.$on('fixture/changed', failingListener) ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) try { - ctx.remote.$dispatch('fixture/changed', ['credentials']) - await Promise.resolve() - await Promise.resolve() + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['sync'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync']) }) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['async'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async']) }) + expect(consoleError).toHaveBeenCalledTimes(2) - expect(seen).toEqual(['credentials']) - expect(consoleError).toHaveBeenCalledWith( - 'client api: Remote event "fixture/changed" listener threw:', - expect.any(Error), - ) + disposeThrowing() + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async', 'survivor']) }) } finally { consoleError.mockRestore() + await client.dispose() } }) it('retires only its own registration when one listener subscribes twice', async () => { - const ctx = await bench(vi.fn()) + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] - // One function object, two registrations. A table keyed by listener identity - // stores it once, so the first frame would reach it once instead of twice - // and either disposer would silence both. const listener = (namespace: string): void => { seen.push(namespace) } const disposeFirst = ctx.remote.$on('fixture/changed', listener) ctx.remote.$on('fixture/changed', listener) - ctx.remote.$dispatch('fixture/changed', ['both']) - expect(seen).toEqual(['both', 'both']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['both'] }) + await vi.waitFor(() => { expect(seen).toEqual(['both', 'both']) }) - // The surviving registration keeps receiving after its twin retires. disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['survivor']) - expect(seen).toEqual(['both', 'both', 'survivor']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] }) + await vi.waitFor(() => { expect(seen).toEqual(['both', 'both', 'survivor']) }) - // Disposing twice is inert: the record is already gone, so the second call - // must not splice the surviving twin out from under its own owner. disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['still here']) - expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['still here'] }) + await vi.waitFor(() => { + expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + }) + await client.dispose() }) - it('separates the consumer verb from the carrier handoff', () => { + it('keeps the carrier handoff private', () => { expectTypeOf().toHaveProperty('$on') - // The carrier owning the frame sink calls this; a consumer subscribes instead. - expectTypeOf().toHaveProperty('$dispatch') + expectTypeOf<'$dispatch' extends keyof ClientRemote ? true : false>().toEqualTypeOf() }) - it('drops a forwarded event nobody subscribes to', async () => { - const ctx = await bench(vi.fn()) + it('drops an unobserved notification and accepts a null-prototype frame', async () => { + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - ctx.remote.$dispatch('fixture/idle', [1]) + carrier.emit({ type: 'emit', event: 'fixture/idle', args: [1] }) + carrier.emit(Object.assign(Object.create(null) as Record, { + type: 'emit', + event: 'fixture/changed', + args: ['null prototype'], + })) + await vi.waitFor(() => { expect(seen).toEqual(['null prototype']) }) + await client.dispose() + }) + + it('delegates immediately when the Agent adapter or Context is unavailable', async () => { + const { ctx, client, carrier, call } = await eventBench() + carrier.emit(approvalFrame('event-no-adapter', 'agent-late', 'no adapter')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + + const target = ctx.extend() + const resolve = vi.fn((id: unknown) => id === 'agent-found' ? target : undefined) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-found') : undefined, + resolve, + }) + carrier.emit(approvalFrame('event-missing-context', 'agent-missing', 'missing')) + carrier.emit(approvalFrame('event-no-listener', 'agent-found', 'delegate')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(3) }) + expect(resolve).toHaveBeenCalledTimes(2) + for (const eventId of ['event-no-adapter', 'event-missing-context', 'event-no-listener']) { + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { args: { clientId: 'event-client-1', eventId, outcome: { kind: 'next' } } }, + expect.any(AbortSignal), + ) + } + await client.dispose() + }) + + it('reports Agent Context resolution failures and delegates', async () => { + const { ctx, client, carrier, call } = await eventBench() + ctx.typert.contexts.registerClient('agent', { + identity: () => undefined, + resolve: () => { throw new Error('fixture Context lookup failed') }, + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + carrier.emit(approvalFrame('event-resolve-error', 'agent-error', 'resolve')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(consoleError).toHaveBeenCalledWith( + 'client api: Remote event "fixture/approval" listener threw:', + expect.objectContaining({ message: 'fixture Context lookup failed' }), + ) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-resolve-error', + outcome: { kind: 'next' }, + }, + }, + expect.any(AbortSignal), + ) + } finally { + consoleError.mockRestore() + await client.dispose() + } + }) + + it('normalizes undefined waterfall results and rejects non-JSON results', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-results') : undefined, + resolve: id => id === 'agent-results' ? target : undefined, + }) + target.remote.$on('fixture/approval', async request => request.prompt === 'undefined' + ? undefined as unknown as FixtureApprovalOutcome + : Symbol('not JSON') as unknown as FixtureApprovalOutcome) + + carrier.emit(approvalFrame('event-undefined', 'agent-results', 'undefined')) + carrier.emit(approvalFrame('event-invalid', 'agent-results', 'invalid')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(2) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { args: { clientId: 'event-client-1', eventId: 'event-undefined', outcome: { kind: 'result' } } }, + expect.any(AbortSignal), + ) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-invalid', + outcome: { + kind: 'rejected', + error: { + name: 'TypeError', + message: 'Remote event listener result is not lossless JSON data', + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('fails the Connection generation when a result RPC is rejected', async () => { + const call = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'internal', message: 'fixture result rejected', details: {} }, + }) + const { client, carrier, run } = await eventBench(call) + + carrier.emit(approvalFrame('event-result-rejected', 'agent-missing', 'respond')) + + await expect(run.done).rejects.toThrow('fixture result rejected') + await client.dispose() + }) + + it('filters scoped waterfall listeners and returns the first claimed result', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend({ + [Context.filter](candidate: Context): boolean { + const tag = (candidate as Context & { [fixtureContextTag]?: string })[fixtureContextTag] + return tag === undefined || tag === 'agent-1' + }, + }) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? target : undefined, + }) + const matching = ctx.extend({ [fixtureContextTag]: 'agent-1' }) + const excluded = ctx.extend({ [fixtureContextTag]: 'agent-2' }) + const seen: string[] = [] + ctx.remote.$on('fixture/approval', async function (request, next) { + expect(this).toBe(target) + expect(request.agent).toBe(target) + expect(request.signal).toBeInstanceOf(AbortSignal) + seen.push('root') + return next() + }) + matching.remote.$on('fixture/approval', async (_request, next) => { + seen.push('matching-next') + return next() + }) + excluded.remote.$on('fixture/approval', async () => { + seen.push('excluded') + return 'unavailable' + }) + matching.remote.$on('fixture/approval', async () => { + seen.push('matching-result') + return 'allowed' + }) + + carrier.emit(approvalFrame('event-1', 'agent-1', 'ship')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(seen).toEqual(['root', 'matching-next', 'matching-result']) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-1', + outcome: { kind: 'result', value: 'allowed' }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('returns a scoped listener rejection to the Host', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-rejected') : undefined, + resolve: id => id === 'agent-rejected' ? target : undefined, + }) + const rejection = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }) + target.remote.$on('fixture/approval', () => Promise.reject(rejection)) + + carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-rejected', + outcome: { + kind: 'rejected', + error: { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('returns Context-filter failures as rejections', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend({ + [Context.filter](): boolean { + throw new Error('fixture Context filter failed') + }, + }) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-filter-failure') : undefined, + resolve: id => id === 'agent-filter-failure' ? target : undefined, + }) + ctx.remote.$on('fixture/approval', async (_request, next) => next()) + + carrier.emit(approvalFrame('event-filter-failure', 'agent-filter-failure', 'filter')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-filter-failure', + outcome: { + kind: 'rejected', + error: { + name: 'Error', + message: 'fixture Context filter failed', + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('cancels a pending Client listener without returning a late result', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-cancel') : undefined, + resolve: id => id === 'agent-cancel' ? target : undefined, + }) + const entered = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + const signal = request.signal as AbortSignal + entered.resolve(signal) + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return 'allowed' + }) + carrier.emit(approvalFrame('event-cancel', 'agent-cancel', 'wait')) + const deliverySignal = await entered.promise + + carrier.emit({ type: 'cancel', eventId: 'event-cancel' }) + await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) }) + await Promise.resolve() + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('drops a settled listener result when cancellation wins before reply', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-cancel-race') : undefined, + resolve: id => id === 'agent-cancel-race' ? target : undefined, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + entered.resolve(request.signal as AbortSignal) + await release.promise + return 'allowed' + }) + carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait')) + const deliverySignal = await entered.promise + + release.resolve(undefined) + carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' }) + await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) }) + await Promise.resolve() + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('cancels pending listener work when the generation ends', async () => { + const { ctx, client, carrier, run, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-generation') : undefined, + resolve: id => id === 'agent-generation' ? target : undefined, + }) + const entered = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + const signal = request.signal as AbortSignal + entered.resolve(signal) + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return 'allowed' + }) + carrier.emit(approvalFrame('event-generation', 'agent-generation', 'wait')) + const deliverySignal = await entered.promise + + run.abort(new Error('fixture generation ended')) + await expect(run.done).resolves.toBeUndefined() + expect(deliverySignal.aborted).toBe(true) + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('contains a result transport failure after the generation is cancelled', async () => { + const response = Promise.withResolvers() + const call = vi.fn(() => response.promise) + const { client, carrier, run } = await eventBench(call) + carrier.emit(approvalFrame('event-late-result', 'agent-missing', 'respond')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledOnce() }) + + run.abort(new Error('fixture generation cancelled')) + response.reject(new Error('fixture late result failure')) + await expect(run.done).resolves.toBeUndefined() + + await client.dispose() + }) + + it('normalizes a non-Error result transport failure', async () => { + const call = vi.fn().mockRejectedValue('fixture transport failure') + const { client, carrier, run } = await eventBench(call) + + carrier.emit(approvalFrame('event-result-throw', 'agent-missing', 'respond')) + + await expect(run.done).rejects.toMatchObject({ + message: 'client api: Remote event result delivery failed', + cause: 'fixture transport failure', + }) + await client.dispose() + }) + + it('keeps the newer generation tracked when an overlapping generation settles', async () => { + const { client, generation, run } = await eventBench() + const overlapping = generation.startOverlapping() + await overlapping.ready + + run.abort(new Error('fixture older generation ended')) + await expect(run.done).resolves.toBeUndefined() + overlapping.abort(new Error('fixture newer generation ended')) + await expect(overlapping.done).resolves.toBeUndefined() + await client.dispose() + }) + + it('opens the forwarded-event stream on the browser Remote mux', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, generation } = await benchFiber(call, 'web') + const seen: string[] = [] + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-browser') : undefined, + resolve: id => id === 'agent-browser' ? target : undefined, + }) + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + target.remote.$on('fixture/approval', async function (request) { + expect(this).toBe(target) + expect(request.agent).toBe(this) + expect(request.signal).toBeInstanceOf(AbortSignal) + return 'allowed' + }) + const run = generation.start() + + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + expect(opened).toMatchObject({ + type: 'open', endpoint: '$events', payload: { args: {} }, + }) + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { type: 'ready', clientId: 'browser-client' }, + }) + await run.ready + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { type: 'emit', event: 'fixture/changed', args: ['browser'] }, + }) + await vi.waitFor(() => { expect(seen).toEqual(['browser']) }) + + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { + type: 'waterfall', + event: 'fixture/approval', + eventId: 'event-browser', + agentId: 'agent-browser', + request: { prompt: 'browser approval' }, + }, + }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(socket.sent).toHaveLength(1) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'browser-client', + eventId: 'event-browser', + outcome: { kind: 'result', value: 'allowed' }, + }, + }, + expect.any(AbortSignal), + ) + + await client.dispose() + }) + }) + + it('publishes the Fixture Host description after Remote events report ready', async () => { + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { hostname: '127.0.0.1', search: '?fixture' }, + }) + const ctx = new Context() + try { + await ctx.plugin(TypertRegistry) + await ctx.plugin({ inject: [], apply: applyConnection }) + await ctx.plugin({ inject, apply }) + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('fixture Connection service is unavailable') + + await vi.waitFor(() => { + expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') + }) + } finally { + await ctx.fiber.dispose() + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } + }) + + it.each([ + null, + [], + {}, + { type: 'pending' }, + { type: 'ready' }, + { type: 'ready', clientId: '' }, + { type: 'ready', clientId: 'client', extra: true }, + { type: 'emit', event: 'fixture/changed', args: ['too early'] }, + ])('rejects malformed forwarded-event readiness item %#', async (opening) => { + const open: NonNullable = () => (async function *() { + yield opening + })() + const { client, generation } = await benchFiber( + vi.fn(), + 'in-process', + open, + ) + const run = generation.start() + try { + await expect(run.done).rejects.toThrow('forwarded Remote event stream did not begin with ready') + } finally { + await client.dispose() + } + }) + + it('propagates physical carrier failure and opens events for the replacement generation', async () => { + const { ctx, client, carrier, generation, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + expect(carrier.calls).toHaveLength(1) + + carrier.fail(new RemoteStreamCarrierError('fixture generation lost')) + await expect(run.done).rejects.toThrow('fixture generation lost') + const replacement = generation.start() + await replacement.ready + await vi.waitFor(() => { expect(carrier.calls).toHaveLength(2) }) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['replacement'] }) + await vi.waitFor(() => { expect(seen).toEqual(['replacement']) }) + + await client.dispose() + }) + + it.each([ + { + name: 'Host failure', + stop: (carrier: RemoteEventCarrier) => { + carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {})) + }, + message: 'fixture Host failed', + }, + { + name: 'normal end', + stop: (carrier: RemoteEventCarrier) => { carrier.end() }, + message: 'forwarded Remote event stream ended unexpectedly', + }, + ])('fails the active generation after $name', async ({ stop, message }) => { + const { ctx, client, carrier, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + stop(carrier) + await expect(run.done).rejects.toThrow(message) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] }) + await Promise.resolve() + expect(carrier.calls).toHaveLength(1) expect(seen).toEqual([]) + await client.dispose() + }) + + it.each([ + 'not an object', + null, + [], + {}, + { type: 'unknown' }, + { type: 'emit', event: 'fixture/changed' }, + { type: 'emit', event: 'fixture/changed', args: [], extra: true }, + { type: 'emit', event: 1, args: [] }, + { type: 'emit', event: '', args: [] }, + { type: 'emit', event: 'fixture/changed', args: {} }, + { type: 'emit', event: 'fixture/changed', args: [1n] }, + { type: 'waterfall', event: 'fixture/approval', eventId: '', agentId: 'agent-1', request: {} }, + { type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: '', request: {} }, + { + type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { agent: null }, + }, + { + type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { signal: null }, + }, + { type: 'cancel', eventId: '' }, + { type: 'cancel', eventId: 'event-1', extra: true }, + ])('rejects malformed forwarded-event frame %# and stops that stream', async (frame) => { + const { ctx, client, carrier, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + carrier.emit(frame) + await expect(run.done).rejects.toThrow('client api: invalid forwarded Remote event frame') + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] }) + await Promise.resolve() + expect(carrier.calls).toHaveLength(1) + expect(seen).toEqual([]) + await client.dispose() + }) + + it('aborts and awaits forwarded-event delivery during disposal', async () => { + const { ctx, client, carrier, run } = await eventBench() + ctx.remote.$on('fixture/changed', () => {}) + expect(carrier.activeConnections).toBe(1) + const signal = carrier.calls[0]?.signal + + await client.dispose() + await expect(run.done).resolves.toBeUndefined() + + expect(signal?.aborted).toBe(true) + expect(carrier.activeConnections).toBe(0) + expect(ctx.get('remote')).toBeUndefined() + }) + + it('rejects a generation when its Connection has been withdrawn', async () => { + const carrier = new RemoteEventCarrier() + const { ctx, client, generation } = await benchFiber( + vi.fn(), + 'in-process', + carrier.open, + ) + ctx.set('connection', undefined) + const run = generation.start() + await expect(run.done).rejects.toThrow('$events has no active Connection') + expect(carrier.calls).toEqual([]) + await client.dispose() + }) + + it('guards stream iteration across mount and Connection withdrawal', async () => { + const call = vi.fn() + const ctx = await bench(call) + const firstDispose = await ctx.remote.$mount({ + package: '@fixture/stream-first', descriptors: [streamDescriptor()], + }) + const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]() + await firstDispose() + await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted') + + const secondDispose = await ctx.remote.$mount({ + package: '@fixture/stream-second', descriptors: [streamDescriptor()], + }) + ctx.set('connection', undefined) + await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next()) + .rejects.toThrow('probe/watch has no active Connection') + + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + const source = async function *(): AsyncIterable { + markStarted() + await released + yield 'late item' + } + ctx.set('connection', { + rpc: { call, open: () => source() }, + } as unknown as ConnectionHandle) + const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]() + const pending = active.next() + await started + await secondDispose() + release() + await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted') + }) + + it('publishes a namespace only after every contributed method is installed', async () => { + const ctx = await bench(vi.fn()) + let visible: string[] | undefined + const consumer = ctx.plugin({ + inject: ['remote.probe'], + apply(scope) { + const namespace = scope.get('remote.probe') as unknown as Record + visible = [typeof namespace.watch, typeof namespace.archive] + }, + }) + const archive: InvocationDescriptor = { + ...streamDescriptor(), + id: '@fixture/probe#probe/archive', + method: 'archive', + } + + const dispose = await ctx.remote.$mount({ + package: '@fixture/atomic-namespace', + descriptors: [streamDescriptor(), archive], + }) + await consumer.await() + + expect(visible).toEqual(['function', 'function']) + await dispose() + }) + + it('normalizes worker-local structural stream failures without sharing class identity', async () => { + const cases = [{ + failure: Object.assign(new Error('fixture Host rejected the stream'), { + dshRemoteStreamFailure: { + kind: 'remote' as const, + code: 'fixture-rejected', + details: { retry: false }, + }, + }), + assert: (error: unknown) => { + expect(error).toBeInstanceOf(RemoteStreamError) + expect(error).toMatchObject({ + code: 'fixture-rejected', + message: 'fixture Host rejected the stream', + details: { retry: false }, + }) + }, + }, { + failure: Object.assign(new Error('worker carrier stopped'), { + dshRemoteStreamFailure: { kind: 'carrier' as const }, + }), + assert: (error: unknown) => { + expect(error).toBeInstanceOf(RemoteStreamCarrierError) + expect(error).toMatchObject({ message: 'worker carrier stopped' }) + }, + }, { + failure: 'caller abort sentinel', + assert: (error: unknown) => { expect(error).toBe('caller abort sentinel') }, + }] + + for (const testCase of cases) { + const open: NonNullable = () => (async function *(): AsyncGenerator { + throw testCase.failure + })() + const { ctx, client } = await benchFiber( + vi.fn(), + 'in-process', + open, + ) + const dispose = await ctx.remote.$mount({ package: '@fixture/worker-stream', descriptors: [streamDescriptor()] }) + try { + const error = await ctx.remote.probe.watch('failure')[Symbol.asyncIterator]().next() + .then(() => undefined, (reason: unknown) => reason) + testCase.assert(error) + } finally { + await dispose() + await client.dispose() + } + } + }) + + it('multiplexes Remote streams without using the Connection RPC caller', async () => { + const originalWebSocket = globalThis.WebSocket + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin: 'https://harness.example' }, + }) + FakeWebSocket.sockets.length = 0 + const call = vi.fn() + const ctx = await bench(call, 'web') + expect(FakeWebSocket.sockets).toHaveLength(1) + const dispose = await ctx.remote.$mount({ package: '@fixture/stream', descriptors: [streamDescriptor()] }) + try { + const first = ctx.remote.probe.watch('alpha')[Symbol.asyncIterator]() + const firstItem = first.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('wss://harness.example/api/remote.mux') + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + expect(opened).toMatchObject({ + type: 'open', + endpoint: 'probe/watch', + payload: { args: { topic: 'alpha' } }, + }) + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:one' }) + await expect(firstItem).resolves.toEqual({ done: false, value: 'alpha:one' }) + const firstEnd = first.next() + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(firstEnd).resolves.toEqual({ done: true, value: undefined }) + + const failed = ctx.remote.probe.watch('failure')[Symbol.asyncIterator]() + const failedItem = failed.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const failedOpen = JSON.parse(socket.sent[1]!) as { streamId: string } + socket.receive({ + type: 'error', + streamId: failedOpen.streamId, + error: { + code: 'lookup-unavailable', + message: 'fixture stream failed', + details: { lookup: 'missing' }, + }, + }) + await expect(failedItem).rejects.toMatchObject({ + name: 'RemoteStreamError', + code: 'lookup-unavailable', + message: 'fixture stream failed', + details: { lookup: 'missing' }, + }) + + const abort = new AbortController() + const cancelled = ctx.remote.probe.watch('cancel', abort.signal)[Symbol.asyncIterator]() + const cancelledItem = cancelled.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + const cancelledOpen = JSON.parse(socket.sent[2]!) as { streamId: string } + const cancellation = new Error('caller cancelled') + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'already queued' }) + abort.abort(cancellation) + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'after cancellation' }) + await expect(cancelledItem).rejects.toBe(cancellation) + await vi.waitFor(() => { + expect(socket.sent.map(text => JSON.parse(text) as unknown)).toContainEqual({ + type: 'cancel', streamId: cancelledOpen.streamId, + }) + }) + expect(call).not.toHaveBeenCalled() + } finally { + await dispose() + await ctx.fiber.dispose() + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } }) }) + +describe('Remote stream client carrier lifecycle', () => { + it('connects without a logical stream, reconnects after failures, and stops permanently', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const client = new RemoteStreamMuxClient() + client.start() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(1) + + const failed = FakeWebSocket.sockets[0]! + failed.fail() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(2) + + const connected = FakeWebSocket.sockets[1]! + connected.open() + await vi.advanceTimersByTimeAsync(0) + expect(connected.sent).toEqual([]) + connected.fail() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(3) + + const replacement = FakeWebSocket.sockets[2]! + replacement.open() + replacement.drop() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(4) + + const final = FakeWebSocket.sockets[3]! + final.open() + await vi.advanceTimersByTimeAsync(0) + await client.close() + await client.close() + client.start() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') + await vi.advanceTimersByTimeAsync(20_000) + + expect(FakeWebSocket.sockets).toHaveLength(4) + expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) + expect(warn).toHaveBeenCalledTimes(3) + + const stopping = new RemoteStreamMuxClient() + stopping.start() + const racing = FakeWebSocket.sockets[4]! + racing.open() + racing.drop() + await stopping.close() + await vi.advanceTimersByTimeAsync(20_000) + expect(FakeWebSocket.sockets).toHaveLength(5) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('shares an in-flight connection and uses the internal ws URL without a browser origin', async () => { + await withFakeWebSocket(undefined, async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) + [Symbol.asyncIterator]() + const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal) + [Symbol.asyncIterator]() + const firstPending = first.next() + const secondPending = second.next() + expect(FakeWebSocket.sockets).toHaveLength(1) + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('ws://dsh.internal/api/remote.mux') + + socket.open() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const streamIds = socket.sent.map(text => (JSON.parse(text) as { streamId: string }).streamId) + socket.receive({ type: 'end', streamId: streamIds[0] }) + socket.receive({ type: 'end', streamId: streamIds[1] }) + await expect(firstPending).resolves.toEqual({ done: true, value: undefined }) + await expect(secondPending).resolves.toEqual({ done: true, value: undefined }) + await client.close() + }) + }) + + it('keeps waiters across failed attempts and contains waiter cancellation', async () => { + await withFakeWebSocket('null', async () => { + FakeWebSocket.autoOpen = false + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const closedClient = new RemoteStreamMuxClient() + const closed = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[0]!.drop() + await vi.advanceTimersByTimeAsync(500) + + const replacement = FakeWebSocket.sockets[1]! + replacement.open() + await vi.advanceTimersByTimeAsync(0) + const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } + replacement.receive({ type: 'end', streamId }) + await expect(closed).resolves.toEqual({ done: true, value: undefined }) + await closedClient.close() + + const disposedClient = new RemoteStreamMuxClient() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[2]!.fail() + await disposedClient.close() + await expect(disposed).rejects.toThrow('Remote stream client disposed') + + const abortedClient = new RemoteStreamMuxClient() + const abort = new AbortController() + const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + abort.abort('cancelled while connecting') + await expect(aborted).rejects.toBe('cancelled while connecting') + await abortedClient.close() + expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('fails active streams on an invalid frame and ignores later frames', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() + const pending = stream.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const { streamId } = JSON.parse(socket.sent[0]!) as { streamId: string } + FakeWebSocket.dispatchClose = false + socket.receiveRaw(new Uint8Array([1, 2, 3])) + socket.receive({ type: 'item', streamId, value: 'too late' }) + socket.drop() + + await expect(pending).rejects.toMatchObject({ + name: 'RemoteStreamCarrierError', message: 'api gateway: invalid Remote stream frame', + }) + expect(socket.closedWith).toContainEqual({ code: 4002, reason: 'invalid Remote stream frame' }) + await client.close() + }) + }) + + it('completes a stream and drops a frame racing with cancellation', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + const completed = client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]() + const completedPending = completed.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const completedOpen = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: completedOpen.streamId }) + await expect(completedPending).resolves.toEqual({ done: true, value: undefined }) + + const abort = new AbortController() + const cancelled = client.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const cancelledOpen = JSON.parse(socket.sent[1]!) as { streamId: string } + const reason = new Error('fixture cancellation race') + abort.abort(reason) + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'too late' }) + await expect(cancelled).rejects.toBe(reason) + await client.close() + }) + }) + + it('contains non-Error cancellation reasons and late socket close events', async () => { + await withFakeWebSocket('http://harness.example', async () => { + const cancelledClient = new RemoteStreamMuxClient() + const abort = new AbortController() + const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + abort.abort('caller cancelled') + await expect(cancelled).rejects.toThrow('caller cancelled') + await cancelledClient.close() + + FakeWebSocket.dispatchClose = false + const disposedClient = new RemoteStreamMuxClient() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) }) + const disposedSocket = FakeWebSocket.sockets[1]! + await disposedClient.close() + disposedSocket.receive({ type: 'end', streamId: 'stale' }) + disposedSocket.drop() + await expect(disposed).rejects.toThrow('Remote stream client disposed') + }) + }) +}) + +async function withFakeWebSocket( + origin: string | undefined, + run: () => Promise, +): Promise { + const originalWebSocket = globalThis.WebSocket + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + if (origin === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } }) + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + try { + await run() + } finally { + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } +} diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 8baa3d0b99..1dd83bdc47 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1046,6 +1046,56 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('claims and validates in-process Remote event results for the active Client generation', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + const connection = rawConnection(ctx) + const handler = connection.handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') + expect(connection.matches?.('$events/result')).toBe(true) + + const result = { + args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } }, + } + const inactive = await handler('$events/result', result, new AbortController().signal) + expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded') + expect(inactive.error.message).toContain('identifies no active event stream') + + const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })()) + const carrier = new AbortController() + const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const opening = await events.next() + expect(opening).toMatchObject({ done: false, value: { type: 'ready' } }) + if (opening.done) throw new Error('Remote event stream ended before ready') + const clientId: unknown = Reflect.get(opening.value as object, 'clientId') + if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id') + + for (const payload of [null, [], {}, { other: {} }]) { + const invalid = await handler('$events/result', payload, carrier.signal) + expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded') + expect(invalid.error.message).toContain('requires exactly one plain-object args field') + } + await expect(handler('$events/result', { + args: { clientId, eventId: 'missing', outcome: { kind: 'next' } }, + }, carrier.signal)).resolves.toEqual({ + ok: true, + value: undefined, + }) + + await events.return(undefined) + await unregister() + await ctx.fiber.dispose() + }) + it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) @@ -1228,6 +1278,17 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } +interface GatewayEventHarness { + openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator +} + +function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { + const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { + [symbols.original]?: GatewayEventHarness + } + return receiver[symbols.original] ?? receiver +} + function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', @@ -1256,6 +1317,7 @@ function contextProvider(context: Context) { return { wire: 'agentId', wireTypeSymbol: '@fixture/domain#AgentId', + identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined, resolve: (id: string) => id === 'agent-1' ? context : undefined, } } diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts new file mode 100644 index 0000000000..cf19abd307 --- /dev/null +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RemoteJournalStream, + RemoteStream, + RemoteStreamCarrierError, + type RemoteJournalChange, + type RemoteJournalFrame, + type RemoteStreamOptions, +} from '../src/client/index.ts' + +interface Entry { + readonly seq: number +} + +interface Page { + readonly entries: readonly Entry[] + readonly hasMore: boolean + readonly marker: string +} + +interface PageRequest { + readonly before?: number + readonly limit?: number +} + +interface Generation { + readonly frames: readonly RemoteJournalFrame[] + readonly terminal?: Error + readonly hold?: boolean +} + +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +const entries = (...seqs: number[]): Entry[] => seqs.map(seq => ({ seq })) + +const page = (marker: string, seqs: number[], hasMore = false): Page => ({ + entries: entries(...seqs), + hasMore, + marker, +}) + +const STREAM_FACTORY = { + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(AVAILABLE_CONNECTION, options) + }, +} + +class FixtureJournal extends RemoteJournalStream { + constructor( + private readonly generations: Generation[], + private readonly pages: (Page | Promise)[], + private readonly calls: string[], + private readonly pageRequests: PageRequest[], + private readonly followCursors: (number | undefined)[], + changes: RemoteJournalChange[], + failed: (error: unknown) => void, + ) { + super(STREAM_FACTORY, { + name: 'fixture journal', + emptyCursor: -1, + entries: value => value.entries, + hasMore: value => value.hasMore, + cursor: entry => entry.seq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: (change) => { changes.push(change) }, + failed, + }) + } + + /** @inheritdoc */ + protected override async * follow( + after: number | undefined, + signal: AbortSignal, + ): AsyncIterable> { + this.calls.push('follow') + this.followCursors.push(after) + const generation = this.generations.shift() + if (generation === undefined) throw new Error('no scripted journal generation') + for (const frame of generation.frames) yield frame + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } + + /** @inheritdoc */ + protected override readPage(request: PageRequest): Promise { + this.calls.push('page') + this.pageRequests.push(request) + const value = this.pages.shift() + if (value === undefined) throw new Error('no scripted journal page') + return Promise.resolve(value) + } + + /** @inheritdoc */ + protected override repairRequest(request: PageRequest): PageRequest { + return request.limit === undefined ? {} : { limit: request.limit } + } +} + +function journalFixture( + generations: Generation[], + pages: (Page | Promise)[], +): { + readonly journal: RemoteJournalStream + readonly changes: RemoteJournalChange[] + readonly failed: ReturnType + readonly calls: string[] + readonly pageRequests: PageRequest[] + readonly followCursors: (number | undefined)[] +} { + const calls: string[] = [] + const pageRequests: PageRequest[] = [] + const followCursors: (number | undefined)[] = [] + const changes: RemoteJournalChange[] = [] + const failed = vi.fn() + const journal = new FixtureJournal( + generations, + pages, + calls, + pageRequests, + followCursors, + changes, + failed, + ) + return { journal, changes, failed, calls, pageRequests, followCursors } +} + +describe('RemoteJournalStream', () => { + it('opens follow before page, removes overlap, appends live entries, and prepends history', async () => { + const fixture = journalFixture( + [{ + frames: [ + { type: 'opened', cursor: 3 }, + { type: 'entry', entry: { seq: 3 } }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }], + [page('tail', [2, 3], true), page('older', [0, 1])], + ) + + await fixture.journal.open({ limit: 2 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + await fixture.journal.prepend({ before: 2, limit: 2 }) + + expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page']) + expect(fixture.pageRequests).toEqual([{ limit: 2 }, { before: 2, limit: 2 }]) + expect(fixture.changes).toEqual([ + { type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true }, + { type: 'append', entry: { seq: 4 } }, + { type: 'prepend', page: page('older', [0, 1]), entries: entries(0, 1), hasMore: false }, + ]) + await fixture.journal.dispose() + await fixture.journal.dispose() + }) + + it('publishes one sorted replacement from a repair page and live entries queued while it loads', async () => { + let resolveRepair!: (value: Page) => void + const repair = new Promise((resolve) => { resolveRepair = resolve }) + const fixture = journalFixture( + [{ + frames: [ + { type: 'opened', cursor: 15 }, + { type: 'entry', entry: { seq: 17 } }, + { type: 'entry', entry: { seq: 16 } }, + ], + hold: true, + }], + [page('stale', [6, 7, 8, 9, 10, 11]), repair], + ) + + const opening = fixture.journal.open({ limit: 6 }) + await vi.waitFor(() => { + expect(fixture.calls.filter(call => call === 'page')).toHaveLength(2) + }) + expect(fixture.changes).toEqual([]) + + resolveRepair(page('repair', [10, 11, 12, 13, 14, 15])) + await opening + + expect(fixture.changes).toEqual([{ + type: 'replace', + page: page('repair', [10, 11, 12, 13, 14, 15]), + entries: entries(10, 11, 12, 13, 14, 15, 16, 17), + hasMore: false, + }]) + await fixture.journal.dispose() + }) + + it('repairs a replacement generation through one tail page and drops replay overlap', async () => { + const lost = new RemoteStreamCarrierError('carrier lost') + const fixture = journalFixture( + [ + { + frames: [ + { type: 'opened', cursor: 1 }, + { type: 'entry', entry: { seq: 2 } }, + ], + terminal: lost, + }, + { + frames: [ + { type: 'opened', cursor: 4 }, + { type: 'entry', entry: { seq: 3 } }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }, + ], + [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])], + ) + + await fixture.journal.open({ limit: 5 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(3) }) + + expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) + expect(fixture.changes[2]).toMatchObject({ + type: 'replace', page: { marker: 'repair' }, entries: entries(0, 1, 2, 3, 4), + }) + expect(fixture.followCursors).toEqual([undefined, 2]) + expect(fixture.failed).not.toHaveBeenCalled() + await fixture.journal.dispose() + }) + + it('repairs a live gap before publishing another change', async () => { + const fixture = journalFixture( + [{ + frames: [ + { type: 'opened', cursor: 1 }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }], + [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])], + ) + + await fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace']) + expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } }) + await fixture.journal.dispose() + }) + + it('rejects malformed opening and page sequences', async () => { + const beforeOpening = journalFixture( + [{ frames: [{ type: 'entry', entry: { seq: 0 } }] }], + [page('unused', [])], + ) + await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor') + + const discontinuousPage = journalFixture( + [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }], + [page('bad', [0, 2, 3])], + ) + await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries') + + const shortPage = journalFixture( + [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }], + [page('short', [0, 1]), page('repair-short', [0, 1, 2])], + ) + await expect(shortPage.journal.open({})).rejects.toThrow('page did not reach its opening cursor') + }) + + it('reports duplicate and regressed generation cursors as terminal failures', async () => { + const duplicate = journalFixture( + [{ + frames: [{ type: 'opened', cursor: 1 }, { type: 'opened', cursor: 1 }], + }], + [page('initial', [0, 1])], + ) + await duplicate.journal.open({}) + await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() }) + const duplicateFailure: unknown = duplicate.failed.mock.calls[0]?.[0] + expect(duplicateFailure).toBeInstanceOf(Error) + if (!(duplicateFailure instanceof Error)) throw new Error('expected duplicate-cursor failure') + expect(duplicateFailure.message).toContain('more than one opening cursor') + + const regressed = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 1 }, { type: 'entry', entry: { seq: 2 } }], + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [{ type: 'opened', cursor: 1 }] }, + ], + [page('initial', [0, 1])], + ) + await regressed.journal.open({}) + await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() }) + const regressedFailure: unknown = regressed.failed.mock.calls[0]?.[0] + expect(regressedFailure).toBeInstanceOf(Error) + if (!(regressedFailure instanceof Error)) throw new Error('expected regressed-cursor failure') + expect(regressedFailure.message).toContain('behind the last applied entry') + }) + + it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => { + const fixture = journalFixture( + [{ frames: [{ type: 'opened', cursor: 4 }], hold: true }], + [page('initial', [3, 4], true), page('older', [0, 1], true)], + ) + await fixture.journal.open({}) + + await expect(fixture.journal.prepend({ before: 3 })).rejects.toThrow('history page is discontinuous') + expect(fixture.changes.at(-1)).toEqual({ + type: 'prepend', page: page('older', [0, 1], true), entries: [], hasMore: false, + }) + await fixture.journal.dispose() + }) + + it('guards lifecycle operations before and after open', async () => { + const fixture = journalFixture( + [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], + [page('empty', [])], + ) + + await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') + await fixture.journal.open({}) + await expect(fixture.journal.open({})).rejects.toThrow('already opened') + fixture.journal.restart() + await fixture.journal.dispose() + await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') + }) +}) diff --git a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts new file mode 100644 index 0000000000..09259ea43f --- /dev/null +++ b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' +import { + isRemoteJsonValue, + parseRemoteEventResult, + parseRemoteStreamClientMessage, + projectRemoteEventRequest, + projectRemoteEventRejection, + restoreRemoteEventRejection, +} from '../src/stream-protocol.ts' + +describe('Remote Event result protocol', () => { + it('accepts delegation, values, and structured rejections', () => { + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, + })).toEqual({ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' } }) + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' }, + })).toEqual({ clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' } }) + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } }, + })).toEqual({ + clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } }, + }) + expect(parseRemoteEventResult({ + clientId: 'client-1', + eventId: 'event-minimal', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } }, + })).toEqual({ + clientId: 'client-1', + eventId: 'event-minimal', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } }, + }) + expect(parseRemoteEventResult({ + clientId: 'client-1', + eventId: 'event-4', + outcome: { + kind: 'rejected', + error: { + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }, + }, + })).toEqual({ + clientId: 'client-1', + eventId: 'event-4', + outcome: { + kind: 'rejected', + error: { + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }, + }, + }) + }) + + it.each([ + null, + [], + {}, + { clientId: '', eventId: 'event-1', outcome: { kind: 'next' } }, + { clientId: 'client-1', eventId: '', outcome: { kind: 'next' } }, + { clientId: 'client-1', eventId: 'event-1', outcome: null }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, extra: true }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next', value: null } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', extra: true } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', value: undefined } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'unknown' } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: null } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: '', message: 'bad' } } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: 'Error', message: 1 } } }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', code: 1 } }, + }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', details: 1n } }, + }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', extra: true } }, + }, + ])('rejects an invalid result frame: %#', (value) => { + expect(() => parseRemoteEventResult(value)).toThrow('api gateway: invalid Remote event') + }) + + it('rejects symbol properties in rejection records', () => { + const error = { name: 'Error', message: 'bad', [Symbol('hidden')]: true } + expect(() => parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error }, + })).toThrow('api gateway: invalid Remote event rejection') + }) +}) + +describe('Remote Event request projection', () => { + it('removes only the direct Agent and signal fields', () => { + const agent = { kind: 'agent' } + const abort = new AbortController() + const nested = { agent, signal: 'payload' } + const projected = projectRemoteEventRequest({ + agent, + signal: abort.signal, + prompt: 'approve?', + nested, + }, agent) + + expect(projected).toEqual({ + request: { prompt: 'approve?', nested }, + signal: abort.signal, + }) + expect(Object.getPrototypeOf(projected.request)).toBeNull() + }) + + it('accepts a null-prototype request and an omitted signal', () => { + const agent = { kind: 'agent' } + const request = Object.assign(Object.create(null) as Record, { + agent, + accepted: true, + }) + expect(projectRemoteEventRequest(request, agent)).toEqual({ + request: { accepted: true }, + }) + }) + + it('requires the scoped Agent as a direct own field', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest(null, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest({}, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest({ agent: {} }, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest(Object.create({ agent }), agent)) + .toThrow('must carry its scoped Agent directly') + }) + + it('rejects an invalid direct signal', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, signal: 'abort' }, agent)) + .toThrow('request signal must be an AbortSignal') + }) + + it('rejects non-JSON payload fields', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, value: 1n }, agent)) + .toThrow('request is not lossless JSON data') + + const cycle: Record = {} + cycle.self = cycle + expect(() => projectRemoteEventRequest({ agent, cycle }, agent)) + .toThrow('request is not lossless JSON data') + }) + + it('rejects symbol and non-enumerable payload fields', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, [Symbol('hidden')]: true }, agent)) + .toThrow('request has a non-JSON property') + + const hidden = { agent } + Object.defineProperty(hidden, 'value', { value: true }) + expect(() => projectRemoteEventRequest(hidden, agent)) + .toThrow('request has a non-JSON property') + }) +}) + +describe('Remote Event rejection projection', () => { + it('preserves stable error fields in both directions', () => { + const reason = Object.assign(new Error('declined'), { + name: 'ApprovalError', + code: 'DECLINED', + details: { retryable: false }, + }) + expect(projectRemoteEventRejection(reason)).toEqual({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) + + const restored = restoreRemoteEventRejection({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) as Error & { code?: string; details?: unknown } + expect(restored).toMatchObject({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) + }) + + it('normalizes arbitrary reasons and omits non-JSON optional fields', () => { + expect(projectRemoteEventRejection('offline')).toEqual({ + name: 'Error', message: 'offline', + }) + expect(projectRemoteEventRejection(undefined)).toEqual({ + name: 'Error', message: 'undefined', + }) + expect(projectRemoteEventRejection({ + name: 1, message: 2, code: 3, details: 1n, + })).toEqual({ + name: 'Error', message: '[object Object]', + }) + + const restored = restoreRemoteEventRejection({ name: 'Error', message: 'offline' }) + expect(restored).toMatchObject({ name: 'Error', message: 'offline' }) + expect(restored).not.toHaveProperty('code') + expect(restored).not.toHaveProperty('details') + }) +}) + +describe('Remote Event JSON values', () => { + it('accepts lossless JSON values, null-prototype objects, and repeated references', () => { + const shared = { value: 1 } + const nullPrototype = Object.assign(Object.create(null) as Record, { + enabled: true, + }) + expect(isRemoteJsonValue({ + null: null, + string: 'value', + boolean: true, + number: 1.5, + array: [shared, shared], + nullPrototype, + })).toBe(true) + }) + + it.each([ + undefined, + 1n, + Symbol('value'), + () => undefined, + NaN, + Number.POSITIVE_INFINITY, + -0, + ])('rejects a non-lossless scalar: %s', (value) => { + expect(isRemoteJsonValue(value)).toBe(false) + }) + + it('rejects cycles and non-plain arrays and objects', () => { + const cycle: Record = {} + cycle.self = cycle + expect(isRemoteJsonValue(cycle)).toBe(false) + + class Fixture { + value = 1 + } + expect(isRemoteJsonValue(new Fixture())).toBe(false) + + const customArray = [1] + Object.setPrototypeOf(customArray, null) + expect(isRemoteJsonValue(customArray)).toBe(false) + expect(isRemoteJsonValue(Object.assign([1], { extra: true }))).toBe(false) + + const sparse = new Array(2) + sparse[1] = 'value' + expect(isRemoteJsonValue(sparse)).toBe(false) + const disguisedSparse = Object.assign(new Array(2), { extra: true }) + disguisedSparse[1] = 'value' + expect(isRemoteJsonValue(disguisedSparse)).toBe(false) + expect(isRemoteJsonValue([undefined])).toBe(false) + + const symbolic = { [Symbol('value')]: true } + expect(isRemoteJsonValue(symbolic)).toBe(false) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true }) + expect(isRemoteJsonValue(hidden)).toBe(false) + expect(isRemoteJsonValue({ nested: undefined })).toBe(false) + }) +}) + +describe('Remote stream client protocol', () => { + it('rejects the removed logical-stream input message', () => { + expect(() => parseRemoteStreamClientMessage(JSON.stringify({ + type: 'input', streamId: 'stream-1', value: { answer: true }, + }))).toThrow('api gateway: invalid Remote stream client message') + }) +}) diff --git a/packages/api/gateway/tests/stream-protocol.host.spec.ts b/packages/api/gateway/tests/stream-protocol.host.spec.ts new file mode 100644 index 0000000000..f353dce901 --- /dev/null +++ b/packages/api/gateway/tests/stream-protocol.host.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + parseRemoteStreamClientMessage, + parseRemoteStreamServerMessage, +} from '../src/stream-protocol.ts' + +describe('Remote stream wire protocol', () => { + it('accepts every client message variant', () => { + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 }, + }))).toEqual({ + type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 }, + }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'cancel', streamId: 'stream-1', + }))).toEqual({ type: 'cancel', streamId: 'stream-1' }) + }) + + it.each([ + { type: 'open', streamId: '', endpoint: 'feed/follow', payload: {} }, + { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} }, + { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' }, + { type: 'cancel', streamId: 'stream-1', extra: true }, + { type: 'unknown', streamId: 'stream-1' }, + ])('rejects an invalid client message: %j', (message) => { + expect(() => parseRemoteStreamClientMessage(JSON.stringify(message))) + .toThrow('api gateway: invalid Remote stream client message') + }) + + it('accepts every server message variant', () => { + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', value: null, + }))).toEqual({ type: 'item', streamId: 'stream-1', value: null }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', + }))).toEqual({ type: 'item', streamId: 'stream-1' }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'error', + streamId: 'stream-1', + error: { code: 'offline', message: 'connection lost', details: {} }, + }))).toEqual({ + type: 'error', + streamId: 'stream-1', + error: { code: 'offline', message: 'connection lost', details: {} }, + }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'end', streamId: 'stream-1', + }))).toEqual({ type: 'end', streamId: 'stream-1' }) + }) + + it.each([ + { type: 'item', streamId: '', value: 'item' }, + { type: 'item', streamId: 'stream-1', extra: true }, + { type: 'end', streamId: 'stream-1', extra: true }, + { type: 'error', streamId: 'stream-1', error: [] }, + { type: 'error', streamId: 'stream-1', error: { code: 1, message: 'failure', details: {} } }, + { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 1, details: {} } }, + { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 'failure', details: [] } }, + { type: 'unknown', streamId: 'stream-1' }, + ])('rejects an invalid server message: %j', (message) => { + expect(() => parseRemoteStreamServerMessage(JSON.stringify(message))) + .toThrow('api gateway: invalid Remote stream server message') + }) + + it.each(['not json', 'null', '[]', '1'])('rejects a non-message payload: %s', (text) => { + expect(() => parseRemoteStreamServerMessage(text)).toThrow('api gateway: Remote stream message') + }) +}) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts new file mode 100644 index 0000000000..9746943b63 --- /dev/null +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -0,0 +1,246 @@ +import { once } from 'node:events' +import { createServer, type Server } from 'node:http' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + RemoteStreamMuxServer, + type RemoteStreamFailureMapper, + type RemoteStreamOpener, +} from '../src/stream-server.ts' + +interface RunningMux { + readonly http: Server + readonly mux: RemoteStreamMuxServer + readonly url: string +} + +const running = new Set() + +afterEach(async () => { + await Promise.all([...running].map(async (entry) => { + running.delete(entry) + await entry.mux.close().catch(() => undefined) + await closeHttp(entry.http) + })) +}) + +describe('Remote stream mux server carrier lifecycle', () => { + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + + const binary = await connect(entry.url) + const binaryClosed = once(binary, 'close') + binary.send(Buffer.from('{}')) + const binaryEvent = await binaryClosed + expect(binaryEvent[0]).toBe(1003) + + const malformed = await connect(entry.url) + const malformedClosed = once(malformed, 'close') + malformed.send('not json') + const malformedEvent = await malformedClosed + expect(malformedEvent[0]).toBe(1008) + expect(String(malformedEvent[1])).toBe('invalid Remote stream request') + + const duplicate = await connect(entry.url) + const longId = 'same'.repeat(100) + duplicate.send(openFrame(longId)) + duplicate.send(openFrame(longId)) + const duplicateEvent = await once(duplicate, 'close') + expect(duplicateEvent[0]).toBe(1008) + expect(String(duplicateEvent[1])).toBe('invalid Remote stream request') + + const noInput = await connect(entry.url) + noInput.send(openFrame('no-input')) + noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' })) + const noInputEvent = await once(noInput, 'close') + expect(noInputEvent[0]).toBe(1008) + expect(String(noInputEvent[1])).toBe('invalid Remote stream request') + }) + + it('accepts all ws text representations and terminates a carrier error', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' }) + + serverSocket.emit('message', [Buffer.from(cancel)], false) + serverSocket.emit('message', Uint8Array.from(Buffer.from(cancel)).buffer, false) + + const closed = once(client, 'close') + serverSocket.emit('error', new Error('fixture carrier failure')) + await closed + }) + + it('does not send an end frame after clean source cancellation', async () => { + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async (_endpoint, _payload, signal) => { + opened() + return cleanlyCancelled(signal, returned) + }) + const client = await connect(entry.url) + const frames: unknown[] = [] + client.on('message', (data) => { + if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') + frames.push(JSON.parse(data.toString('utf8')) as unknown) + }) + client.send(openFrame('cancelled')) + await didOpen + client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' })) + await didReturn + await new Promise((resolve) => { setImmediate(resolve) }) + expect(frames).toEqual([]) + client.close() + await once(client, 'close') + }) + + it('closes the carrier when ws reports an item write failure', async () => { + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + const entry = await startMux(async () => delayedItem(released, opened)) + const client = await connect(entry.url) + client.send(openFrame('write-failure')) + await didOpen + const serverSocket = acceptedSocket(entry.mux) + const mutable = serverSocket as unknown as { + send(data: unknown, callback: (error?: Error) => void): void + } + mutable.send = (_data, callback): void => { + callback(new Error('fixture ws write failure')) + } + + const closed = once(client, 'close') + release() + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + }) + + it('contains an item produced after its socket closes', async () => { + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async () => delayedItem(released, opened, returned)) + const client = await connect(entry.url) + client.send(openFrame('late-item')) + await didOpen + const serverSocket = acceptedSocket(entry.mux) + client.close() + await once(client, 'close') + await vi.waitFor(() => { expect(serverSocket.readyState).toBe(WebSocket.CLOSED) }) + release() + await didReturn + }) + + it('terminates active sockets on close and reports a repeated close', async () => { + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async (_endpoint, _payload, signal) => { + opened() + return cleanlyCancelled(signal, returned) + }) + const client = await connect(entry.url) + client.send(openFrame('active')) + await didOpen + + const closed = once(client, 'close') + await entry.mux.close() + running.delete(entry) + await closed + await didReturn + await expect(entry.mux.close()).rejects.toThrow() + await closeHttp(entry.http) + }) +}) + +const mapFailure: RemoteStreamFailureMapper = error => ({ + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, +}) + +async function startMux(open: RemoteStreamOpener): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure) + const http = createServer() + http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) + await new Promise((resolve, reject) => { + http.once('error', reject) + http.listen(0, '127.0.0.1', () => { + http.off('error', reject) + resolve() + }) + }) + const address = http.address() + if (address === null || typeof address === 'string') throw new Error('fixture HTTP server has no TCP port') + const entry = { http, mux, url: `ws://127.0.0.1:${String(address.port)}` } + running.add(entry) + return entry +} + +async function connect(url: string): Promise { + const socket = new WebSocket(url) + await once(socket, 'open') + return socket +} + +function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket { + const exposed = mux as unknown as { server: { clients: Set } } + const socket = [...exposed.server.clients][0] + if (socket === undefined) throw new Error('fixture mux has no accepted socket') + return socket +} + +function openFrame(streamId: string): string { + return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} }) +} + +async function *waitForAbort(signal: AbortSignal): AsyncIterable { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +async function *cleanlyCancelled(signal: AbortSignal, returned: () => void): AsyncIterable { + try { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + returned() + } +} + +async function *delayedItem( + released: Promise, + opened: () => void, + returned: () => void = () => {}, +): AsyncIterable { + try { + opened() + await released + yield 'item' + } finally { + returned() + } +} + +async function closeHttp(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index f95582851d..03fd952ac5 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -10,7 +10,7 @@ import type { Context, Events } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' -import type { Agent } from './runtime-types.ts' +import type { Agent } from './types.ts' /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ type Params = F extends (...args: infer P) => unknown ? P : never diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 81052096dc..5334728416 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -12,8 +12,8 @@ import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' -import type { Agent, AgentOptions } from './runtime-types.ts' +import type { Agent } from './types.ts' +import type { AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' @@ -23,16 +23,6 @@ export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' -declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertLookupMap { - agent: TypertLookup - } - - interface TypertContextMap { - agent: TypertContext - } -} - declare module '@deepseek-ai/cordis' { interface Context { agents: AgentRegistry @@ -276,6 +266,7 @@ export class AgentRegistry extends Service { typeCtx.typert.contexts.registerHost('agent', { wire: 'agentId', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + identity: candidate => candidate.agent?.id, resolve: sessionId => this.get(sessionId)?.ctx, }) }) diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 7d713f8c77..0a88642009 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -8,10 +8,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type { Inbox } from './inbox.ts' -import type { InboxTarget } from './types.ts' +import type { Agent } from './types.ts' +export type { Agent } from './types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -60,39 +61,38 @@ export type RequestErrorAction = { kind: 'retry' } | undefined /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** Public live-agent handle. */ -export interface Agent { - /** The single identity shared with {@link session}. */ - readonly id: SessionId - /** The provider route and model this agent's requests use. */ - readonly options: AgentOptions - /** The live session this agent drives; its log is the durable source of truth. */ - readonly session: Session - /** The agent-owned projection of durable pending work. */ - readonly inbox: Inbox - /** The current lifecycle state, mirrored on every `agent/status` transition. */ - readonly status: AgentStatus - /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context +declare module './types.ts' { + /** Public live-agent handle. */ + interface Agent { + /** The provider route and model this agent's requests use. */ + readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + readonly status: AgentStatus + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ + readonly ctx: Context - /** + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no * active activity, cancellation is a no-op and does not arm later work. * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** + /** * Resolve after the current whole-agent activity reaches quiescence. This * follows replacement work started before the observed driver retires, * but does not identify the settlement of any particular message. * @returns fulfillment after no active driver or maintenance task remains. */ - whenIdle(): Promise + whenIdle(): Promise - /** + /** * Run one non-turn maintenance task from the true idle phase. The task starts * synchronously after claiming that phase; later waking input remains in the * inbox until the task settles, while public status stays `idle`. @@ -101,9 +101,9 @@ export interface Agent { * @throws synchronously when turn-driving or another maintenance task already owns the agent. * @returns the task promise. */ - runMaintenance(task: (signal: AbortSignal) => Promise): Promise + runMaintenance(task: (signal: AbortSignal) => Promise): Promise - /** + /** * Route identified input to an inbox boundary and optionally wake the driver. * Waking input submitted after active cancellation is queued for the next * turn and runs when the aborted activity converges to idle; a `disposed` @@ -114,25 +114,25 @@ export interface Agent { * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. */ - send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + send(message: UserMessage, target: InboxTarget, wakeup: boolean): void - /** + /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. */ - followup(message: UserMessage): void + followup(message: UserMessage): void - /** + /** * Submit steering for the nearest step. An idle driver starts a turn; * a running driver consumes it at its next step boundary. * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. */ - steer(message: UserMessage): void + steer(message: UserMessage): void - /** + /** * Queue model-facing context for the next pre-step without waking the * driver. A running driver claims it at the nearest later step boundary; * idle drivers leave it pending until follow-up or steering @@ -140,7 +140,8 @@ export interface Agent { * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. */ - inject(message: UserMessage): void + inject(message: UserMessage): void + } } declare module '@deepseek-ai/cordis' { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b54e56ea8f..955024ee67 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -5,6 +5,25 @@ */ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' + +/** Minimum Agent identity visible to cross-process event declarations. */ +export interface Agent { + /** Session-backed Agent identity. */ + readonly id: SessionId +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertLookupMap { + agent: TypertLookup + } + + interface TypertContextMap { + /** Agent Context identity shared by Host and Client adapters. */ + agent: TypertContext + } +} /** One of the two ordered pending-message lists owned by an agent. */ export type InboxTarget = 'next-turn' | 'next-step' diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 4876e38809..33ecfb3c7b 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -149,6 +149,7 @@ describe('AgentRegistry', () => { await agentFiber await ctx.plugin(TypertRegistry) const agent = stubAgent('remote-agent') + Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) }) const disposeAgent = ctx.agents.register(agent) const lookup = ctx.typert.lookups.get('agent') @@ -159,7 +160,10 @@ describe('AgentRegistry', () => { wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) expect(lookup?.resolve(agent.id)).toBe(agent) - expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx) + const context = ctx.typert.contexts.getHost('agent') + expect(context?.identity(agent.ctx)).toBe(agent.id) + expect(context?.identity(ctx)).toBeUndefined() + expect(context?.resolve(agent.id)).toBe(agent.ctx) disposeAgent() expect(lookup?.resolve(agent.id)).toBeUndefined() diff --git a/packages/experimental/webworker-runtime/src/client/api-client.ts b/packages/experimental/webworker-runtime/src/client/api-client.ts index 0e99075d47..17bbc8f270 100644 --- a/packages/experimental/webworker-runtime/src/client/api-client.ts +++ b/packages/experimental/webworker-runtime/src/client/api-client.ts @@ -1,9 +1,7 @@ /** - * Page-side API carrier over the postMessage tunnel. Only `doFetch` is - * implemented: the streaming methods stay on `AbstractApiClient`'s default - * `readSse`, which is exactly what the worker answers on the two event-stream - * paths — so unary calls and downstream streams share one framing and neither - * side needs a WebSocket. + * Page-side unary API carrier over the postMessage tunnel. Gateway Remote + * streams use the tunnel's dedicated logical-stream frames instead of this + * fetch-shaped API path. */ import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' import type { WorkerTunnel } from './client.ts' diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index e03fb6e9c9..7fa05a5355 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -6,31 +6,16 @@ */ import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' - -/** Frame sent to the worker. */ -interface RequestFrame { - t: 'req' - id: number - method: string - /** Absolute URL; the worker derives `req.url` (pathname + search) from it. */ - url: string - headers: Record - body?: ArrayBuffer | undefined -} - -/** Cancellation of an in-flight request or stream. */ -interface AbortFrame { - t: 'abort' - id: number -} - -/** Frames received from the worker. */ -type ResponseFrame = - | { t: 'res'; id: number; status: number; headers: Record; body?: ArrayBuffer; message?: string } - | { t: 'res-head'; id: number; status: number; headers: Record } - | { t: 'res-chunk'; id: number; chunk: ArrayBuffer } - | { t: 'res-end'; id: number } - | { t: 'res-err'; id: number; message: string } +import type { + TunnelAbortFrame as AbortFrame, + TunnelOutboundFrame as ResponseFrame, + TunnelRequestFrame as RequestFrame, + TunnelRequestId, + TunnelStreamEndFrame, + TunnelStreamErrorFrame, + TunnelStreamItemFrame, + TunnelStreamOpenFrame, +} from '../transport/frames.ts' /** Boot payload of the tunnel bootstrap route. */ export interface BootPayload { @@ -46,6 +31,58 @@ interface PendingUnary { reject(reason: Error): void } +type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelStreamErrorFrame + +interface TunnelStreamFailureMarker { + readonly kind: 'remote' | 'carrier' + readonly code?: string + readonly details?: object +} + +/** Error carrying stream semantics across independently bundled Client code. */ +class TunnelLogicalStreamError extends Error { + readonly dshRemoteStreamFailure: TunnelStreamFailureMarker + + constructor(failure: TunnelStreamErrorFrame['failure'], options?: ErrorOptions) { + super(failure.message, options) + this.name = 'TunnelLogicalStreamError' + this.dshRemoteStreamFailure = failure.kind === 'remote' + ? { kind: 'remote', code: failure.code, details: failure.details } + : { kind: 'carrier' } + } +} + +class LogicalStreamInbox { + private readonly frames: LogicalStreamFrame[] = [] + private wake: (() => void) | undefined + private failed = false + private failure: unknown + + push(frame: LogicalStreamFrame): void { + if (this.failed) return + this.frames.push(frame) + this.wake?.() + this.wake = undefined + } + + fail(reason: unknown): void { + if (this.failed) return + this.failed = true + this.failure = reason + this.frames.length = 0 + this.wake?.() + this.wake = undefined + } + + async next(): Promise { + while (this.frames.length === 0) { + if (this.failed) throw this.failure + await new Promise((resolve) => { this.wake = resolve }) + } + return this.frames.shift() as LogicalStreamFrame + } +} + /** * Statuses the worker only produces when the host refused the exchange rather than * answered it; a route's own 4xx is the tree talking and stays silent here. @@ -72,8 +109,9 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]) export class WorkerTunnel { private readonly worker: Worker private nextId = 1 - private readonly unary = new Map() - private readonly streams = new Map>() + private readonly unary = new Map() + private readonly bodyStreams = new Map>() + private readonly logicalStreams = new Map() /** * In-flight request descriptions, so a refusal names what was refused. * @@ -82,10 +120,10 @@ export class WorkerTunnel { * page console but not the frames. Warning here separates the two without * recording anything on the normal path, where no refusal frame ever arrives. */ - private readonly inFlight = new Map() + private readonly inFlight = new Map() /** Body-phase abort listeners, released when their stream settles. */ - private readonly releases = new Map void>() + private readonly releases = new Map void>() /** * Attach to a spawned worker and start consuming response frames. @@ -102,8 +140,14 @@ export class WorkerTunnel { this.inFlight.clear() for (const pending of this.unary.values()) pending.reject(reason) this.unary.clear() - for (const controller of this.streams.values()) controller.error(reason) - this.streams.clear() + for (const controller of this.bodyStreams.values()) controller.error(reason) + this.bodyStreams.clear() + const failure = new TunnelLogicalStreamError({ + kind: 'carrier', + message: `web-preview tunnel: worker failed: ${event.message}`, + }, { cause: reason }) + for (const inbox of this.logicalStreams.values()) inbox.fail(failure) + this.logicalStreams.clear() for (const release of this.releases.values()) release() this.releases.clear() }) @@ -145,13 +189,60 @@ export class WorkerTunnel { const settled = await Promise.race([response, raced.rejected]) // A streaming response outlives its head: hand the signal to the body // phase, so a later stop still ends the stream and reaches the worker. - if (this.streams.has(id)) this.observeStreamAbort(id, signal) + if (this.bodyStreams.has(id)) this.observeStreamAbort(id, signal) return settled } finally { raced.release() } } + /** + * Open one decoded Gateway Remote stream over the worker-local carrier. + * @param endpoint - canonical Gateway Remote endpoint. + * @param payload - decoded endpoint payload. + * @param signal - logical-stream cancellation. + * @returns decoded stream values from the worker Host. + */ + async *open(endpoint: string, payload: unknown, signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const id = this.nextId++ + const inbox = new LogicalStreamInbox() + let opened = false + let terminal = false + const onAbort = (): void => { inbox.fail(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + this.logicalStreams.set(id, inbox) + this.inFlight.set(id, `STREAM ${endpoint}`) + try { + const frame: TunnelStreamOpenFrame = { t: 'stream-open', id, endpoint, payload } + try { + this.worker.postMessage(frame) + opened = true + } catch (cause) { + throw new TunnelLogicalStreamError({ + kind: 'carrier', + message: `web-preview tunnel: failed to open Remote stream ${endpoint}`, + }, { cause }) + } + while (true) { + const response = await inbox.next() + signal.throwIfAborted() + if (response.t === 'stream-item') { + yield response.value + continue + } + terminal = true + if (response.t === 'stream-error') throw new TunnelLogicalStreamError(response.failure) + return + } + } finally { + signal.removeEventListener('abort', onAbort) + this.logicalStreams.delete(id) + this.inFlight.delete(id) + if (opened && !terminal) this.abortWorkerOperation(id) + } + } + /** * Read the pre-cordis boot payload (the injection table). * @returns The payload the page applies before the client tree loads. @@ -198,7 +289,7 @@ export class WorkerTunnel { } } - private rejectOnAbort(id: number, signal: AbortSignal): { rejected: Promise; release: () => void } { + private rejectOnAbort(id: TunnelRequestId, signal: AbortSignal): { rejected: Promise; release: () => void } { let release = (): void => {} const rejected = new Promise((_resolve, reject) => { const fail = (): void => { reject(this.abortRequest(id)) } @@ -220,14 +311,13 @@ export class WorkerTunnel { * @param id - request id being abandoned. * @returns The abort error the caller surfaces. */ - private abortRequest(id: number): DOMException { + private abortRequest(id: TunnelRequestId): DOMException { this.unary.delete(id) - const controller = this.streams.get(id) - this.streams.delete(id) + const controller = this.bodyStreams.get(id) + this.bodyStreams.delete(id) this.inFlight.delete(id) this.releases.delete(id) - const abort: AbortFrame = { t: 'abort', id } - this.worker.postMessage(abort) + this.abortWorkerOperation(id) const reason = new DOMException('The operation was aborted.', 'AbortError') controller?.error(reason) return reason @@ -240,26 +330,35 @@ export class WorkerTunnel { * @param id - request id whose body is still crossing. * @param signal - the caller's signal. */ - private observeStreamAbort(id: number, signal: AbortSignal): void { + private observeStreamAbort(id: TunnelRequestId, signal: AbortSignal): void { const onAbort = (): void => { this.abortRequest(id) } signal.addEventListener('abort', onAbort, { once: true }) this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) }) } /** Release a body-phase abort listener a settled stream no longer needs. */ - private releaseSignal(id: number): void { + private releaseSignal(id: TunnelRequestId): void { const release = this.releases.get(id) this.releases.delete(id) release?.() } /** Cancel a stream the consumer stopped reading (the head already resolved). */ - private cancelStream(id: number): void { + private cancelStream(id: TunnelRequestId): void { this.releaseSignal(id) - this.streams.delete(id) + this.bodyStreams.delete(id) this.inFlight.delete(id) + this.abortWorkerOperation(id) + } + + /** Best-effort cancellation: a failed worker cannot receive the frame anyway. */ + private abortWorkerOperation(id: TunnelRequestId): void { const abort: AbortFrame = { t: 'abort', id } - this.worker.postMessage(abort) + try { + this.worker.postMessage(abort) + } catch { + // The operation is already locally terminal; worker failure is reported by its owning path. + } } /** @@ -271,7 +370,7 @@ export class WorkerTunnel { * @param id - request id the frame answers. * @param outcome - what came back instead of a reply. */ - private warnRefusal(id: number, outcome: string): void { + private warnRefusal(id: TunnelRequestId, outcome: string): void { console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'} → ${outcome}`) } @@ -297,7 +396,7 @@ export class WorkerTunnel { this.unary.delete(frame.id) const stream = new ReadableStream({ start: (controller) => { - this.streams.set(frame.id, controller) + this.bodyStreams.set(frame.id, controller) }, cancel: () => { this.cancelStream(frame.id) @@ -307,13 +406,13 @@ export class WorkerTunnel { return } case 'res-chunk': { - this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk)) + this.bodyStreams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk)) return } case 'res-end': { - const controller = this.streams.get(frame.id) + const controller = this.bodyStreams.get(frame.id) if (controller === undefined) return - this.streams.delete(frame.id) + this.bodyStreams.delete(frame.id) this.inFlight.delete(frame.id) this.releaseSignal(frame.id) controller.close() @@ -329,13 +428,19 @@ export class WorkerTunnel { pending.reject(reason) return } - const controller = this.streams.get(frame.id) + const controller = this.bodyStreams.get(frame.id) if (controller === undefined) return - this.streams.delete(frame.id) + this.bodyStreams.delete(frame.id) this.releaseSignal(frame.id) controller.error(reason) return } + case 'stream-item': + case 'stream-end': + case 'stream-error': { + this.logicalStreams.get(frame.id)?.push(frame) + return + } default: { const unknown: never = frame throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`) diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 4603586fca..c84f637896 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -23,6 +23,7 @@ interface ClientTransportGlobal { __DSH_TRANSPORT__?: { createApiClient: () => WorkerApiClient fetch: TunnelFetch + openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable loadBundle: (url: string) => Promise /** The page spawned the worker the Host runs in, so the page owns it. */ ownsHost: boolean @@ -84,6 +85,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { createApiClient: () => new WorkerApiClient(tunnel), fetch: (input, init) => tunnel.fetch(input, init), + openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal), loadBundle: (url: string) => tunnel.loadBundle(url), // The host lives in a worker this page spawned: the page owns it, so // the privileged surface stays reachable off loopback authorities. diff --git a/packages/experimental/webworker-runtime/src/index.ts b/packages/experimental/webworker-runtime/src/index.ts index 762384f1d6..cf39694ca0 100644 --- a/packages/experimental/webworker-runtime/src/index.ts +++ b/packages/experimental/webworker-runtime/src/index.ts @@ -11,6 +11,8 @@ export { type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame, type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame, + type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame, + type TunnelStreamOpenFrame, } from './transport/frames.ts' export { DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader, @@ -23,7 +25,7 @@ export { } from './transport/synthetic-http.ts' export { lowerModuleSource, type LoweredModule } from './compile/transform.ts' export { - API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer, + API_PREFIX, SYNTHETIC_HOST, TunnelServer, type TunnelPort, type TunnelSeams, type TunnelServerOptions, } from './transport/tunnel.ts' export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts' diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index e10f58a021..67d89b73fd 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -17,6 +17,14 @@ export interface TunnelRequestFrame { readonly body?: ArrayBuffer | undefined } +/** Open one Gateway Remote stream over the worker-local carrier. */ +export interface TunnelStreamOpenFrame { + readonly t: 'stream-open' + readonly id: TunnelRequestId + readonly endpoint: string + readonly payload: unknown +} + /** Page-side cancellation of an in-flight request or stream. */ export interface TunnelAbortFrame { readonly t: 'abort' @@ -34,7 +42,11 @@ export interface TunnelInitFrame { } /** Every frame the page sends the worker. */ -export type TunnelInboundFrame = TunnelInitFrame | TunnelRequestFrame | TunnelAbortFrame +export type TunnelInboundFrame = + | TunnelInitFrame + | TunnelRequestFrame + | TunnelStreamOpenFrame + | TunnelAbortFrame /** Complete response for unary requests and static files. */ export interface TunnelResponseFrame { @@ -75,6 +87,36 @@ export interface TunnelResponseErrorFrame { readonly message: string } +/** One decoded value from a worker-local Gateway Remote stream. */ +export interface TunnelStreamItemFrame { + readonly t: 'stream-item' + readonly id: TunnelRequestId + readonly value?: unknown +} + +/** Normal completion of a worker-local Gateway Remote stream. */ +export interface TunnelStreamEndFrame { + readonly t: 'stream-end' + readonly id: TunnelRequestId +} + +/** Stable Host failure or worker-carrier failure for one logical stream. */ +export interface TunnelStreamErrorFrame { + readonly t: 'stream-error' + readonly id: TunnelRequestId + readonly failure: + | { + readonly kind: 'remote' + readonly code: string + readonly message: string + readonly details: object + } + | { + readonly kind: 'carrier' + readonly message: string + } +} + /** Frames the worker emits. */ export type TunnelOutboundFrame = | TunnelResponseFrame @@ -82,6 +124,9 @@ export type TunnelOutboundFrame = | TunnelResponseChunkFrame | TunnelResponseEndFrame | TunnelResponseErrorFrame + | TunnelStreamItemFrame + | TunnelStreamEndFrame + | TunnelStreamErrorFrame /** * Validate a `postMessage` payload as a tunnel frame. @@ -104,6 +149,12 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`) } if (frame.t === 'abort') return { t: 'abort', id } + if (frame.t === 'stream-open') { + if (typeof frame.endpoint !== 'string' || frame.endpoint.length === 0) { + throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`) + } + return { t: 'stream-open', id, endpoint: frame.endpoint, payload: frame.payload } + } if (frame.t !== 'req') throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`) if (typeof frame.method !== 'string' || typeof frame.url !== 'string') { throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`) diff --git a/packages/experimental/webworker-runtime/src/transport/tunnel.ts b/packages/experimental/webworker-runtime/src/transport/tunnel.ts index 47b6265288..b181e89687 100644 --- a/packages/experimental/webworker-runtime/src/transport/tunnel.ts +++ b/packages/experimental/webworker-runtime/src/transport/tunnel.ts @@ -4,8 +4,6 @@ * * - `GET /__boot__` answers from tunnel glue, never from the host API surface, * because the page needs the boot payload before its Cordis tree exists. - * - The two event-stream paths go straight to the API fetch handler so they take - * its SSE branch; the `/api` route answers 426 upgrade-required first. * - Privileged `/api` methods take that same direct entry: the browser strips the * `host` header from the WHATWG `Request` the route lane rebuilds, so the * privileged fence would answer 403 for every one of them. The method set is @@ -21,14 +19,12 @@ */ import { parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId, + type TunnelStreamOpenFrame, } from './frames.ts' import { createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange, } from './synthetic-http.ts' -/** Event-stream routes that must reach the SSE branch of the API fetch handler. */ -export const STREAM_PATHS = ['/api/events.mux', '/api/events.host'] as const - /** Prefix owning the API methods. */ export const API_PREFIX = '/api' @@ -91,12 +87,24 @@ export interface TunnelPort { /** What the tunnel gains once the host tree is up. */ export interface TunnelSeams { /** - * Direct entry to the API fetch handler: event streams, privileged methods, - * and any unary call the route lane refused with 403. + * Direct entry to the API fetch handler for privileged methods and any unary + * call the route lane refused with 403. */ readonly directFetch: (request: Request) => Promise /** Boot payload for `GET /__boot__`: the structured index injection table. */ readonly bootPayload: () => unknown + /** Open one decoded Gateway Remote stream without another network carrier. */ + readonly openStream: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => Promise> + /** Convert a Gateway stream failure to stable Client fields. */ + readonly streamFailure: (error: unknown) => { + readonly code: string + readonly message: string + readonly details: object + } } /** Construction inputs for {@link TunnelServer}. */ @@ -125,6 +133,8 @@ interface InFlight { abort(): void } +type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame + /** Recorded response frames, so a 403 from the route lane can be discarded. */ class BufferedSink { private readonly calls: Array<() => void> = [] @@ -171,7 +181,7 @@ export class TunnelServer { private readonly requestListener: () => Promise private readonly privilegedMethods: ReadonlySet | undefined private readonly unaryApiLane: 'route' | 'direct' - private readonly queue: TunnelRequestFrame[] = [] + private readonly queue: QueuedFrame[] = [] private readonly inFlight = new Map() private seams: TunnelSeams | undefined private failure: string | undefined @@ -209,7 +219,7 @@ export class TunnelServer { this.queue.push(frame) return } - void this.serveRequest(frame) + this.dispatchFrame(frame) } /** @@ -219,7 +229,7 @@ export class TunnelServer { serve(seams: TunnelSeams): void { this.seams = seams console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`) - for (const frame of this.queue.splice(0)) void this.serveRequest(frame) + for (const frame of this.queue.splice(0)) this.dispatchFrame(frame) } /** @@ -237,7 +247,15 @@ export class TunnelServer { this.port.postMessage(frame, transfer) } - private refuse(frame: TunnelRequestFrame, message: string): void { + private refuse(frame: QueuedFrame, message: string): void { + if (frame.t === 'stream-open') { + this.send({ + t: 'stream-error', + id: frame.id, + failure: { kind: 'carrier', message }, + }) + return + } const body = toTransferable(encoder.encode(message)) this.send({ t: 'res', @@ -249,6 +267,40 @@ export class TunnelServer { }, [body]) } + private dispatchFrame(frame: QueuedFrame): void { + if (frame.t === 'stream-open') void this.serveStream(frame) + else void this.serveRequest(frame) + } + + private async serveStream(frame: TunnelStreamOpenFrame): Promise { + if (this.seams === undefined) { + this.refuse(frame, 'webworker tunnel: Remote stream requested before the host tree is serving') + return + } + const seams = this.seams + const controller = new AbortController() + this.inFlight.set(frame.id, { abort: () => { controller.abort() } }) + try { + const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal) + for await (const value of source) { + if (controller.signal.aborted) return + this.send({ t: 'stream-item', id: frame.id, value }) + } + if (!controller.signal.aborted) this.send({ t: 'stream-end', id: frame.id }) + } catch (error) { + if (!controller.signal.aborted) { + const failure = seams.streamFailure(error) + this.send({ + t: 'stream-error', + id: frame.id, + failure: { kind: 'remote', ...failure }, + }) + } + } finally { + this.inFlight.delete(frame.id) + } + } + private sinkFor(id: TunnelRequestId): ResponseSink { const send = this.send.bind(this) const inFlight = this.inFlight @@ -291,7 +343,6 @@ export class TunnelServer { try { const { frame: routed, path } = this.pathFrame(frame) if (path === '/__boot__') { this.serveBoot(frame, sink); return } - if ((STREAM_PATHS as readonly string[]).includes(path)) { await this.serveDirect(frame, sink); return } if (path.startsWith(`${API_PREFIX}/`)) { await this.serveApi(frame, routed, path, sink); return } this.dispatch(routed, sink) } catch (reason) { diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 40c177a60f..f73f1d2e5c 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -22,6 +22,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host */ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' +import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' import { dirname, join } from './module-system/posix-path.ts' import { installProcessGlobal } from './node/globals/process.ts' @@ -237,6 +238,10 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service') + const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined + if (typertGateway === undefined) { + throw new Error('webworker host: the tree activated without a typertGateway service') + } const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as { toFetchHandler: (api: unknown) => { fetch(request: Request): Promise } } @@ -248,6 +253,8 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), bootPayload: () => readBootPayload(ctx), + openStream: typertGateway.wireStream.open, + streamFailure: typertGateway.wireStream.failure, }) } catch (reason) { tunnel.fail(reason) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index c5dc4ed215..ceacba25df 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -27,17 +27,31 @@ const warnings: string[] = [] console.warn = (message: string) => { warnings.push(message) } ;(globalThis as { location?: unknown }).location = { origin: 'http://localhost:4173' } -type Listener = (event: { data: unknown }) => void +type StubListener = (event: { data?: unknown; message?: string }) => void /** A worker stand-in: collects what the page sent, replays what the test delivers. */ -function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deliver: (frame: unknown) => void } { - const listeners: Listener[] = [] +function stubWorker(): { + worker: Worker + sent: { t: string; id: number }[] + deliver: (frame: unknown) => void + fail: (message: string) => void +} { + const listeners: StubListener[] = [] + const errorListeners: StubListener[] = [] const sent: { t: string; id: number }[] = [] const worker = { - addEventListener: (type: string, listener: Listener) => { if (type === 'message') listeners.push(listener) }, + addEventListener: (type: string, listener: StubListener) => { + if (type === 'message') listeners.push(listener) + if (type === 'error') errorListeners.push(listener) + }, postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) }, } as unknown as Worker - return { worker, sent, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) } } + return { + worker, + sent, + deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) }, + fail: (message) => { for (const listener of errorListeners) listener({ message }) }, + } } // A normal reply resolves and says nothing on the console. @@ -129,3 +143,90 @@ function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deli // A late reply to an aborted request must not resurrect it. deliver({ t: 'res', id: 1, status: 200, headers: {}, message: 'late' }) } + +// A logical Gateway stream carries decoded values and one terminal frame. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const signal = new AbortController() + const stream = tunnel.open('session/follow', { args: { sessionId: 'session-1' } }, signal.signal) + [Symbol.asyncIterator]() + const first = stream.next() + check('a logical stream opens on the worker-local carrier', sent[0], { + t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }) + deliver({ t: 'stream-item', id: 1, value: { type: 'baseline' } }) + check('a logical stream yields decoded values', await first, { value: { type: 'baseline' }, done: false }) + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('a logical stream closes normally', await ended, { done: true, value: undefined }) + check('normal stream completion sends no cancellation', sent, [ + { t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } } }, + ]) +} + +// Host failures retain their code and details for the Gateway Client bundle to normalize. +{ + const { worker, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const pending = tunnel.open('session/follow', {}, new AbortController().signal).next() + deliver({ + t: 'stream-error', + id: 1, + failure: { + kind: 'remote', + code: 'session-not-found', + message: 'fixture Session is absent', + details: { sessionId: 'session-1' }, + }, + }) + const failure = await pending.then(() => undefined, (error: unknown) => error as { + message: string + dshRemoteStreamFailure: unknown + }) + check('a logical Host failure retains its structural marker', { + message: failure?.message, + dshRemoteStreamFailure: failure?.dshRemoteStreamFailure, + }, { + message: 'fixture Session is absent', + dshRemoteStreamFailure: { + kind: 'remote', code: 'session-not-found', details: { sessionId: 'session-1' }, + }, + }) +} + +// Caller cancellation keeps the caller's exact reason and reaches the worker once. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const abort = new AbortController() + const pending = tunnel.open('workspace/follow', {}, abort.signal).next() + const reason = new Error('caller stopped the Workspace feed') + abort.abort(reason) + deliver({ t: 'stream-item', id: 1, value: 'late' }) + check('logical stream cancellation preserves the caller reason', await pending.then( + () => 'resolved', + (error: unknown) => error === reason ? 'same reason' : 'different reason', + ), 'same reason') + check('logical stream cancellation reaches the worker', sent.at(-1), { t: 'abort', id: 1 }) +} + +// A failed worker is a carrier failure, not a fabricated Host Remote error. +{ + warnings.length = 0 + const { worker, fail } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const pending = tunnel.open('$events', { args: {} }, new AbortController().signal).next() + fail('worker crashed') + const failure = await pending.then(() => undefined, (error: unknown) => error as { + message: string + dshRemoteStreamFailure: unknown + }) + check('worker failure carries the carrier marker', { + message: failure?.message, + dshRemoteStreamFailure: failure?.dshRemoteStreamFailure, + }, { + message: 'web-preview tunnel: worker failed: worker crashed', + dshRemoteStreamFailure: { kind: 'carrier' }, + }) +} diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts new file mode 100644 index 0000000000..f9107e1037 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TunnelOutboundFrame } from '../../src/transport/frames.ts' +import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts' + +function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } { + const frames: TunnelOutboundFrame[] = [] + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.reject(new Error('fixture has no HTTP listener')), + }) + return { server, frames } +} + +function seams(openStream: TunnelSeams['openStream']): TunnelSeams { + return { + directFetch: () => Promise.reject(new Error('fixture has no direct fetch')), + bootPayload: () => ({}), + openStream, + streamFailure: error => ({ + code: 'fixture-stream-failed', + message: error instanceof Error ? error.message : String(error), + details: { fixture: true }, + }), + } +} + +describe('worker tunnel logical streams', () => { + it('drains a pre-boot open through the worker-local Gateway seam', async () => { + const { server, frames } = harness() + const seen: unknown[] = [] + server.handleMessage({ + t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }) + expect(frames).toEqual([]) + + server.serve(seams(async (endpoint, payload, signal) => { + seen.push(endpoint, payload, signal) + return (async function *(): AsyncGenerator { + yield { type: 'baseline' } + yield { type: 'event', seq: 1 } + })() + })) + + await vi.waitFor(() => { + expect(frames).toEqual([ + { t: 'stream-item', id: 1, value: { type: 'baseline' } }, + { t: 'stream-item', id: 1, value: { type: 'event', seq: 1 } }, + { t: 'stream-end', id: 1 }, + ]) + }) + expect(seen).toEqual([ + 'session/follow', + { args: { sessionId: 'session-1' } }, + expect.any(AbortSignal), + ]) + }) + + it('cancels one logical stream without emitting a terminal frame', async () => { + const { server, frames } = harness() + const opened = Promise.withResolvers() + const stopped = Promise.withResolvers() + server.serve(seams(async (_endpoint, _payload, signal) => { + opened.resolve(signal) + return (async function *(): AsyncGenerator { + yield 'ready' + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + stopped.resolve(undefined) + })() + })) + server.handleMessage({ t: 'stream-open', id: 2, endpoint: '$events', payload: { args: {} } }) + const signal = await opened.promise + await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-item', id: 2, value: 'ready' }) }) + + server.handleMessage({ t: 'abort', id: 2 }) + await stopped.promise + expect(signal.aborted).toBe(true) + expect(frames).toEqual([{ t: 'stream-item', id: 2, value: 'ready' }]) + }) + + it('maps a Host stream failure through Gateway-owned fields', async () => { + const { server, frames } = harness() + server.serve(seams(async () => { throw new Error('Host stream exploded') })) + server.handleMessage({ t: 'stream-open', id: 3, endpoint: 'probe/watch', payload: {} }) + + await vi.waitFor(() => { + expect(frames).toEqual([{ + t: 'stream-error', + id: 3, + failure: { + kind: 'remote', + code: 'fixture-stream-failed', + message: 'Host stream exploded', + details: { fixture: true }, + }, + }]) + }) + }) + + it('refuses queued and future streams after boot failure as carrier failures', () => { + const { server, frames } = harness() + server.handleMessage({ t: 'stream-open', id: 4, endpoint: '$events', payload: {} }) + server.fail(new Error('image failed')) + server.handleMessage({ t: 'stream-open', id: 5, endpoint: '$events', payload: {} }) + + expect(frames).toEqual([4, 5].map(id => ({ + t: 'stream-error', + id, + failure: { kind: 'carrier', message: 'Error: image failed' }, + }))) + }) +}) diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index b0618f6b3d..ee4f087d5e 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -10,7 +10,6 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -18,44 +17,10 @@ declare module '@deepseek-ai/cordis' { interface Context { approval: ApprovalService } - - interface Events { - /** - * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). - * @mode waterfall - */ - 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise - } } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { - /** - * An approval question was put to the answerer chain — log-only audit - * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs - * it with the `approval/decided` that always follows; `toolName` is the - * tool the question is about, `callId` the exact tool call when the asker - * had one, `reason` the asker's human-readable explanation (e.g. a hook's - * permission-decision reason). - */ - 'approval/asked': { - id: ApprovalRequestId - toolName: string - callId?: CallId - reason?: string - } - /** - * The outcome of a prior `approval/asked` (same `id`) — log-only audit. - * Exactly one per ask, appended when the outcome is known: a decision, a - * cancellation, or the fail-closed `'unavailable'`. - */ - 'approval/decided': { - id: ApprovalRequestId - outcome: ApprovalOutcome - } /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy @@ -73,7 +38,7 @@ declare module '@deepseek-ai/dsh-session/types' { } import { ApprovalRequestId } from './types.ts' -import type { ApprovalOutcome } from './types.ts' +import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts' export { ApprovalRequestId } from './types.ts' export type { ApprovalOutcome } from './types.ts' @@ -150,7 +115,7 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -export interface ApprovalRequest { +export interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit diff --git a/packages/interaction/user-approval/src/types.ts b/packages/interaction/user-approval/src/types.ts index 5a862ea546..a04ef15db2 100644 --- a/packages/interaction/user-approval/src/types.ts +++ b/packages/interaction/user-approval/src/types.ts @@ -6,6 +6,9 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent/types' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' /** * Pairs one `approval/asked` audit event with its `approval/decided`. @@ -27,3 +30,61 @@ export function ApprovalRequestId(id: string): ApprovalRequestId { * request, or unavailable answerer. Callers fail closed on `unavailable`. */ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * An approval question was put to the answerer chain — log-only audit + * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs + * it with the `approval/decided` that always follows; `toolName` is the + * tool the question is about, `callId` the exact tool call when the asker + * had one, `reason` the asker's human-readable explanation (e.g. a hook's + * permission-decision reason). + */ + 'approval/asked': { + id: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string + } + /** + * The outcome of a prior `approval/asked` (same `id`) — log-only audit. + * Exactly one per ask, appended when the outcome is known: a decision, a + * cancellation, or the fail-closed `'unavailable'`. + */ + 'approval/decided': { + id: ApprovalRequestId + outcome: ApprovalOutcome + } + } +} + +/** Client-safe payload declared for the approval answerer waterfall. */ +export interface ApprovalRequestEvent { + /** Agent identity projected to the corresponding Client Context in transit. */ + readonly agent: Agent + /** Tool whose operation requires a decision. */ + readonly toolName: string + /** Exact tool call being decided, when available. */ + readonly callId?: CallId + /** Human-readable reason supplied by the asker. */ + readonly reason?: string + /** Cancellation lifetime of the pending request. */ + readonly signal?: AbortSignal +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()` to delegate. + * @param req - pending approval request. + * @mode waterfall + */ + 'approval/request'( + this: Scoped, + req: ApprovalRequestEvent, + next: () => Promise, + ): Promise + } +} diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 0862f49cd0..4feb4e296f 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -17,7 +17,9 @@ declare module '@deepseek-ai/cordis' { } } -import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts' +import type { + AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequestEvent, +} from './types.ts' export type { AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem, @@ -25,7 +27,7 @@ export type { } from './types.ts' /** Request for a human answer. */ -export interface AskUserQuestionRequest { +export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent { /** Questions to display. */ questions: AskUserQuestionItem[] /** Exact live calling agent, when the request came from an agent tool call. */ diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 147d416f65..fbca8f5e3b 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -1,9 +1,7 @@ -/** - * Wire-safe question and answer types, free of cordis/service imports so browser - * type chains (apiproxy api → client) can consume them without loading this - * package's Context augmentation. - * @module @deepseek-ai/dsh-user-questions/types - */ +/** Client-safe question, answer, and event types. @module @deepseek-ai/dsh-user-questions/types */ + +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent/types' /** One selectable answer offered to the user. */ export interface AskUserQuestionOption { @@ -64,3 +62,29 @@ export interface AskUserQuestionAnswer { /** Structured answers keyed by question id. */ answers: AskUserQuestionAnswerItem[] } + +/** Client-safe payload declared for the user-question answerer waterfall. */ +export interface AskUserQuestionRequestEvent { + /** Questions to display. */ + questions: AskUserQuestionItem[] + /** Agent identity projected to the corresponding Client Context in transit. */ + agent?: Agent + /** Cancellation lifetime of the pending request. */ + signal?: AbortSignal +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Ask composed answerers for structured user input. Return an answer to + * claim the request or call `next()` to delegate. + * @param request - pending user-question request. + * @mode waterfall + */ + 'user-questions/request'( + this: Scoped, + request: AskUserQuestionRequestEvent, + next: () => Promise, + ): Promise + } +} diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 0eee1ff61a..a27f577da2 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -976,7 +976,7 @@ class FaceAnalyzer { binding: GatewayBinding, method: ts.MethodDeclaration, invocation: - | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, ): InvocationModel { if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { @@ -1106,13 +1106,15 @@ class FaceAnalyzer { } } - const resultType = this.remoteResultType(method) + const mode = invocation.kind === 'direct' ? invocation.mode : undefined + const resultType = this.remoteResultType(method, mode) return { id: `${registration.name}#${binding.namespace}/${exportedMethod}`, service: binding.service, namespace: binding.namespace, method: exportedMethod, ...(exportedMethod === methodName ? {} : { implementation: methodName }), + ...(mode === undefined ? {} : { mode }), invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, @@ -1215,11 +1217,11 @@ class FaceAnalyzer { private remoteMarker( member: ts.ClassElement, ): - | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined { let found: - | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { @@ -1229,12 +1231,28 @@ class FaceAnalyzer { marker = { kind: 'direct' } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'Remote')) { - if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') - const exportName = stringLiteralValue(expression.arguments[0]) - if (exportName === undefined || !isRemoteSegment(exportName)) { - this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters') + if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one name or options object') + const argument = expression.arguments[0] + if (argument === undefined) this.fail(expression, 'Remote() requires one name or options object') + const exportName = stringLiteralValue(argument) + if (exportName !== undefined) { + if (!isRemoteSegment(exportName)) { + this.fail(argument, 'Remote() name must contain only RPC endpoint segment characters') + } + marker = { kind: 'direct', exportName } + } else { + if (!ts.isObjectLiteralExpression(argument) || argument.properties.length !== 1) { + this.fail(argument, 'Remote() options must contain exactly mode: "stream"') + } + const [property] = argument.properties + if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"') + if (!ts.isPropertyAssignment(property) + || memberName(property.name) !== 'mode' + || stringLiteralValue(property.initializer) !== 'stream') { + this.fail(property, 'Remote() options must contain exactly mode: "stream"') + } + marker = { kind: 'direct', mode: 'stream' } } - marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { @@ -1259,16 +1277,27 @@ class FaceAnalyzer { return found } - private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode { + private remoteResultType(method: ts.MethodDeclaration, mode?: 'stream'): ts.TypeNode { const authored = this.requiredType(method, method.type, 'return') - if (!ts.isTypeReferenceNode(authored)) return authored - const symbol = this.checker.getSymbolAtLocation(authored.typeName) - const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) - const resultType = authored.typeArguments?.[0] - if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored - const declaration = preferredDeclaration(resolved) - if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored - return resultType + if (ts.isTypeReferenceNode(authored)) { + const symbol = this.checker.getSymbolAtLocation(authored.typeName) + const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) + const resultType = authored.typeArguments?.[0] + const wrappers = mode === 'stream' ? ['Iterable', 'AsyncIterable'] : ['Promise'] + const declaration = resolved === undefined ? undefined : preferredDeclaration(resolved) + if (resolved !== undefined + && wrappers.includes(resolved.name) + && resultType !== undefined + && authored.typeArguments?.length === 1 + && declaration !== undefined + && isStandardLibraryFile(declaration.getSourceFile().fileName)) { + return resultType + } + } + if (mode === 'stream') { + this.fail(method, 'stream Remote methods must return Iterable or AsyncIterable') + } + return authored } private isGlobalAbortSignal(type: ts.TypeNode): boolean { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index a8216426d2..4b036372d6 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -282,6 +282,7 @@ export class FaceModelEmitter { if (invocation.implementation !== undefined) { lines.push(` implementation: ${quote(invocation.implementation)},`) } + if (invocation.mode !== undefined) lines.push(` mode: ${quote(invocation.mode)},`) if (invocation.invocation.kind === 'direct') { lines.push(' invocation: { kind: \'direct\' },') } else { @@ -467,9 +468,11 @@ export class FaceModelEmitter { `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) - // The Client Remote face delivers the carrier's outcome, so every generated - // consumer signature resolves to a result the caller reads instead of a - // value it must guard with its own try/catch. + if (invocation.mode === 'stream') { + return `(${parameters.join(', ')}) => AsyncIterable<${result}>` + } + // The unary Client Remote face delivers the carrier's outcome, so every + // generated consumer signature resolves to a result the caller reads. return `(${parameters.join(', ')}) => Promise>` } } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 1b8402b6d2..477c4368c4 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -131,6 +131,7 @@ export interface InvocationModel { readonly namespace: string readonly method: string readonly implementation?: string + readonly mode?: 'stream' readonly invocation: | { readonly kind: 'direct' } | { diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index e72d838b47..514722123b 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -23,6 +23,12 @@ export class GoalService extends TypertRemoteService { rename(request: RenameGoalRequest): RenameGoalResult { return { renamed: request.title.length > 0 } } + + @Remote({ mode: 'stream' }) + async *watch(agent: Agent, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + yield { ref: agent.id } + } } export type { diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 3ad5bedddb..37c741915c 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -60,7 +60,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ): void - export function Remote(exportName: string): + export function Remote(option: string | { readonly mode: 'stream' }): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 21804c52fa..12ff2f4d07 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -20,6 +20,7 @@ interface RuntimeSchema { interface RuntimeDescriptor { readonly id: string + readonly mode?: 'stream' readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string @@ -66,7 +67,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const model = remotePackage(fixtureRoot) expect(model.services).toEqual([]) - expect(model.invocations).toHaveLength(2) + expect(model.invocations).toHaveLength(3) expect(model.invocations[0]).toMatchObject({ id: '@fixture/remote#goals/create', service: 'goals', @@ -111,6 +112,22 @@ describe('Remote model generation', { timeout: 60_000 }, () => { }], result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' }, }) + expect(model.invocations[2]).toMatchObject({ + id: '@fixture/remote#goals/watch', + service: 'goals', + namespace: 'goals', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + }], + cancellation: { parameter: 'signal' }, + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( @@ -124,6 +141,9 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.remote?.dts).toContain( "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) + expect(artifact?.remote?.dts).toContain( + "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => AsyncIterable", + ) const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript') @@ -136,6 +156,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false) + expect(generated.TYPERT_REMOTE.descriptors[2]?.mode).toBe('stream') const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap expect(declarationMap).toMatchObject({ @@ -241,17 +262,15 @@ export type GenericResult = { ' RenameGoalResult,\n GenericRequest,\n GenericResult,\n', ) .replace( - ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', - ` rename(request: RenameGoalRequest): RenameGoalResult { - return { renamed: request.title.length > 0 } - } - - @Remote + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote dispatch(request: GenericRequest): GenericResult { if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } } return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } } } -}`, + + @Remote({ mode: 'stream' }) + async *watch`, )) const [artifact] = new WorkspaceTypertGenerator(root).generate() @@ -292,16 +311,14 @@ export interface BoxPayload { ' RenameGoalResult,\n Box,\n BoxPayload,\n', ) .replace( - ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', - ` rename(request: RenameGoalRequest): RenameGoalResult { - return { renamed: request.title.length > 0 } - } - - @Remote + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote box(request: Box): Box { return request } -}`, + + @Remote({ mode: 'stream' }) + async *watch`, )) const [artifact] = new WorkspaceTypertGenerator(root).generate() @@ -313,16 +330,14 @@ export interface BoxPayload { it('quotes aliased methods in generated namespace interfaces', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace( - ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', - ` rename(request: RenameGoalRequest): RenameGoalResult { - return { renamed: request.title.length > 0 } - } - - @Remote('create-goal') + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote('create-goal') createAlias(request: CreateGoalRequest): CreateGoalResult { return { ref: request.title } } -}`, + + @Remote({ mode: 'stream' }) + async *watch`, )) const [artifact] = new WorkspaceTypertGenerator(root).generate() @@ -343,8 +358,9 @@ export interface BoxPayload { it('rejects a Remote export after its last Remote method is removed', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source - .replace(' @Remote\n', '') - .replace(" @RemoteScope('agent')\n", '')) + .replaceAll(' @Remote\n', '') + .replace(" @RemoteScope('agent')\n", '') + .replace(" @Remote({ mode: 'stream' })\n", '')) editFile(root, 'packages/remote/src/types.ts', source => `${source} /** @typert schema */ diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 8481c40343..ad8d973f2f 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -5,7 +5,7 @@ */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { TypertContextMap } from './types.ts' +import type { RemoteFailure, TypertContextMap } from './types.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -37,22 +37,42 @@ export class TypertLookupFailure extends Error { } } +/** A business Remote rejection preserved by unary and stream carriers. */ +export class TypertRemoteFailure extends Error { + /** Stable caller-facing failure payload. */ + readonly failure: RemoteFailure + + /** + * Wrap one business rejection for transport without changing its code or details. + * @param failure - business failure returned unchanged to the caller. + */ + constructor(failure: RemoteFailure) { + super(failure.message) + this.name = 'TypertRemoteFailure' + this.failure = failure + } +} + export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, RemoteFailure, RemoteResult, + TypertClientEventListener, TypertClientRemote, - TypertClientContextBinder, + TypertClientContextAdapter, TypertCodec, TypertContext, + TypertContextAdapter, TypertContextMap, TypertContextRegistry, TypertContextWire, TypertDisposer, TypertForwardableEvent, - TypertHostContextProvider, + TypertForwardableEventEntry, + TypertHostContextAdapter, + TypertHostContextIdentity, TypertHostContextResolver, TypertLocalRegistry, TypertLookup, @@ -103,9 +123,17 @@ export interface RemoteMethodMarker { readonly method: string /** Endpoint method when it differs from the implementation member. */ readonly exportName?: string + /** Stream methods yield many independently validated result items. */ + readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker } +/** Options for a non-unary Remote method. */ +export interface RemoteMethodOptions { + /** Deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode: 'stream' +} + type RemoteMethodDecorator = ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, @@ -120,6 +148,7 @@ interface RemoteInitializerContext { interface StoredRemoteMethodMarker { readonly exportName?: string + readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker } @@ -170,31 +199,47 @@ export function Remote( context: ClassMethodDecoratorContext Result>, ): void /** - * Mark one public instance method under a distinct exported method name. - * @param exportName - Remote endpoint method, without a namespace or slash. + * Mark one public instance method under an exported name or as a logical stream. + * @param option - endpoint method name or stream delivery mode. * @returns a standard method decorator. */ -export function Remote(exportName: string): RemoteMethodDecorator +export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator export function Remote( - methodOrExportName: string | ((this: This, ...args: Args) => Result), + methodExportOrOptions: string | RemoteMethodOptions | ((this: This, ...args: Args) => Result), context?: ClassMethodDecoratorContext Result>, ): void | RemoteMethodDecorator { - if (typeof methodOrExportName === 'string') { - validateName('Remote export name', methodOrExportName) - return function ( - _method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult, - decoratorContext: ClassMethodDecoratorContext< - DecoratorThis, - (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult - >, - ): void { - addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName) + if (typeof methodExportOrOptions === 'string') { + validateName('Remote export name', methodExportOrOptions) + return remoteDecorator({ kind: 'direct' }, undefined, methodExportOrOptions) + } + if (typeof methodExportOrOptions === 'object') { + if (remoteOptionMode(methodExportOrOptions) !== 'stream' + || Reflect.ownKeys(methodExportOrOptions).length !== 1) { + throw new TypeError('typert-protocol: Remote options must contain exactly mode: "stream"') } + return remoteDecorator({ kind: 'direct' }, 'stream') } if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing') addMarkerInitializer(context, { kind: 'direct' }) } +function remoteOptionMode(options: object): unknown { + return Reflect.get(options, 'mode') as unknown +} + +function remoteDecorator( + invocation: RemoteInvocationMarker, + mode?: 'stream', + exportName?: string, +): RemoteMethodDecorator { + return function ( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void { + addMarkerInitializer(context, invocation, mode, exportName) + } +} + /** * Create a decorator for a method resolved from one Remote Scope. * @param key - scope key declared through the Context map. @@ -207,12 +252,7 @@ export function RemoteScope( ): RemoteMethodDecorator { validateName('Scope key', key) if (exportName !== undefined) validateName('Remote export name', exportName) - return function ( - _method: (this: This, ...args: Args) => Result, - context: ClassMethodDecoratorContext Result>, - ): void { - addMarkerInitializer(context, { kind: 'context', context: key }, exportName) - } + return remoteDecorator({ kind: 'context', context: key }, undefined, exportName) } /** @@ -230,6 +270,7 @@ export function remoteMethods(service: object): readonly RemoteMethodMarker[] { function addMarkerInitializer( context: RemoteInitializerContext, invocation: RemoteInvocationMarker, + mode?: 'stream', exportName?: string, ): void { if (context.private || context.static || typeof context.name !== 'string') { @@ -241,7 +282,7 @@ function addMarkerInitializer( if (prototype === null) { throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`) } - mark(prototype, method, invocation, exportName) + mark(prototype, method, invocation, mode, exportName) }) } @@ -249,6 +290,7 @@ function mark( prototype: object, method: string, invocation: RemoteInvocationMarker, + mode?: 'stream', exportName?: string, ): void { let table = markers.get(prototype) @@ -258,11 +300,14 @@ function mark( } const marker: StoredRemoteMethodMarker = { ...(exportName === undefined || exportName === method ? {} : { exportName }), + ...(mode === undefined ? {} : { mode }), invocation: Object.freeze(invocation), } const current = table.get(method) if (current !== undefined) { - if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return + if (current.exportName === marker.exportName + && current.mode === marker.mode + && sameInvocation(current.invocation, invocation)) return throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`) } table.set(method, Object.freeze(marker)) diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 2f8c4a6276..8123b2a36a 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -54,7 +54,7 @@ export interface RemoteFailure { * What every generated Remote method resolves to. The Remote face itself folds * carrier failures into the error branch, so no consumer wraps a call to * recover one; only assembly faults (arity, an unmounted method, a missing - * Context binder) still reject. + * Context adapter) still reject. * @template T - the Host method's business result. */ export type RemoteResult = @@ -64,23 +64,92 @@ export type RemoteResult = /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypertRemoteScopeMap {} +type TypertEventParameters = + Events[Event] extends (...args: infer Args) => unknown ? Args : never + +type TypertEventResult = + Events[Event] extends (...args: never[]) => infer Result ? Result : never + +type TypertProjectedContextKey = Extract + +type TypertProjectedContextSubject = { + [Key in TypertProjectedContextKey]: TypertLookupHost +}[TypertProjectedContextKey] + +type TypertAgentScopedRequest = Request extends object + ? 'agent' extends keyof Request + ? Exclude extends TypertProjectedContextSubject ? Request : never + : never + : never + +type TypertWaterfallEvent = + unknown extends ThisParameterType + ? never + : TypertEventParameters extends [infer Request, infer Next] + ? Next extends () => TypertEventResult + ? TypertEventResult extends Promise + ? TypertAgentScopedRequest extends never ? never : Event + : never + : never + : never + +type TypertForwardingMode = + unknown extends ThisParameterType + ? TypertEventResult extends void ? 'emit' : never + : TypertWaterfallEvent extends never ? never : 'waterfall' + /** - * Cordis event names whose shape a one-way Remote delivery can carry: unbound - * from any Scope and returning `void`. Which ones are actually forwarded is the - * Host assembly's selection; this predicate only excludes shapes the carrier - * cannot represent. + * Cordis event names the Remote Event carrier can preserve without a second + * signature declaration: unscoped `void` notifications and scoped async + * waterfalls whose final parameter is their same-result `next()` callback. */ export type TypertForwardableEvent = { - [Event in keyof Events]: unknown extends ThisParameterType - ? ReturnType extends void ? Event : never + [Event in keyof Events]: TypertForwardingMode extends never ? never : Event +}[keyof Events] + +/** Event and dispatch mode accepted by the Remote Event source. */ +export type TypertForwardableEventEntry = { + [Event in keyof Events]: TypertForwardingMode extends infer Mode + ? Mode extends 'emit' | 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] /** Merge-extensible forwarding selection declared once by the Host assembly. */ export interface TypertRemoteEventSelection {} -/** Legal `$on` keys: selected events that exist in the current compilation face. */ -export type TypertRemoteEvent = Extract +/** Legal `$on` keys selected from the carrier-compatible Cordis event declarations. */ +export type TypertRemoteEvent = Extract + +type TypertClientAgent = + Exclude extends TypertProjectedContextSubject + ? Context | Extract + : Value + +type TypertClientEventRequest = Request extends object + ? { [Key in keyof Request]: Key extends 'agent' ? TypertClientAgent : Request[Key] } + : never + +type TypertScopedClientEventListener = + Events[Event] extends (request: infer Request, next: infer Next) => infer Result + ? ( + this: Context, + request: TypertClientEventRequest, + next: Next, + ) => Result + : never + +/** + * Listener derived from one selected Cordis event declaration. Scoped Host + * subjects become the resolved Client `Context`; one-way notifications retain + * their declaration unchanged. + * @template Event - selected Remote Event name. + */ +export type TypertClientEventListener = + unknown extends ThisParameterType + ? Events[Event] + : TypertScopedClientEventListener /** * Resolve one direct Remote namespace from the generated flat endpoint map. @@ -181,6 +250,8 @@ export interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -192,7 +263,7 @@ export interface InvocationDescriptor { } /** Optional consuming-Context projection for one direct lookup parameter. */ readonly scope?: { - /** Context kind whose Client binder supplies the identity. */ + /** Context kind whose Client adapter supplies the identity. */ readonly context: string /** Lookup parameter wire field replaced by the Context identity. */ readonly wire: string @@ -204,7 +275,7 @@ export interface InvocationDescriptor { /** Reserved final Host method parameter. */ readonly parameter: 'signal' } - /** Codec for the resolved method result. */ + /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation @@ -227,26 +298,15 @@ export interface TypertClientRemote extends TypertRemoteNamespaceMap { */ $mount(contribution: TypertRemoteContribution): Promise /** - * Subscribe to one forwarded Host event; delivery is one-way, in registration - * order, and isolates a throwing listener from the rest. + * Subscribe to one forwarded Host event. Notifications run in registration + * order and isolate failures; scoped waterfalls return, delegate through + * `next()`, or reject the Host dispatch. * @template Event - forwarded event name selected by the Host assembly. * @param event - forwarded Host event name, unchanged on the wire. - * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @param listener - receives the Client projection of the Cordis `Events` declaration. * @returns disposer owned by the calling fiber. */ - $on(event: Event, listener: Events[Event]): () => void - /** - * Hand one decoded forwarded frame to the subscription table. The carrier - * owning the Host frame sink calls this; a consumer subscribes with - * {@link TypertClientRemote.$on} and never calls it. - * - * `event` is a plain string because this is the wire boundary: the name is - * whatever the Host assembly's allowlist selected, and one nobody subscribed - * to is dropped silently. - * @param event - forwarded Host event name, exactly as the Host emitted it. - * @param args - the Host argument list, already JSON-decoded. - */ - $dispatch(event: string, args: readonly unknown[]): void + $on(event: Event, listener: TypertClientEventListener): () => void } /** @@ -290,33 +350,58 @@ export interface TypertLookupDefinition { readonly wireTypeSymbol: string } -/** Host resolver for one scoped Remote kind. */ -export interface TypertHostContextProvider { - /** Wire field carrying the Context identity. */ - readonly wire: string - /** Canonical wire type symbol used by strict generation. */ - readonly wireTypeSymbol: string +/** Bidirectional projection between one environment's Context and its wire identity. */ +export interface TypertContextAdapter { /** - * Resolve a wire identity to its live scoped Context. + * Read the identity represented by a live Context. + * @param ctx - Context in this adapter's environment. + * @returns the wire identity, or `undefined` when the Context has another kind. + */ + identity(ctx: Context): Wire | undefined + /** + * Resolve a wire identity to a live Context in this adapter's environment. + * An asynchronous Client resolver may wait for its owner to create the Context. * @param id - validated wire identity. - * @returns the scoped Context, or `undefined` when unavailable. + * @returns the Context, or `undefined` when it is unavailable. */ resolve(id: Wire): Context | undefined | Promise } -/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */ +/** Host Context adapter plus the wire declaration used by strict Remote methods. */ +export interface TypertHostContextAdapter extends TypertContextAdapter { + /** Wire field carrying the Context identity. */ + readonly wire: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} + +/** Composition-owned resolver replacing one Host Context adapter's default lookup policy. */ export type TypertHostContextResolver = ( id: Wire, ) => Context | undefined | Promise -/** Client resolver for the identity carried by the calling scoped Context. */ -export interface TypertClientContextBinder { +/** Client-side bidirectional Context adapter. */ +export interface TypertClientContextAdapter { /** - * Read the Remote identity represented by a calling Context. - * @param ctx - Context rebound by the Cordis service tracker. - * @returns the wire identity, or `undefined` when the Context has the wrong scope. + * Read the identity represented by a live Client Context. + * @param ctx - Client Context inspected by a scoped Remote caller. + * @returns the wire identity, or `undefined` for another Context kind. */ identity(ctx: Context): Wire | undefined + /** + * Resolve a wire identity from the Client's currently materialized Contexts. + * @param id - validated wire identity. + * @returns the Client Context, or `undefined` when unavailable. + */ + resolve(id: Wire): Context | undefined +} + +/** Host Context identity selected from the registered adapter set. */ +export interface TypertHostContextIdentity { + /** Merge-declared Context kind whose adapter recognized the Context. */ + readonly kind: string + /** Wire identity returned by that adapter. */ + readonly identity: unknown } /** Notification emitted after a Typert runtime registry changes. */ @@ -423,20 +508,20 @@ export interface TypertLookupRegistry { subscribe(listener: TypertRegistryListener): TypertDisposer } -/** Runtime registry for Host Context resolvers and Client Context binders. */ +/** Runtime registry for the Host and Client adapters of each Context kind. */ export interface TypertContextRegistry { /** - * Register a Host Context resolver. + * Register a Host Context adapter. * @param key - merge-declared Context key. - * @param provider - owning package's Host resolver. - * @returns disposer withdrawing the exact provider. + * @param adapter - owning package's bidirectional Host projection. + * @returns disposer withdrawing the exact adapter. */ registerHost>( key: K, - provider: TypertHostContextProvider>, + adapter: TypertHostContextAdapter>, ): TypertDisposer /** - * Override one Host Context key's identity policy for the calling fiber. + * Override one Host Context key's resolution policy for the calling fiber. * Configuration may precede provider registration and restores the provider's default resolver on disposal. * @param key - merge-declared Context key. * @param resolver - composition-owned resolver used by every Host Context lookup of this key. @@ -447,29 +532,36 @@ export interface TypertContextRegistry { resolver: TypertHostContextResolver>, ): TypertDisposer /** - * Register a Client Context identity binder. + * Register a Client Context adapter. * @param key - merge-declared Context key. - * @param binder - Client scope identity resolver. - * @returns disposer withdrawing the exact binder. + * @param adapter - owning package's bidirectional Client projection. + * @returns disposer withdrawing the exact adapter. */ registerClient>( key: K, - binder: TypertClientContextBinder>, + adapter: TypertClientContextAdapter>, ): TypertDisposer /** - * Look up a Host Context resolver. - * @param key - descriptor Context key. - * @returns the provider, or `undefined` when absent. + * Identify a live Host Context through the sole registered adapter set. + * @param ctx - Context projected by a Host-to-Client scoped event. + * @returns its kind and wire identity, or `undefined` when no adapter recognizes it. + * @throws when more than one Context kind recognizes the same Context. */ - getHost(key: string): TypertHostContextProvider | undefined + identifyHost(ctx: Context): TypertHostContextIdentity | undefined /** - * Look up a Client Context binder. + * Look up a Host Context adapter. * @param key - descriptor Context key. - * @returns the binder, or `undefined` when absent. + * @returns the adapter, or `undefined` when absent. */ - getClient(key: string): TypertClientContextBinder | undefined + getHost(key: string): TypertHostContextAdapter | undefined /** - * Observe later Context provider changes. + * Look up a Client Context adapter. + * @param key - descriptor Context key. + * @returns the adapter, or `undefined` when absent. + */ + getClient(key: string): TypertClientContextAdapter | undefined + /** + * Observe later Context adapter changes. * @param listener - synchronous contained observer. * @returns disposer for this subscription. */ diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index 332ce183c5..84e919edc5 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -8,11 +8,25 @@ import { Remote, RemoteScope, remoteMethods, + type TypertClientEventListener, type TypertContext, type TypertForwardableEvent, + type TypertForwardableEventEntry, + type TypertLookup, type TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' +interface MetaFixtureSubject { + readonly subjectId: string +} + +interface MetaFixtureRequest { + readonly agent: MetaFixtureSubject + readonly signal?: AbortSignal + readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }] + readonly transform: (subject: MetaFixtureSubject) => Promise +} + declare module '@deepseek-ai/cordis' { interface Events { /** @@ -25,6 +39,17 @@ declare module '@deepseek-ai/cordis' { * @param value - marker payload. */ 'meta-fixture/scoped'(this: Context, value: string): void + /** + * Test-only scoped waterfall whose result can make the return trip. + * @param value - marker payload. + * @param next - delegates to the next listener. + * @returns the claimed or delegated value. + */ + 'meta-fixture/waterfall'( + this: Context, + request: MetaFixtureRequest, + next: () => Promise, + ): Promise /** * Test-only answered event, whose result no one-way delivery can return. * @param value - marker payload. @@ -35,12 +60,16 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertLookupMap { + metaFixture: TypertLookup + } + interface TypertContextMap { metaFixture: TypertContext } interface TypertRemoteEventSelection extends - Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {} + Record<'meta-fixture/forwardable' | 'meta-fixture/waterfall' | 'meta-fixture/absent', true> {} } describe('typert-protocol Remote declarations', () => { @@ -55,6 +84,11 @@ describe('typert-protocol Remote declarations', () => { return value } + @Remote({ mode: 'stream' }) + *watch(): Iterable { + yield 'value' + } + @RemoteScope('metaFixture') scoped(value: string): string { return value @@ -78,6 +112,7 @@ describe('typert-protocol Remote declarations', () => { }) expect(remoteMethods(goals)).toEqual([ { method: 'create', invocation: { kind: 'direct' } }, + { method: 'watch', mode: 'stream', invocation: { kind: 'direct' } }, { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, ]) await ctx.fiber.dispose() @@ -192,6 +227,8 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote('bad name')).toThrow('export name') expect(() => Remote('.')).toThrow('export name') expect(() => Remote('..')).toThrow('export name') + expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode') + expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') @@ -213,6 +250,12 @@ describe('typert-protocol Remote declarations', () => { Reflect.setPrototypeOf(prototypeLess, null) expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype') + const stream: Array<(this: object) => void> = [] + Remote({ mode: 'stream' })(method, methodContext('run', stream)) + const conflict = Object.create({}) as object + direct[0]!.call(conflict) + expect(() => { stream[0]!.call(conflict) }).toThrow('conflicting invocation markers') + class Service { run(): void {} } @@ -236,14 +279,41 @@ describe('typert-protocol Remote declarations', () => { expect(() => bindTypertRemote({}, 'goals', { namespace: 'api goals' })).toThrow('namespace') }) - it('admits only one-way event shapes and only selected events that exist', () => { + it('admits notifications and same-result scoped waterfalls selected from Cordis Events', () => { expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/waterfall'>().toExtend() expectTypeOf<'meta-fixture/scoped'>().not.toExtend() expectTypeOf<'meta-fixture/answered'>().not.toExtend() expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/waterfall'>().toExtend() expectTypeOf<'meta-fixture/scoped'>().not.toExtend() expectTypeOf<'meta-fixture/absent'>().not.toExtend() + + expectTypeOf<{ event: 'meta-fixture/forwardable'; mode: 'emit' }>() + .toExtend() + expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'waterfall' }>() + .toExtend() + expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'emit' }>() + .not.toExtend() + }) + + it('derives Client Context arguments from the selected Cordis waterfall declaration', () => { + type ExpectedListener = ( + this: Context, + request: { + readonly agent: Context + readonly signal?: AbortSignal + readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }] + readonly transform: (subject: MetaFixtureSubject) => Promise + }, + next: () => Promise, + ) => Promise + + expectTypeOf>() + .toEqualTypeOf() + expectTypeOf>() + .toEqualTypeOf<(value: string) => void>() }) }) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 30fc57d1bc..4f4e86d10f 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -9,12 +9,12 @@ import { Context, Service } from '@deepseek-ai/cordis' import { z } from 'zod' import type { InvocationDescriptor, - TypertClientContextBinder, + TypertClientContextAdapter, TypertContextMap, TypertContextRegistry, TypertContextWire, TypertDisposer, - TypertHostContextProvider, + TypertHostContextAdapter, TypertHostContextResolver, TypertLocalRegistry, TypertLookupHost, @@ -334,9 +334,9 @@ function lookupDefinitionEquals(left: TypertLookupDefinition, right: TypertLooku } class ContextStore { - private readonly hosts = new Map>() + private readonly hosts = new Map>() private readonly hostResolvers = new Map>() - private readonly clients = new Map>() + private readonly clients = new Map>() private readonly changes: ChangeSource constructor(report: ReportObserverError) { @@ -347,34 +347,51 @@ class ContextStore { return { registerHost: >( key: K, - provider: TypertHostContextProvider>, - ) => this.registerHost(ctx, key, provider), + adapter: TypertHostContextAdapter>, + ) => this.registerHost(ctx, key, adapter), configureHost: >( key: K, resolver: TypertHostContextResolver>, ) => this.configureHost(ctx, key, resolver), registerClient: >( key: K, - binder: TypertClientContextBinder>, - ) => this.registerClient(ctx, key, binder), + adapter: TypertClientContextAdapter>, + ) => this.registerClient(ctx, key, adapter), + identifyHost: context => this.identifyHost(context), getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), } } - private getHost(key: string): TypertHostContextProvider | undefined { - const provider = this.hosts.get(key)?.provider - if (provider === undefined) return undefined + private getHost(key: string): TypertHostContextAdapter | undefined { + const adapter = this.hosts.get(key)?.provider + if (adapter === undefined) return undefined const resolver = this.hostResolvers.get(key)?.provider - if (resolver === undefined) return provider + if (resolver === undefined) return adapter return { - wire: provider.wire, - wireTypeSymbol: provider.wireTypeSymbol, + wire: adapter.wire, + wireTypeSymbol: adapter.wireTypeSymbol, + identity: context => adapter.identity(context), resolve: id => resolver.resolve(id), } } + private identifyHost(ctx: Context): ReturnType { + let match: ReturnType + for (const key of this.hosts.keys()) { + const identity = this.getHost(key)?.identity(ctx) + if (identity === undefined) continue + if (match !== undefined) { + throw new Error( + `typert: Host Context is recognized by both ${JSON.stringify(match.kind)} and ${JSON.stringify(key)}`, + ) + } + match = { kind: key, identity } + } + return match + } + private configureHost( ctx: Context, key: string, @@ -399,16 +416,16 @@ class ContextStore { }, `typert.contexts.configureHost(${JSON.stringify(key)})`) } - private registerHost(ctx: Context, key: string, provider: TypertHostContextProvider): TypertDisposer { + private registerHost(ctx: Context, key: string, adapter: TypertHostContextAdapter): TypertDisposer { validateSegment('Context key', key) - validateWireName('Context wire field', provider.wire) - validateNonempty('Context wire type symbol', provider.wireTypeSymbol) - return this.registerProvider(ctx, this.hosts, 'host-context', key, provider) + validateWireName('Context wire field', adapter.wire) + validateNonempty('Context wire type symbol', adapter.wireTypeSymbol) + return this.registerProvider(ctx, this.hosts, 'host-context', key, adapter) } - private registerClient(ctx: Context, key: string, binder: TypertClientContextBinder): TypertDisposer { + private registerClient(ctx: Context, key: string, adapter: TypertClientContextAdapter): TypertDisposer { validateSegment('Context key', key) - return this.registerProvider(ctx, this.clients, 'client-context', key, binder) + return this.registerProvider(ctx, this.clients, 'client-context', key, adapter) } private registerProvider( @@ -484,7 +501,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract { return this.lookupStore.view(this.ctx) } - /** Host Context providers and Client Context binders. */ + /** Host and Client Context adapters. */ get contexts(): TypertContextRegistry { return this.contextStore.view(this.ctx) } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 2128a186a7..546063b0de 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertContextMap { registryFixture: TypertContext + registryFixtureOther: TypertContext } } @@ -331,10 +332,12 @@ describe('TypertRegistry', () => { const disposeHost = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', + identity: candidate => candidate === scoped ? object.id : undefined, resolve: id => id === object.id ? scoped : undefined, }) const disposeClient = ctx.typert.contexts.registerClient('registryFixture', { identity: candidate => candidate === scoped ? object.id : undefined, + resolve: id => id === object.id ? scoped : undefined, }) expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) @@ -345,8 +348,15 @@ describe('TypertRegistry', () => { hostTypeSymbol: '@fixture/agent#Agent', wireTypeSymbol: '@fixture/session#SessionId', }]) + expect(ctx.typert.contexts.getHost('registryFixture')?.identity(scoped)).toBe('agent-1') expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') + expect(ctx.typert.contexts.getClient('registryFixture')?.resolve('agent-1')).toBe(scoped) + expect(ctx.typert.contexts.identifyHost(scoped)).toEqual({ + kind: 'registryFixture', + identity: 'agent-1', + }) + expect(ctx.typert.contexts.identifyHost(ctx)).toBeUndefined() await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) expect(ctx.typert.lookups.keys()).toEqual([]) @@ -355,6 +365,26 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('rejects a Host Context recognized by more than one registered kind', async () => { + const ctx = await makeCtx() + const scoped = ctx.extend() + ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === scoped ? 'first' : undefined, + resolve: () => undefined, + }) + ctx.typert.contexts.registerHost('registryFixtureOther', { + wire: 'otherAgentId', + wireTypeSymbol: '@fixture#OtherAgentId', + identity: candidate => candidate === scoped ? 'second' : undefined, + resolve: () => undefined, + }) + + expect(() => ctx.typert.contexts.identifyHost(scoped)) + .toThrow('recognized by both "registryFixture" and "registryFixtureOther"') + }) + it('configures an asynchronous lookup resolver independently of provider load order', async () => { const ctx = await makeCtx() const fallback = { id: 'fallback' } @@ -400,9 +430,11 @@ describe('TypertRegistry', () => { const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', + identity: candidate => candidate === fallback ? 'fallback' : undefined, resolve: id => id === 'fallback' ? fallback : undefined, }) await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + expect(ctx.typert.contexts.getHost('registryFixture')?.identity(fallback)).toBe('fallback') expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') await disposeProvider() @@ -410,6 +442,7 @@ describe('TypertRegistry', () => { const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', + identity: candidate => candidate === fallback ? 'fallback' : undefined, resolve: id => id === 'fallback' ? fallback : undefined, }) await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) @@ -438,9 +471,13 @@ describe('TypertRegistry', () => { const host = { wire: 'agentId', wireTypeSymbol: '@fixture#AgentId', + identity: (_candidate: Context) => undefined, + resolve: () => undefined, + } + const client = { + identity: (_candidate: Context) => undefined, resolve: () => undefined, } - const client = { identity: () => undefined } const disposeLookup = ctx.typert.lookups.register('fixture', lookup) const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host) const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client)