blob: 7d2ae126e4e674b259eabeed36249231ab330b47 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHash } from 'node:crypto';
import {
encodeProtocolMessage,
type SessionAssistantStreamIdentity,
type SessionRuntimeResourcePtyDataFrame,
type SessionDomainChangedFrame,
type SessionContinuitySnapshot,
SESSION_TRANSCRIPT_PAGE_MAX_BYTES,
SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES,
type SubscriptionFrame,
type SubscriptionOpenResult,
type SessionTranscriptBootstrap,
type SessionTranscriptFragment,
type SessionTranscriptPage,
type SessionTranscriptPageInput,
} 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'
| 'transcript_release_failed'
| 'connection_closed';
export class RuntimeHostSubscriptionError extends Error {
constructor(
readonly reason: RuntimeHostSubscriptionFailureReason,
message: string,
options: ErrorOptions = {},
) {
super(message, options);
this.name = 'RuntimeHostSubscriptionError';
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export interface RuntimeHostSessionSubscription extends AsyncIterable<SubscriptionFrame> {
subscribePtyData(listener: (frame: SessionRuntimeResourcePtyDataFrame) => void): () => void;
subscribeSessionDomainChanges(listener: (frame: SessionDomainChangedFrame) => void): () => void;
readonly hostEpoch: string;
readonly subscriptionId: string;
readonly snapshot: SessionContinuitySnapshot;
readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[];
readonly transcriptBootstrap: SessionTranscriptBootstrap | null;
loadTranscript<T>(decodeMessage: (value: unknown) => T): Promise<T[]>;
loadTranscriptOverlay<T>(
decodeMessage: (value: unknown) => T,
maxMessageBytes?: number,
accountAssemblyBytes?: (deltaBytes: number) => void,
): Promise<T[]>;
decodeTranscriptPage<T>(
page: SessionTranscriptPage,
decodeMessage: (value: unknown) => T,
maxMessageBytes?: number,
accountAssemblyBytes?: (deltaBytes: number) => void,
): Promise<DecodedSessionTranscriptPage<T>>;
loadTranscriptPage(
input: Omit<SessionTranscriptPageInput, 'subscriptionId'>,
): Promise<SessionTranscriptPage>;
close(): Promise<void>;
}
export interface DecodedSessionTranscriptPage<T> {
readonly messages: readonly {
readonly identity: number;
readonly message: T;
}[];
readonly nextCursor: string | null;
}
interface QueuedFrame {
frame: SubscriptionFrame;
encodedBytes: number;
}
export class ClientSessionSubscription
implements RuntimeHostSessionSubscription, AsyncIterator<SubscriptionFrame>
{
readonly hostEpoch: string;
readonly subscriptionId: string;
readonly snapshot: SessionContinuitySnapshot;
readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[];
readonly transcriptBootstrap: SessionTranscriptBootstrap | null;
readonly #requestClose: () => Promise<void>;
readonly #readTranscriptPage: (
input: SessionTranscriptPageInput,
) => Promise<SessionTranscriptPage>;
readonly #releaseTranscriptOverlay: () => Promise<void>;
readonly #expectedSessionId: string;
readonly #queue: QueuedFrame[] = [];
readonly #ptyListeners = new Set<(frame: SessionRuntimeResourcePtyDataFrame) => void>();
readonly #sessionDomainListeners = new Set<(frame: SessionDomainChangedFrame) => void>();
#queuedBytes = 0;
#expectedSequence: number;
#latestProjectionRevision: number;
#waiting:
| {
resolve(value: IteratorResult<SubscriptionFrame>): void;
reject(error: Error): void;
}
| undefined;
#terminalError: Error | undefined;
#done = false;
#doneAfterQueue = false;
#closing = false;
#closeTask: Promise<void> | undefined;
#transcriptTask: Promise<unknown[]> | undefined;
#overlayTask: Promise<Array<{ identity: number; value: unknown }>> | undefined;
#overlayConsumed = false;
#latestTranscriptThroughSequence: number | null;
constructor(
result: SubscriptionOpenResult,
requestClose: () => Promise<void>,
readTranscriptPage: (input: SessionTranscriptPageInput) => Promise<SessionTranscriptPage>,
releaseTranscriptOverlay: () => Promise<void> = async () => undefined,
) {
this.hostEpoch = result.hostEpoch;
this.subscriptionId = result.subscriptionId;
this.snapshot = result.snapshot;
this.activeAssistantStreams = result.activeAssistantStreams;
this.transcriptBootstrap = result.transcript;
this.#expectedSessionId = result.snapshot.session.sessionId;
this.#expectedSequence = result.nextSequence;
this.#latestProjectionRevision = result.snapshot.projectionRevision;
this.#latestTranscriptThroughSequence = result.transcript?.throughSequence ?? null;
this.#requestClose = requestClose;
this.#readTranscriptPage = readTranscriptPage;
this.#releaseTranscriptOverlay = releaseTranscriptOverlay;
}
[Symbol.asyncIterator](): AsyncIterator<SubscriptionFrame> {
return this;
}
subscribePtyData(listener: (frame: SessionRuntimeResourcePtyDataFrame) => void): () => void {
if (this.#done || this.#terminalError || this.#closing) return () => undefined;
this.#ptyListeners.add(listener);
return () => this.#ptyListeners.delete(listener);
}
subscribeSessionDomainChanges(listener: (frame: SessionDomainChangedFrame) => void): () => void {
if (this.#done || this.#terminalError || this.#closing) return () => undefined;
this.#sessionDomainListeners.add(listener);
return () => this.#sessionDomainListeners.delete(listener);
}
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();
this.#closing = true;
this.#ptyListeners.clear();
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));
}
loadTranscriptOverlay<T>(
decodeMessage: (value: unknown) => T,
maxMessageBytes = Number.MAX_SAFE_INTEGER,
accountAssemblyBytes: (deltaBytes: number) => void = () => undefined,
): Promise<T[]> {
this.#assertTranscriptReadable();
const bootstrap = this.transcriptBootstrap;
if (!bootstrap) {
return Promise.reject(
new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription was opened without transcript access',
),
);
}
return this.#consumeTranscriptOverlay(bootstrap, maxMessageBytes, accountAssemblyBytes).then(
(messages) => messages.map((entry) => decodeMessage(entry.value)),
);
}
async decodeTranscriptPage<T>(
page: SessionTranscriptPage,
decodeMessage: (value: unknown) => T,
maxMessageBytes = Number.MAX_SAFE_INTEGER,
accountAssemblyBytes: (deltaBytes: number) => void = () => undefined,
): Promise<DecodedSessionTranscriptPage<T>> {
this.#assertTranscriptReadable();
this.#assertTranscriptPage(page, {
source: page.source,
direction: page.direction,
throughSequence: page.throughSequence,
maxBytes: Math.max(1, page.rawBytes),
});
const assembler = new TranscriptFragmentAssembler(
page.source,
page.direction,
maxMessageBytes,
accountAssemblyBytes,
);
try {
assembler.accept(page.fragments);
let cursor = page.nextCursor;
let rangeBytes = page.fragments.reduce((total, fragment) => total + fragment.totalBytes, 0);
const rangeIdentities = new Set(
page.fragments.map((fragment) =>
fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex,
),
);
let reachedBoundary =
page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence);
while (assembler.continuationBytes !== null || !reachedBoundary) {
if (cursor === null) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript message ended before every fragment arrived',
);
}
const requestedCursor = cursor;
const continuation = await this.loadTranscriptPage({
source: page.source,
direction: page.direction,
throughSequence: page.throughSequence,
cursor,
anchorSequence: null,
maxBytes:
assembler.continuationBytes === null
? SESSION_TRANSCRIPT_PAGE_MAX_BYTES
: Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes),
});
if (continuation.nextCursor === requestedCursor) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript cursor did not advance',
);
}
for (const fragment of continuation.fragments) {
const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex;
if (!rangeIdentities.has(identity)) {
rangeIdentities.add(identity);
rangeBytes += fragment.totalBytes;
}
}
if (
rangeBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES ||
rangeIdentities.size > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES
) {
throw new RangeError('Session transcript range exceeds the local capacity limit');
}
assembler.accept(continuation.fragments);
reachedBoundary =
page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence);
cursor = continuation.nextCursor;
}
return {
messages: assembler.finish().map((entry) => ({
identity: entry.identity,
message: decodeMessage(entry.value),
})),
nextCursor: cursor,
};
} finally {
assembler.release();
}
}
loadTranscriptPage(
input: Omit<SessionTranscriptPageInput, 'subscriptionId'>,
): Promise<SessionTranscriptPage> {
this.#assertTranscriptReadable();
if (!this.transcriptBootstrap) {
return Promise.reject(
new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription was opened without transcript access',
),
);
}
if (
input.throughSequence !== null &&
(this.#latestTranscriptThroughSequence === null ||
input.throughSequence > this.#latestTranscriptThroughSequence)
) {
return Promise.reject(
new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript watermark has not been announced',
),
);
}
return this.#readTranscriptPage({
subscriptionId: this.subscriptionId,
...input,
}).then((page) => {
this.#assertTranscriptReadable();
this.#assertTranscriptPage(page, input);
return page;
});
}
async #loadTranscript(): Promise<unknown[]> {
this.#assertTranscriptReadable();
const bootstrap = this.transcriptBootstrap;
if (!bootstrap) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription was opened without transcript access',
);
}
const overlay = await this.#consumeTranscriptOverlay(bootstrap);
const durable = await this.#loadTranscriptSource(bootstrap.durable);
const messages = durable.map((entry) => entry.value);
const indexById = new Map<string, number>();
for (const [index, message] of messages.entries()) {
const id = messageIdentity(message);
if (id) indexById.set(id, index);
}
for (const entry of overlay) {
const id = messageIdentity(entry.value);
const index = id ? indexById.get(id) : undefined;
if (index === undefined) {
if (id) indexById.set(id, messages.length);
messages.push(entry.value);
} else {
messages[index] = entry.value;
}
}
return messages;
}
#consumeTranscriptOverlay(
bootstrap: SessionTranscriptBootstrap,
maxMessageBytes = Number.MAX_SAFE_INTEGER,
accountAssemblyBytes: (deltaBytes: number) => void = () => undefined,
): Promise<Array<{ identity: number; value: unknown }>> {
if (this.#overlayConsumed && !this.#overlayTask) {
return Promise.reject(
new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript overlay was already consumed',
),
);
}
this.#overlayTask ??= (async () => {
const overlay = await this.#loadTranscriptSource(
bootstrap.overlay,
maxMessageBytes,
accountAssemblyBytes,
);
assertCompleteIdentities(
overlay,
bootstrap.overlayMessageCount === 0 ? null : bootstrap.overlayMessageCount - 1,
);
if (bootstrap.overlayMessageCount === 0) {
this.#overlayConsumed = true;
return overlay;
}
try {
await this.#releaseTranscriptOverlay();
} catch (cause) {
await this.close().catch(() => undefined);
throw new RuntimeHostSubscriptionError(
'transcript_release_failed',
'Runtime Host Session transcript overlay release was not confirmed',
{ cause },
);
}
this.#overlayConsumed = true;
return overlay;
})();
const task = this.#overlayTask;
return task.finally(() => {
if (this.#overlayTask === task) this.#overlayTask = undefined;
});
}
async #loadTranscriptSource(
initial: SessionTranscriptPage,
maxMessageBytes = Number.MAX_SAFE_INTEGER,
accountAssemblyBytes: (deltaBytes: number) => void = () => undefined,
): Promise<Array<{ identity: number; value: unknown }>> {
this.#assertTranscriptPage(initial, {
source: initial.source,
direction: initial.direction,
throughSequence: initial.throughSequence,
maxBytes: Math.max(1, initial.rawBytes),
});
const assembler = new TranscriptFragmentAssembler(
initial.source,
initial.direction,
maxMessageBytes,
accountAssemblyBytes,
);
try {
assembler.accept(initial.fragments);
let cursor = initial.nextCursor;
while (cursor !== null) {
const page = await this.loadTranscriptPage({
source: initial.source,
direction: initial.direction,
throughSequence: initial.throughSequence,
cursor,
anchorSequence: null,
maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES,
});
if (page.nextCursor === cursor) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript cursor did not advance',
);
}
assembler.accept(page.fragments);
cursor = page.nextCursor;
}
return assembler.finish();
} finally {
assembler.release();
}
}
#assertTranscriptReadable(): void {
if (this.#closing || this.#done || this.#terminalError) {
throw new RuntimeHostSubscriptionError(
'connection_closed',
'Session subscription closed during transcript loading',
);
}
}
#assertTranscriptPage(
page: SessionTranscriptPage,
expected: Pick<
SessionTranscriptPageInput,
'source' | 'direction' | 'throughSequence' | 'maxBytes'
>,
): void {
if (
page.sessionId !== this.#expectedSessionId ||
page.source !== expected.source ||
page.direction !== expected.direction ||
page.throughSequence !== expected.throughSequence ||
page.rawBytes > expected.maxBytes
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript page correlation changed',
);
}
}
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.kind === 'subscription.runtime_resource_pty_data') {
if (frame.sessionId !== this.#expectedSessionId) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'PTY Session identity changed',
);
}
// No iterator backlog when nobody is displaying a terminal. Attaching
// consumers hydrate from a snapshot, including bytes before attachment.
for (const listener of this.#ptyListeners) {
try {
listener(frame);
} catch {
/* A display consumer cannot terminate Session state. */
}
}
return;
}
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.transcript_advanced' ||
frame.kind === 'subscription.session_domain_changed') &&
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',
);
}
if (frame.kind === 'subscription.transcript_advanced') {
if (
this.#latestTranscriptThroughSequence !== null &&
frame.throughSequence <= this.#latestTranscriptThroughSequence
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript watermark did not advance',
);
}
this.#latestTranscriptThroughSequence = frame.throughSequence;
}
if (frame.kind === 'subscription.session_domain_changed') {
for (const listener of this.#sessionDomainListeners) {
try {
listener(frame);
} catch {
/* An invalidation consumer cannot terminate Session state. */
}
}
}
this.#offer(frame);
if (frame.kind === 'subscription.closed') this.#doneAfterQueue = true;
}
finish(): void {
this.#ptyListeners.clear();
this.#sessionDomainListeners.clear();
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 {
this.#ptyListeners.clear();
this.#sessionDomainListeners.clear();
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 = encodeProtocolMessage(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;
}
}
class TranscriptFragmentAssembler {
readonly #messages: Array<{ identity: number; value: unknown }> = [];
#assemblyBytes = 0;
#current:
| {
identity: number;
totalBytes: number;
payloadDigest: `sha256:${string}` | null;
data: Buffer;
edge: number;
}
| undefined;
#lastStartedIdentity: number | undefined;
constructor(
private readonly source: 'durable' | 'overlay',
private readonly direction: 'older' | 'newer',
private readonly maxMessageBytes = Number.MAX_SAFE_INTEGER,
private readonly accountAssemblyBytes: (deltaBytes: number) => void = () => undefined,
) {}
accept(fragments: readonly SessionTranscriptFragment[]): void {
for (const fragment of fragments) this.#accept(fragment);
}
get continuationBytes(): number | null {
const current = this.#current;
if (!current) return null;
return this.direction === 'older' ? current.edge : current.totalBytes - current.edge;
}
finish(): Array<{ identity: number; value: unknown }> {
if (this.#current) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript message ended before every fragment arrived',
);
}
if (this.direction === 'older') this.#messages.reverse();
return this.#messages;
}
release(): void {
if (this.#assemblyBytes === 0) return;
this.accountAssemblyBytes(-this.#assemblyBytes);
this.#assemblyBytes = 0;
}
#accept(fragment: SessionTranscriptFragment): void {
if (fragment.kind !== this.source) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript fragment source changed',
);
}
const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex;
const bytes = Buffer.from(fragment.data, 'base64');
const payloadDigest = fragment.kind === 'durable' ? fragment.payloadDigest : null;
if (!this.#current) this.#start(identity, fragment.totalBytes, payloadDigest);
if (
this.#current?.identity !== identity ||
this.#current.totalBytes !== fragment.totalBytes ||
this.#current.payloadDigest !== payloadDigest
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript message identity changed between fragments',
);
}
const expectedOffset =
this.direction === 'older' ? this.#current.edge - bytes.byteLength : this.#current.edge;
if (fragment.byteOffset !== expectedOffset) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript message has a fragment gap',
);
}
if (fragment.byteOffset + bytes.byteLength > this.#current.totalBytes) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript fragment exceeds its declared message size',
);
}
bytes.copy(this.#current.data, fragment.byteOffset);
this.#current.edge =
this.direction === 'older' ? fragment.byteOffset : fragment.byteOffset + bytes.byteLength;
if (
(this.direction === 'older' && this.#current.edge === 0) ||
(this.direction === 'newer' && this.#current.edge === fragment.totalBytes)
) {
this.#completeCurrent();
}
}
#start(identity: number, totalBytes: number, payloadDigest: `sha256:${string}` | null): void {
if (totalBytes > this.maxMessageBytes) {
throw new RangeError('Session transcript message exceeds the local byte limit');
}
if (
this.#lastStartedIdentity !== undefined &&
(this.direction === 'older'
? identity >= this.#lastStartedIdentity
: identity <= this.#lastStartedIdentity)
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript message order changed between pages',
);
}
this.#lastStartedIdentity = identity;
this.accountAssemblyBytes(totalBytes);
try {
this.#current = {
identity,
totalBytes,
payloadDigest,
data: Buffer.allocUnsafe(totalBytes),
edge: this.direction === 'older' ? totalBytes : 0,
};
this.#assemblyBytes += totalBytes;
} catch (error) {
this.accountAssemblyBytes(-totalBytes);
throw error;
}
}
#completeCurrent(): void {
const current = this.#current!;
try {
if (
current.payloadDigest !== null &&
`sha256:${createHash('sha256').update(current.data).digest('hex')}` !==
current.payloadDigest
) {
throw new Error('payload digest mismatch');
}
this.#messages.push({
identity: current.identity,
value: JSON.parse(current.data.toString('utf8')) as unknown,
});
} catch (cause) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
`Session transcript message failed integrity validation: ${errorMessage(cause)}`,
);
}
this.#current = undefined;
}
}
function assertCompleteIdentities(
messages: readonly { identity: number }[],
throughIdentity: number | null,
): void {
if (throughIdentity === null) {
if (messages.length !== 0) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript contains messages without a watermark',
);
}
return;
}
if (
messages.length !== throughIdentity + 1 ||
messages.some((message, index) => message.identity !== index)
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session transcript has a message sequence gap',
);
}
}
function messageIdentity(value: unknown): string | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
const id = (value as Record<string, unknown>).id;
return typeof id === 'string' ? id : undefined;
}