blob: 0b2edd5dd234b383f28d7108a0e209a2cb50a4b1 [file]
import {
encodeProtocolFrame,
type SessionContinuitySnapshot,
type SubscriptionFrame,
type SubscriptionOpenResult,
type SessionTranscriptQueryInput,
type SessionTranscriptQueryResult,
} from '../protocol/index.js';
const MAX_CLIENT_QUEUED_FRAMES = 32;
const MAX_CLIENT_QUEUED_BYTES = 256 * 1024;
export type RuntimeHostSubscriptionFailureReason =
| 'sequence_gap'
| 'host_epoch_changed'
| 'correlation_changed'
| 'projection_revision_invalid'
| 'slow_consumer'
| 'connection_closed'
| 'transcript_expired';
export class RuntimeHostSubscriptionError extends Error {
constructor(
readonly reason: RuntimeHostSubscriptionFailureReason,
message: string,
) {
super(message);
this.name = 'RuntimeHostSubscriptionError';
}
}
function bufferLength(chunks: readonly Buffer[]): number {
return chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export interface RuntimeHostSessionSubscription extends AsyncIterable<SubscriptionFrame> {
readonly hostEpoch: string;
readonly subscriptionId: string;
readonly snapshot: SessionContinuitySnapshot;
loadTranscript<T>(decodeMessage: (value: unknown) => T): Promise<T[]>;
close(): Promise<void>;
}
interface QueuedFrame {
frame: SubscriptionFrame;
encodedBytes: number;
}
export class ClientSessionSubscription
implements RuntimeHostSessionSubscription, AsyncIterator<SubscriptionFrame>
{
readonly hostEpoch: string;
readonly subscriptionId: string;
readonly snapshot: SessionContinuitySnapshot;
readonly #requestClose: () => Promise<void>;
readonly #queryTranscript: (
input: SessionTranscriptQueryInput,
) => Promise<SessionTranscriptQueryResult>;
readonly #expectedSessionId: string;
readonly #queue: QueuedFrame[] = [];
#queuedBytes = 0;
#expectedSequence: number;
#latestProjectionRevision: number;
#waiting:
| {
resolve(value: IteratorResult<SubscriptionFrame>): void;
reject(error: Error): void;
}
| undefined;
#terminalError: Error | undefined;
#done = false;
#doneAfterQueue = false;
#closeTask: Promise<void> | undefined;
#transcriptTask: Promise<unknown[]> | undefined;
constructor(
result: SubscriptionOpenResult,
requestClose: () => Promise<void>,
queryTranscript: (input: SessionTranscriptQueryInput) => Promise<SessionTranscriptQueryResult>,
) {
this.hostEpoch = result.hostEpoch;
this.subscriptionId = result.subscriptionId;
this.snapshot = result.snapshot;
this.#expectedSessionId = result.snapshot.session.sessionId;
this.#expectedSequence = result.nextSequence;
this.#latestProjectionRevision = result.snapshot.projectionRevision;
this.#requestClose = requestClose;
this.#queryTranscript = queryTranscript;
}
[Symbol.asyncIterator](): AsyncIterator<SubscriptionFrame> {
return this;
}
next(): Promise<IteratorResult<SubscriptionFrame>> {
const queued = this.#queue.shift();
if (queued) {
this.#queuedBytes -= queued.encodedBytes;
if (this.#queue.length === 0 && this.#doneAfterQueue) this.#done = true;
return Promise.resolve({ done: false, value: queued.frame });
}
if (this.#terminalError) return Promise.reject(this.#terminalError);
if (this.#done || this.#doneAfterQueue) {
this.#done = true;
return Promise.resolve({ done: true, value: undefined });
}
if (this.#waiting) {
return Promise.reject(new Error('Session subscription already has a pending iterator read'));
}
return new Promise((resolve, reject) => {
this.#waiting = { resolve, reject };
});
}
async return(): Promise<IteratorResult<SubscriptionFrame>> {
await this.close();
return { done: true, value: undefined };
}
close(): Promise<void> {
if (this.#done || this.#terminalError) return Promise.resolve();
if (!this.#closeTask) this.#closeTask = this.#requestClose();
return this.#closeTask;
}
loadTranscript<T>(decodeMessage: (value: unknown) => T): Promise<T[]> {
this.#transcriptTask ??= this.#loadTranscript().catch((error: unknown) => {
this.#transcriptTask = undefined;
throw error;
});
return this.#transcriptTask.then((messages) => messages.map(decodeMessage));
}
async #loadTranscript(): Promise<unknown[]> {
let result = await this.#queryTranscript({
kind: 'start',
subscriptionId: this.subscriptionId,
});
let snapshotId: string | undefined;
let messageCount: number | undefined;
let chunks: Buffer[] = [];
const messages: unknown[] = [];
while (true) {
if (result.kind === 'snapshot_expired') {
throw new RuntimeHostSubscriptionError(
'transcript_expired',
'Session transcript snapshot expired before it was consumed',
);
}
if (result.sessionId !== this.#expectedSessionId) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript belongs to a different Session',
);
}
snapshotId ??= result.snapshotId;
messageCount ??= result.messageCount;
if (result.snapshotId !== snapshotId || result.messageCount !== messageCount) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript snapshot identity changed',
);
}
if (result.messageCount === 0) return [];
if (result.messageIndex !== messages.length || result.byteOffset !== bufferLength(chunks)) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript chunk position changed',
);
}
chunks.push(Buffer.from(result.data, 'base64'));
if (result.next?.messageIndex !== result.messageIndex) {
const bytes = Buffer.concat(chunks);
let decoded: unknown;
try {
decoded = JSON.parse(bytes.toString('utf8')) as unknown;
} catch (cause) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
`Session transcript message is invalid JSON: ${errorMessage(cause)}`,
);
}
messages.push(decoded);
chunks = [];
}
if (!result.next) {
if (messages.length !== messageCount) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript ended before every message was received',
);
}
return messages;
}
result = await this.#queryTranscript({
kind: 'continue',
subscriptionId: this.subscriptionId,
snapshotId,
...result.next,
});
}
}
accept(frame: SubscriptionFrame): void {
if (this.#done || this.#terminalError) return;
if (this.#doneAfterQueue) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription received a frame after closure',
);
}
if (frame.hostEpoch !== this.hostEpoch) {
throw new RuntimeHostSubscriptionError(
'host_epoch_changed',
'Session subscription Host Epoch changed',
);
}
if (frame.subscriptionId !== this.subscriptionId) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription correlation changed',
);
}
if (frame.sequence !== this.#expectedSequence) {
throw new RuntimeHostSubscriptionError(
'sequence_gap',
`Session subscription expected sequence ${this.#expectedSequence} but received ${frame.sequence}`,
);
}
this.#expectedSequence += 1;
if (frame.kind === 'subscription.session_projection') {
if (frame.snapshot.session.sessionId !== this.#expectedSessionId) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription projection identity changed',
);
}
if (frame.snapshot.projectionRevision <= this.#latestProjectionRevision) {
throw new RuntimeHostSubscriptionError(
'projection_revision_invalid',
'Session projection revision did not advance',
);
}
this.#latestProjectionRevision = frame.snapshot.projectionRevision;
} else if (
(frame.kind === 'subscription.session_delta' ||
frame.kind === 'subscription.session_event' ||
frame.kind === 'subscription.session_domain_changed' ||
frame.kind === 'subscription.runtime_resource_pty_data') &&
frame.sessionId !== this.#expectedSessionId
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription frame identity changed',
);
} else if (
frame.kind === 'subscription.agent_graph_changed' &&
frame.rootSessionId !== this.#expectedSessionId
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription Agent graph identity changed',
);
}
this.#offer(frame);
if (frame.kind === 'subscription.closed') this.#doneAfterQueue = true;
}
finish(): void {
if (this.#done || this.#terminalError) return;
this.#doneAfterQueue = true;
if (this.#queue.length === 0) {
this.#done = true;
this.#waiting?.resolve({ done: true, value: undefined });
this.#waiting = undefined;
}
}
fail(error: Error): void {
if (this.#done || this.#terminalError) return;
this.#terminalError = error;
this.#queue.length = 0;
this.#queuedBytes = 0;
this.#waiting?.reject(error);
this.#waiting = undefined;
}
#offer(frame: SubscriptionFrame): void {
if (this.#waiting) {
const waiting = this.#waiting;
this.#waiting = undefined;
waiting.resolve({ done: false, value: frame });
return;
}
const encodedBytes = encodeProtocolFrame(frame).byteLength;
if (
this.#queue.length >= MAX_CLIENT_QUEUED_FRAMES ||
this.#queuedBytes + encodedBytes > MAX_CLIENT_QUEUED_BYTES
) {
throw new RuntimeHostSubscriptionError(
'slow_consumer',
'Session subscription consumer exceeded its local queue bound',
);
}
this.#queue.push({ frame, encodedBytes });
this.#queuedBytes += encodedBytes;
}
}