blob: d0d221932f2d05447489543033f2f029c5029b3e [file]
import { decodeStoredMessageForRead, type SessionEvent, type StoredMessage } from '@maka/core';
import {
RuntimeHostSessionProjector,
isRuntimeHostTerminalTurn as isTerminalTurn,
type RuntimeHostTerminalTurn as TerminalTurnSnapshot,
} from '@maka/runtime-host/adapter';
import type {
RuntimeHostConnection,
RuntimeHostSessionSubscription,
} from '@maka/runtime-host/client';
import type {
InteractionAnsweredSnapshot,
InteractionPendingSnapshot,
SessionContinuitySnapshot,
SubscriptionFrame,
} from '@maka/runtime-host/protocol';
import type { MakaPreparedSessionTurn } from './session-driver.js';
const MAX_PENDING_FRAMES = 512;
const MAX_PENDING_EVENTS_PER_TURN = 1_024;
export interface RuntimeHostSessionChannelOpenResult {
channel: RuntimeHostSessionChannel;
messages: StoredMessage[];
attachedTurnId?: string;
terminalTurn?: TerminalTurnSnapshot;
}
export interface RuntimeHostSessionChannelOptions {
connection: RuntimeHostConnection;
sessionId: string;
now: () => number;
onTurnStarted: (turn: MakaPreparedSessionTurn) => void;
onRuntimeResourceChanged: (sourceSessionId: string, ref: string) => void;
onInteractionPending: (pending: InteractionPendingSnapshot) => void;
onInteractionResolved: (pending: InteractionPendingSnapshot) => void;
onTurnTerminal: (turn: TerminalTurnSnapshot) => void;
}
export class RuntimeHostSessionChannel {
readonly sessionId: string;
readonly messages: StoredMessage[];
snapshot: SessionContinuitySnapshot;
readonly #subscription: RuntimeHostSessionSubscription;
readonly #now: () => number;
readonly #onTurnStarted: (turn: MakaPreparedSessionTurn) => void;
readonly #onRuntimeResourceChanged: (sourceSessionId: string, ref: string) => void;
readonly #onInteractionPending: (pending: InteractionPendingSnapshot) => void;
readonly #onInteractionResolved: (pending: InteractionPendingSnapshot) => void;
readonly #onTurnTerminal: (turn: TerminalTurnSnapshot) => void;
readonly #turns = new Map<string, SessionEventQueue>();
readonly #pendingFrames: SubscriptionFrame[] = [];
readonly #pendingStartedTurns = new Map<string, MakaPreparedSessionTurn>();
readonly #pendingOpenedInteractions: InteractionPendingSnapshot[] = [];
readonly #pendingResolvedInteractions: InteractionPendingSnapshot[] = [];
readonly #pendingTerminalTurns: TerminalTurnSnapshot[] = [];
#projector: RuntimeHostSessionProjector | undefined;
#ready = false;
#activated = false;
#startedTurnBarrier: string | undefined;
#closing = false;
#failure: Error | undefined;
private constructor(
subscription: RuntimeHostSessionSubscription,
messages: StoredMessage[],
options: Omit<RuntimeHostSessionChannelOptions, 'connection' | 'sessionId'>,
) {
this.#subscription = subscription;
this.sessionId = subscription.snapshot.session.sessionId;
this.snapshot = structuredClone(subscription.snapshot);
this.messages = messages;
this.#now = options.now;
this.#onTurnStarted = options.onTurnStarted;
this.#onRuntimeResourceChanged = options.onRuntimeResourceChanged;
this.#onInteractionPending = options.onInteractionPending;
this.#onInteractionResolved = options.onInteractionResolved;
this.#onTurnTerminal = options.onTurnTerminal;
}
static async open(
options: RuntimeHostSessionChannelOptions,
): Promise<RuntimeHostSessionChannelOpenResult> {
const subscription = await options.connection.openSessionSubscription({
sessionId: options.sessionId,
});
const initialRoot = structuredClone(subscription.snapshot.rootTurn);
const channel = new RuntimeHostSessionChannel(subscription, [], options);
void channel.#pump();
try {
const messages = await subscription.loadTranscript(decodeStoredMessageForRead);
channel.messages.push(...messages.map((message) => structuredClone(message)));
channel.#projector = new RuntimeHostSessionProjector(
channel.snapshot,
channel.messages,
options.now,
);
for (const event of channel.#projector.seedActive(false)) channel.#emit(event);
channel.#ready = true;
for (const frame of channel.#pendingFrames.splice(0)) channel.#accept(frame);
return {
channel,
messages: channel.messages.map((message) => structuredClone(message)),
...(initialRoot && !isTerminalTurn(initialRoot)
? { attachedTurnId: initialRoot.turnId }
: {}),
...(initialRoot && isTerminalTurn(initialRoot) ? { terminalTurn: initialRoot } : {}),
};
} catch (error) {
await channel.close().catch(() => undefined);
throw error;
}
}
async *eventsForTurn(turnId: string): AsyncIterable<SessionEvent> {
try {
yield* this.#queue(turnId);
} finally {
if (this.#startedTurnBarrier === turnId) {
this.#startedTurnBarrier = undefined;
if (!this.#closing) this.#flushStartedTurns();
}
}
}
get failed(): boolean {
return this.#failure !== undefined;
}
get firstObservedTurnId(): string | undefined {
return this.#pendingStartedTurns.keys().next().value;
}
activate(claimedTurnId?: string): void {
if (this.#closing || this.#activated) return;
this.#activated = true;
if (claimedTurnId) {
this.#pendingStartedTurns.delete(claimedTurnId);
this.#startedTurnBarrier = claimedTurnId;
} else {
this.#flushStartedTurns();
}
for (const interaction of this.snapshot.interactions.pending) {
this.#onInteractionPending(structuredClone(interaction));
}
for (const interaction of this.#pendingOpenedInteractions.splice(0)) {
this.#onInteractionPending(interaction);
}
for (const interaction of this.#pendingResolvedInteractions.splice(0)) {
this.#onInteractionResolved(interaction);
}
for (const turn of this.#pendingTerminalTurns.splice(0)) this.#onTurnTerminal(turn);
}
#flushStartedTurns(): void {
for (const turn of this.#pendingStartedTurns.values()) this.#onTurnStarted(turn);
this.#pendingStartedTurns.clear();
}
seedTerminalCut(turn: TerminalTurnSnapshot): void {
if (!this.#projector) return;
for (const event of this.#projector.seedTerminal(turn)) this.#emit(event);
this.#queue(turn.turnId).finish();
}
failTurn(turnId: string, error: unknown): void {
this.#queue(turnId).fail(error);
}
pendingInteraction(interactionId: string): InteractionPendingSnapshot | undefined {
return this.snapshot.interactions.pending.find(
(interaction) => interaction.interactionId === interactionId,
);
}
publishInteractionAnswer(
answered: InteractionAnsweredSnapshot,
pending: InteractionPendingSnapshot,
): void {
const base = {
id: `host-interaction:${answered.interactionId}:${answered.revision}`,
turnId: answered.turnId,
ts: this.#now(),
requestId: answered.interactionId,
toolUseId:
pending.request.kind === 'sandbox_boundary'
? pending.interactionId
: pending.request.toolUseId,
};
if (answered.outcome.kind === 'question_answer') {
this.#emit({ type: 'user_question_answer_ack', ...base });
} else if (answered.outcome.kind === 'sandbox_boundary_decision') {
this.#emit({
type: 'sandbox_boundary_decision_ack',
...base,
decision: answered.outcome.decision,
status: answered.outcome.status,
revision: answered.revision,
});
}
}
async close(): Promise<void> {
if (this.#closing) return;
this.#closing = true;
this.#pendingStartedTurns.clear();
for (const queue of this.#turns.values()) queue.finish();
await this.#subscription.close();
}
async #pump(): Promise<void> {
try {
for await (const frame of this.#subscription) {
if (this.#closing) return;
if (!this.#ready) {
if (this.#pendingFrames.length >= MAX_PENDING_FRAMES) {
throw new Error('Runtime Host transcript could not keep up with live Session events');
}
this.#pendingFrames.push(frame);
} else {
this.#accept(frame);
}
}
if (!this.#closing) throw new Error('Runtime Host Session subscription ended unexpectedly');
} catch (error) {
if (this.#closing) return;
this.#fail(error);
}
}
#accept(frame: SubscriptionFrame): void {
if (frame.kind === 'subscription.session_domain_changed') {
if (frame.domain === 'runtime_resource') {
for (const resource of frame.resources) {
this.#onRuntimeResourceChanged(resource.sourceSessionId, resource.ref);
}
}
return;
}
if (frame.kind === 'subscription.closed') {
this.#fail(new Error(`Runtime Host Session subscription closed: ${frame.reason}`));
return;
}
const previousPendingIds = new Set(
this.snapshot.interactions.pending.map((interaction) => interaction.interactionId),
);
const update = this.#projector?.accept(frame);
if (!update || !this.#projector) return;
this.snapshot = this.#projector.snapshot;
for (const interaction of this.snapshot.interactions.pending) {
if (previousPendingIds.has(interaction.interactionId)) continue;
const pending = structuredClone(interaction);
if (this.#activated) this.#onInteractionPending(pending);
else this.#pendingOpenedInteractions.push(pending);
}
for (const interaction of update.resolvedInteractions) {
if (this.#activated) this.#onInteractionResolved(interaction);
else this.#pendingResolvedInteractions.push(interaction);
}
for (const event of update.events) this.#emit(event);
if (update.startedTurn && !isTerminalTurn(update.startedTurn)) {
const turn = {
sessionId: this.sessionId,
turnId: update.startedTurn.turnId,
runId: update.startedTurn.runId,
events: this.eventsForTurn(update.startedTurn.turnId),
} satisfies MakaPreparedSessionTurn;
if (this.#activated && !this.#startedTurnBarrier) this.#onTurnStarted(turn);
else this.#pendingStartedTurns.set(turn.turnId, turn);
}
if (update.terminalTurn) {
this.#queue(update.terminalTurn.turnId).finish();
if (this.#activated) this.#onTurnTerminal(update.terminalTurn);
else this.#pendingTerminalTurns.push(update.terminalTurn);
}
}
#emit(event: SessionEvent): void {
this.#queue(event.turnId).push(event);
}
#queue(turnId: string): SessionEventQueue {
let queue = this.#turns.get(turnId);
if (!queue) {
queue = new SessionEventQueue();
this.#turns.set(turnId, queue);
if (this.#failure) queue.fail(this.#failure);
}
return queue;
}
#fail(error: unknown): void {
if (this.#failure) return;
this.#failure = error instanceof Error ? error : new Error(String(error));
for (const queue of this.#turns.values()) queue.fail(this.#failure);
}
}
class SessionEventQueue implements AsyncIterable<SessionEvent>, AsyncIterator<SessionEvent> {
readonly #items: SessionEvent[] = [];
#waiting:
| { resolve(value: IteratorResult<SessionEvent>): void; reject(error: unknown): void }
| undefined;
#done = false;
#finishAfterItems = false;
#error: unknown;
[Symbol.asyncIterator](): AsyncIterator<SessionEvent> {
return this;
}
next(): Promise<IteratorResult<SessionEvent>> {
const item = this.#items.shift();
if (item) return Promise.resolve({ done: false, value: item });
if (this.#error !== undefined) return Promise.reject(this.#error);
if (this.#done || this.#finishAfterItems) {
this.#done = true;
return Promise.resolve({ done: true, value: undefined });
}
if (this.#waiting)
return Promise.reject(new Error('Session event stream already has a reader'));
return new Promise((resolve, reject) => {
this.#waiting = { resolve, reject };
});
}
push(event: SessionEvent): void {
if (this.#done || this.#finishAfterItems || this.#error !== undefined) return;
if (this.#waiting) {
const waiting = this.#waiting;
this.#waiting = undefined;
waiting.resolve({ done: false, value: event });
return;
}
if (this.#items.length >= MAX_PENDING_EVENTS_PER_TURN) {
this.fail(new Error('Runtime Host Session event consumer is too slow'));
return;
}
this.#items.push(event);
}
finish(): void {
if (this.#done || this.#error !== undefined) return;
this.#finishAfterItems = true;
if (this.#items.length === 0) {
this.#done = true;
this.#waiting?.resolve({ done: true, value: undefined });
this.#waiting = undefined;
}
}
fail(error: unknown): void {
if (this.#done || this.#error !== undefined) return;
this.#error = error;
this.#items.length = 0;
this.#waiting?.reject(error);
this.#waiting = undefined;
}
}