| /* |
| * 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 type { RuntimeEvent } from '@maka/core/runtime-event'; |
| import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection'; |
| import { readRunInvocation } from '@maka/core/runtime-event-store'; |
| import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; |
| import { runtimeHandoffPause } from '@maka/core/runtime-handoff'; |
| import { readLogicalRuntimeExecution } from '@maka/core/runtime-logical-execution'; |
| import { WORKHUB_COORDINATION_SESSION_ID, type StoredMessage } from '@maka/core/session'; |
| import { |
| createRuntimeEventStoredMessageProjector, |
| projectTranscriptToolResult, |
| isHardRuntimeEventReadModelDiagnostic, |
| projectRuntimeEventUserMessage, |
| } from '@maka/runtime/runtime-event-read-model'; |
| import { |
| type CanonicalPermissionOutcomeReader, |
| type CanonicalPermissionOutcomeRecord, |
| } from '@maka/runtime/interaction-authority'; |
| import type { |
| ExecutionStoresWriter, |
| SessionTranscriptMessageLookupRequest, |
| SessionTranscriptPageRequest, |
| SessionTranscriptRecordScanPage, |
| SessionTranscriptRecordScanRequest, |
| SessionTranscriptStorageFragment, |
| SessionTranscriptStoragePage, |
| SessionTurnContribution, |
| SessionTurnContributionPage, |
| SessionTurnLandmark, |
| SessionTurnLandmarkSnapshot, |
| RuntimeTranscriptInvocationHeader, |
| } from '@maka/storage/execution-stores'; |
| import { foldTurnContribution } from '@maka/storage/session-message-projection'; |
| import { SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES, type TurnSnapshot } from '../protocol/index.js'; |
| |
| const PERMISSION_OUTCOME_READ_CONCURRENCY = 8; |
| /** One event can emit content, a permission, usage, and terminal/notice rows. */ |
| const EVENT_SEQUENCE_STRIDE = 8; |
| export const ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES = SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES; |
| export const ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES = 16 * 1024 * 1024; |
| const TRANSCRIPT_SOURCE_MAX_EVENTS = ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES * 2; |
| // One RuntimeEvent can carry both the raw Tool Result and its durable model projection. |
| const TRANSCRIPT_SOURCE_MAX_RECORD_BYTES = |
| DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES * 2 + 256 * 1024; |
| // The scan releases each decoded record after projection. Its memory budget is |
| // separate from the bounded amount of immutable input it may visit. |
| const TRANSCRIPT_SOURCE_MAX_BYTES = |
| TRANSCRIPT_SOURCE_MAX_EVENTS * TRANSCRIPT_SOURCE_MAX_RECORD_BYTES; |
| const ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES = 256 * 1024; |
| /** Turns per storage round trip: one, so a page loads no Turn it cannot use. */ |
| const TRANSCRIPT_TURN_SCAN_LIMIT = 1; |
| /** |
| * How far back the live-to-durable handoff looks for a message id. The ids come |
| * from assistant streams the subscriber is still watching, so they are in the |
| * newest Turn or the one it continued from; an id that is in neither is treated |
| * as absent rather than searched for down the Session. |
| */ |
| const TRANSCRIPT_LOOKUP_MAX_TURNS = 2; |
| |
| export function createSessionTranscriptReader(input: { |
| stores: ExecutionStoresWriter<'interactive'>; |
| canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader; |
| /** |
| * Converts a Session whose transcript predates the ledger, before this reader |
| * looks for invocations that only the conversion can create. Omitted only by |
| * tests that seed the ledger themselves. |
| */ |
| ensureTranscriptLedger?: (sessionId: string) => Promise<void>; |
| }): SessionTranscriptReader { |
| const ledger = createDurableLedgerTranscriptReader(input); |
| const prepared = async (sessionId: string): Promise<typeof ledger> => { |
| await input.ensureTranscriptLedger?.(sessionId); |
| return ledger; |
| }; |
| return { |
| readDurableHighWater: async (sessionId) => (await prepared(sessionId)).readHighWater(sessionId), |
| readDurablePage: async (sessionId, request) => |
| (await prepared(sessionId)).readPage(sessionId, request), |
| readDurableRecords: async (sessionId, request) => |
| (await prepared(sessionId)).readRecords(sessionId, request), |
| readDurableMessagesById: async (sessionId, request) => |
| (await prepared(sessionId)).readMessagesById(sessionId, request), |
| readDurableTurnContributions: async (sessionId, throughSequence, position, maxContributions) => |
| (await prepared(sessionId)).readTurnContributions( |
| sessionId, |
| throughSequence, |
| position, |
| maxContributions, |
| ), |
| readDurableTurnLandmarks: async (sessionId, maxLandmarks) => |
| (await prepared(sessionId)).readTurnLandmarks(sessionId, maxLandmarks), |
| readActiveOverlay: async (sessionId, rootTurn) => { |
| if (!rootTurn || isTerminalTurn(rootTurn)) return []; |
| |
| const store = input.stores.runtimeEventStore; |
| const root = await readRunInvocation(store, sessionId, rootTurn.runId); |
| if (!root) return []; |
| const invocations = new Map<string, RuntimeInvocationRecord>([[root.runId, root]]); |
| let runIds: readonly string[] = [root.runId]; |
| if (root.terminalEvent && runtimeHandoffPause(root.terminalEvent)) { |
| const logical = await readLogicalRuntimeExecution( |
| { |
| ...store, |
| readRunInvocation: async (id, runId) => { |
| const run = await readRunInvocation(store, id, runId); |
| if (run) invocations.set(runId, run); |
| return run; |
| }, |
| readImmutableRuntimePrefixProof: (prefix) => |
| store.readImmutableRuntimePrefixProof(prefix, { |
| maxEvents: TRANSCRIPT_SOURCE_MAX_EVENTS, |
| maxBytes: TRANSCRIPT_SOURCE_MAX_BYTES, |
| maxRecordBytes: TRANSCRIPT_SOURCE_MAX_RECORD_BYTES, |
| }), |
| }, |
| { sessionId, turnId: rootTurn.turnId, runId: rootTurn.runId }, |
| root, |
| { mode: 'membership' }, |
| ); |
| if (!logical) return []; |
| runIds = logical.runIds; |
| } |
| const pending = createTranscriptProjection( |
| runIds.map((runId) => invocations.get(runId)!), |
| true, |
| ); |
| for (const runId of runIds) |
| await scanActiveRuntimeEvents(input.stores, sessionId, runId, pending.push); |
| const projected = await pending.finish(input.canonicalPermissionOutcomes); |
| if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { |
| throw new Error('Active RuntimeEvent transcript projection is incomplete'); |
| } |
| assertActiveOverlayBounded(projected.messages); |
| return projected.messages; |
| }, |
| }; |
| } |
| |
| export interface SessionTranscriptReader { |
| readDurableHighWater(sessionId: string): Promise<number | null>; |
| readDurablePage( |
| sessionId: string, |
| request: SessionTranscriptPageRequest, |
| ): Promise<SessionTranscriptStoragePage>; |
| readDurableRecords( |
| sessionId: string, |
| request: SessionTranscriptRecordScanRequest, |
| ): Promise<SessionTranscriptRecordScanPage>; |
| readDurableMessagesById( |
| sessionId: string, |
| request: SessionTranscriptMessageLookupRequest, |
| ): Promise<readonly StoredMessage[]>; |
| readDurableTurnContributions( |
| sessionId: string, |
| throughSequence: number | null, |
| position: number, |
| maxContributions: number, |
| ): Promise<SessionTurnContributionPage>; |
| readDurableTurnLandmarks( |
| sessionId: string, |
| maxLandmarks: number, |
| ): Promise<SessionTurnLandmarkSnapshot>; |
| readActiveOverlay( |
| sessionId: string, |
| rootTurn: TurnSnapshot | null, |
| ): Promise<readonly StoredMessage[]>; |
| } |
| |
| /** |
| * Pages seek immutable Session event ordinals before decoding payloads. One |
| * Turn is projected at a time, so a page costs one Turn rather than the |
| * Session. The low sequence bits distinguish the few rows one event emits. |
| * |
| * What an event becomes is asked only of the read model. Storage selects Turns |
| * by ordinal and hands over their events; it never classifies one. |
| */ |
| function createDurableLedgerTranscriptReader(input: { |
| stores: ExecutionStoresWriter<'interactive'>; |
| canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader; |
| }) { |
| const store = input.stores.runtimeEventStore; |
| const highWater = async (sessionId: string): Promise<number | null> => { |
| const ordinal = await store.readTranscriptHighWater(sessionId); |
| return ordinal === null ? null : ordinal * EVENT_SEQUENCE_STRIDE + EVENT_SEQUENCE_STRIDE - 1; |
| }; |
| |
| /** One Turn's rows, each at the sequence its own event sits at. */ |
| const projectTurn = async ( |
| turn: PendingTranscriptTurn, |
| ): Promise<{ sequence: number; message: StoredMessage }[]> => { |
| const projected = await turn.projection.finish(input.canonicalPermissionOutcomes); |
| if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { |
| throw new Error('Durable RuntimeEvent transcript projection is incomplete'); |
| } |
| const admission = |
| turn.invocation.sessionId === WORKHUB_COORDINATION_SESSION_ID |
| ? await input.stores.agentRunStore.readRootTurnAdmission( |
| turn.invocation.sessionId, |
| turn.invocation.turnId, |
| ) |
| : undefined; |
| const actionId = |
| admission?.execution.kind === 'workhub_coordination' |
| ? admission.execution.actionId |
| : undefined; |
| const ordinals = turn.ordinals; |
| const emitted = new Map<number, number>(); |
| return projected.messages.map((message, index) => { |
| const ordinal = ordinals.get(projected.sourceEventIds[index]!); |
| if (ordinal === undefined) { |
| throw new Error('Durable transcript message has no source RuntimeEvent'); |
| } |
| const offset = emitted.get(ordinal) ?? 0; |
| if (offset >= EVENT_SEQUENCE_STRIDE) { |
| throw new Error('RuntimeEvent exceeds its transcript sequence stride'); |
| } |
| emitted.set(ordinal, offset + 1); |
| return { |
| sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, |
| message: |
| message.type === 'user' && actionId |
| ? { ...message, coordinationActionId: actionId } |
| : message, |
| }; |
| }); |
| }; |
| |
| const readTurns = async ( |
| sessionId: string, |
| request: { direction: 'older' | 'newer'; throughOrdinal: number; position: number }, |
| limit = TRANSCRIPT_TURN_SCAN_LIMIT, |
| ): Promise<PendingTranscriptTurn[]> => |
| store.readTranscriptInvocations( |
| sessionId, |
| { |
| ...request, |
| limit, |
| maxEvents: TRANSCRIPT_SOURCE_MAX_EVENTS, |
| maxBytes: TRANSCRIPT_SOURCE_MAX_BYTES, |
| maxRecordBytes: TRANSCRIPT_SOURCE_MAX_RECORD_BYTES, |
| }, |
| (turn, events) => { |
| const projection = createTranscriptProjection([turn.invocation]); |
| const ordinals = new Map<string, number>(); |
| for (const { event, ordinal } of events) { |
| ordinals.set(event.id, ordinal); |
| projection.push(event); |
| } |
| return { ...turn, projection, ordinals }; |
| }, |
| ); |
| |
| const scan = async function* ( |
| sessionId: string, |
| request: { |
| direction: 'older' | 'newer'; |
| throughSequence?: number | null; |
| position?: number; |
| /** Stops the walk after this many Turns, for a read that may find nothing. */ |
| maxTurns?: number; |
| }, |
| ): AsyncGenerator<{ sequence: number; message: StoredMessage }> { |
| const throughSequence = |
| request.throughSequence === undefined ? await highWater(sessionId) : request.throughSequence; |
| if (throughSequence === null) return; |
| const position = request.position ?? (request.direction === 'older' ? throughSequence : 0); |
| const throughOrdinal = ordinalOf(throughSequence); |
| const older = request.direction === 'older'; |
| const readTurnAt = async (at: number): Promise<PendingTranscriptTurn | undefined> => |
| at < 0 || at > throughOrdinal |
| ? undefined |
| : ( |
| await readTurns(sessionId, { |
| direction: request.direction, |
| throughOrdinal, |
| position: at, |
| }) |
| )[0]; |
| let ordinal = ordinalOf(position); |
| let walked = 0; |
| let carried: PendingTranscriptTurn | undefined; |
| while (ordinal >= 0 && ordinal <= throughOrdinal) { |
| const first = carried ?? (await readTurnAt(ordinal)); |
| carried = undefined; |
| if (first === undefined) return; |
| if (request.maxTurns !== undefined && walked >= request.maxTurns) return; |
| // A page resumes from one record's sequence and drops everything the other |
| // side of it, so what this yields has to be monotone in sequence. Turns |
| // whose ordinal ranges overlap — a nested run inside its parent — are |
| // therefore drained together instead of one after the other. |
| const cluster = [first]; |
| let low = first.firstOrdinal; |
| let high = first.lastOrdinal; |
| for (;;) { |
| const next = await readTurnAt(older ? low - 1 : high + 1); |
| if (next === undefined) break; |
| if (older ? next.lastOrdinal < low : next.firstOrdinal > high) { |
| carried = next; |
| break; |
| } |
| cluster.push(next); |
| low = Math.min(low, next.firstOrdinal); |
| high = Math.max(high, next.lastOrdinal); |
| } |
| walked += cluster.length; |
| const records = (await Promise.all(cluster.map(projectTurn))) |
| .flat() |
| .filter( |
| ({ sequence }) => |
| sequence <= throughSequence && (older ? sequence <= position : sequence >= position), |
| ) |
| .sort((a, b) => (older ? b.sequence - a.sequence : a.sequence - b.sequence)); |
| yield* records; |
| ordinal = older ? low - 1 : high + 1; |
| } |
| }; |
| |
| const source: TranscriptRecordSource = { readHighWater: highWater, scan }; |
| return { |
| source, |
| readHighWater: highWater, |
| |
| ...pagedTranscriptReads(source), |
| |
| /** One row per Turn, folded from the Turn's own projected messages. */ |
| async readTurnContributions( |
| sessionId: string, |
| throughSequence: number | null, |
| position: number, |
| maxContributions: number, |
| ): Promise<SessionTurnContributionPage> { |
| const watermark = throughSequence ?? (await highWater(sessionId)); |
| if (watermark === null) { |
| return { throughSequence: null, contributions: [], nextPosition: null }; |
| } |
| const turns = await readTurns( |
| sessionId, |
| { |
| direction: 'newer', |
| throughOrdinal: ordinalOf(watermark), |
| position: ordinalOf(position), |
| }, |
| maxContributions + 1, |
| ); |
| const contributions: SessionTurnContribution[] = []; |
| for (const turn of turns.slice(0, maxContributions)) { |
| // Folded from the Turn's own rows, so `firstSequence` lands on its first |
| // row rather than on the opening fact, which has no row at all. |
| let contribution: SessionTurnContribution | undefined; |
| for (const { sequence, message } of await projectTurn(turn)) { |
| if (sequence < position || sequence > watermark) continue; |
| contribution = foldTurnContribution( |
| contribution, |
| turn.invocation.turnId, |
| sequence, |
| message, |
| ); |
| } |
| if (contribution) contributions.push(contribution); |
| } |
| const next = turns[maxContributions]; |
| return { |
| throughSequence: watermark, |
| contributions, |
| nextPosition: next ? next.firstOrdinal * EVENT_SEQUENCE_STRIDE : null, |
| }; |
| }, |
| |
| /** Evenly spaced Turn starts, selected in SQL before loading their prompts. */ |
| async readTurnLandmarks( |
| sessionId: string, |
| maxLandmarks: number, |
| ): Promise<SessionTurnLandmarkSnapshot> { |
| const throughSequence = await highWater(sessionId); |
| if (throughSequence === null) return { throughSequence: null, landmarks: [] }; |
| const turns = await store.readTranscriptLandmarks( |
| sessionId, |
| ordinalOf(throughSequence), |
| maxLandmarks, |
| ); |
| const landmarks: SessionTurnLandmark[] = []; |
| for (const turn of turns) { |
| if (!turn.prompt) continue; |
| const message = projectRuntimeEventUserMessage(turn.prompt.event, turn.prompt.event.id); |
| const label = (message?.displayText ?? message?.text ?? '').trim(); |
| if (!label) continue; |
| landmarks.push({ |
| turnId: turn.invocation.turnId, |
| sequence: turn.prompt.ordinal * EVENT_SEQUENCE_STRIDE, |
| label, |
| }); |
| } |
| return { throughSequence, landmarks }; |
| }, |
| }; |
| } |
| |
| /** An ordered, bounded walk over one Session's transcript records. */ |
| interface TranscriptRecordSource { |
| readHighWater(sessionId: string): Promise<number | null>; |
| scan( |
| sessionId: string, |
| request: { |
| direction: 'older' | 'newer'; |
| throughSequence?: number | null; |
| position?: number; |
| /** Stops the walk after this many Turns, for a read that may find nothing. */ |
| maxTurns?: number; |
| }, |
| ): AsyncGenerator<{ sequence: number; message: StoredMessage }>; |
| } |
| |
| /** |
| * The reads that are the same whatever produces the records: a byte-bounded |
| * page, a record scan, and a lookup by message id. Each walks one source's |
| * ordered records and never asks where they came from. |
| */ |
| function pagedTranscriptReads(source: TranscriptRecordSource) { |
| return { |
| async readPage( |
| sessionId: string, |
| request: SessionTranscriptPageRequest, |
| ): Promise<SessionTranscriptStoragePage> { |
| const throughSequence = |
| request.throughSequence === undefined |
| ? await source.readHighWater(sessionId) |
| : request.throughSequence; |
| if (throughSequence === null) { |
| return { throughSequence: null, fragments: [], rawBytes: 0, next: null }; |
| } |
| const fragments: SessionTranscriptStorageFragment[] = []; |
| let rawBytes = 0; |
| let next: SessionTranscriptStoragePage['next'] = null; |
| let truncated = false; |
| for await (const record of source.scan(sessionId, { ...request, throughSequence })) { |
| if (fragments.length >= request.maxMessages || rawBytes >= request.maxBytes) { |
| truncated = true; |
| next = { position: record.sequence, byteOffset: null }; |
| break; |
| } |
| const data = Buffer.from(JSON.stringify(record.message), 'utf8'); |
| // A message larger than the remaining budget is served in byte slices, |
| // from the edge the traversal is moving away from, so the next page |
| // resumes inside the same record instead of skipping it. |
| const continued = record.sequence === request.position && request.byteOffset !== undefined; |
| const edge = continued |
| ? request.byteOffset! |
| : request.direction === 'older' |
| ? data.byteLength |
| : 0; |
| const available = request.maxBytes - rawBytes; |
| const byteOffset = request.direction === 'older' ? Math.max(0, edge - available) : edge; |
| const end = |
| request.direction === 'older' ? edge : Math.min(data.byteLength, edge + available); |
| fragments.push({ |
| sequence: record.sequence, |
| byteOffset, |
| totalBytes: data.byteLength, |
| payloadDigest: null, |
| data: data.subarray(byteOffset, end), |
| }); |
| rawBytes += end - byteOffset; |
| const complete = request.direction === 'older' ? byteOffset === 0 : end === data.byteLength; |
| if (!complete) { |
| truncated = true; |
| next = { |
| position: record.sequence, |
| byteOffset: request.direction === 'older' ? byteOffset : end, |
| }; |
| break; |
| } |
| } |
| if (!truncated) next = null; |
| return { throughSequence, fragments, rawBytes, next }; |
| }, |
| |
| async readRecords( |
| sessionId: string, |
| request: SessionTranscriptRecordScanRequest, |
| ): Promise<SessionTranscriptRecordScanPage> { |
| const throughSequence = |
| request.throughSequence === undefined |
| ? await source.readHighWater(sessionId) |
| : request.throughSequence; |
| if (throughSequence === null) { |
| return { throughSequence: null, records: [], nextPosition: null }; |
| } |
| const records: Array<{ sequence: number; message: StoredMessage }> = []; |
| let storedBytes = 0; |
| let nextPosition: number | null = null; |
| for await (const record of source.scan(sessionId, { ...request, throughSequence })) { |
| if (records.length >= request.maxMessages || storedBytes >= request.maxStoredBytes) { |
| nextPosition = record.sequence; |
| break; |
| } |
| records.push(record); |
| storedBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); |
| } |
| return { throughSequence, records, nextPosition }; |
| }, |
| |
| /** |
| * The durable rows behind a set of message ids. |
| * |
| * The ids come from the assistant streams a subscriber is still watching, |
| * so they belong to the Session's newest Turns. The scan walks back from |
| * the watermark a Turn at a time and stops as soon as every id is found, |
| * rather than keeping an index from message id to event. An id that is not |
| * there stops the walk after the newest Turns instead of reading the |
| * Session: the handoff shows what the tail holds, not everything it could. |
| */ |
| async readMessagesById( |
| sessionId: string, |
| request: SessionTranscriptMessageLookupRequest, |
| ): Promise<StoredMessage[]> { |
| if (request.throughSequence === null || request.messageIds.length === 0) return []; |
| const wanted = new Set(request.messageIds); |
| const found: Array<{ sequence: number; message: StoredMessage }> = []; |
| let bytes = 0; |
| for await (const record of source.scan(sessionId, { |
| direction: 'older', |
| throughSequence: request.throughSequence, |
| maxTurns: TRANSCRIPT_LOOKUP_MAX_TURNS, |
| })) { |
| if (!wanted.delete(record.message.id)) continue; |
| bytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); |
| if (found.length >= request.maxMessages || bytes > request.maxBytes) break; |
| found.push(record); |
| if (wanted.size === 0) break; |
| } |
| return found.sort((a, b) => a.sequence - b.sequence).map((record) => record.message); |
| }, |
| }; |
| } |
| |
| function ordinalOf(sequence: number): number { |
| return Math.floor(sequence / EVENT_SEQUENCE_STRIDE); |
| } |
| |
| function assertActiveOverlayBounded(messages: readonly StoredMessage[]): void { |
| if (messages.length > ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES) { |
| throw new Error('Active Session transcript overlay exceeds its message limit'); |
| } |
| let encodedBytes = 0; |
| for (const message of messages) { |
| encodedBytes += Buffer.byteLength(JSON.stringify(message), 'utf8'); |
| if (encodedBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) { |
| throw new Error('Active Session transcript overlay exceeds its byte limit'); |
| } |
| } |
| } |
| |
| async function readCanonicalPermissionOutcomes( |
| requestIds: readonly string[], |
| reader: CanonicalPermissionOutcomeReader, |
| ): Promise<ReadonlyMap<string, CanonicalPermissionOutcomeRecord>> { |
| const outcomes = new Map<string, CanonicalPermissionOutcomeRecord>(); |
| const ids = [...requestIds]; |
| let encodedBytes = 0; |
| for (let index = 0; index < ids.length; index += PERMISSION_OUTCOME_READ_CONCURRENCY) { |
| const batch = await Promise.all( |
| ids.slice(index, index + PERMISSION_OUTCOME_READ_CONCURRENCY).map(async (requestId) => ({ |
| requestId, |
| outcome: await reader.readPermissionOutcome(requestId), |
| })), |
| ); |
| for (const item of batch) { |
| if (!item.outcome) continue; |
| encodedBytes += Buffer.byteLength(JSON.stringify(item.outcome), 'utf8'); |
| if (encodedBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) { |
| throw new Error('Active Session permission outcomes exceed the transcript byte limit'); |
| } |
| outcomes.set(item.requestId, item.outcome); |
| } |
| } |
| return outcomes; |
| } |
| |
| async function scanActiveRuntimeEvents( |
| stores: ExecutionStoresWriter<'interactive'>, |
| sessionId: string, |
| runId: string, |
| visit: (event: RuntimeEvent) => void, |
| ): Promise<void> { |
| const result = await stores.runtimeEventStore.scanRuntimeEvents( |
| sessionId, |
| runId, |
| { |
| maxBatchBytes: ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES, |
| maxRecordBytes: TRANSCRIPT_SOURCE_MAX_RECORD_BYTES, |
| maxImmutableRecords: TRANSCRIPT_SOURCE_MAX_EVENTS, |
| maxImmutableBytes: TRANSCRIPT_SOURCE_MAX_BYTES, |
| maxPartialRecords: TRANSCRIPT_SOURCE_MAX_EVENTS, |
| maxPartialBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, |
| }, |
| (batch) => { |
| for (const event of batch) visit(event); |
| }, |
| ); |
| if (result.status === 'limit_exceeded') { |
| throw new Error('Active RuntimeEvent transcript exceeds its storage scan limit'); |
| } |
| } |
| |
| interface PendingTranscriptTurn extends RuntimeTranscriptInvocationHeader { |
| projection: ReturnType<typeof createTranscriptProjection>; |
| ordinals: Map<string, number>; |
| } |
| |
| /** Keep only presentation state while the storage snapshot visits complete facts. */ |
| function createTranscriptProjection( |
| invocations: readonly RuntimeInvocationRecord[], |
| active = false, |
| ) { |
| const canonicalPermissionOutcomes = new Map<string, CanonicalPermissionOutcomeRecord>(); |
| let messageCount = 0; |
| let messageBytes = 0; |
| let eventCount = 0; |
| let sourceBytes = 0; |
| const projector = createRuntimeEventStoredMessageProjector({ |
| invocations, |
| active, |
| canonicalPermissionOutcomes, |
| projectToolResult: projectTranscriptToolResult, |
| onMessage: (message) => { |
| messageCount += 1; |
| messageBytes += Buffer.byteLength(JSON.stringify(message)); |
| if ( |
| messageCount > ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES || |
| messageBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES |
| ) |
| throw new Error('Session transcript projection exceeds its presentation limit'); |
| }, |
| }); |
| return { |
| push(event: RuntimeEvent) { |
| eventCount += 1; |
| const content = event.content; |
| // The durable model projection is never a transcript input. Large Bash |
| // streams are consumed into a bounded terminal preview by the read model. |
| const measured = |
| content?.kind === 'function_response' |
| ? { ...event, content: { ...content, modelProjection: undefined, result: undefined } } |
| : event; |
| sourceBytes += Buffer.byteLength(JSON.stringify(measured)); |
| if (eventCount > TRANSCRIPT_SOURCE_MAX_EVENTS) |
| throw new Error('Active RuntimeEvent transcript exceeds its event limit'); |
| if (sourceBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) |
| throw new Error('Active RuntimeEvent transcript exceeds its byte limit'); |
| projector.push(event); |
| }, |
| async finish(reader: CanonicalPermissionOutcomeReader) { |
| const outcomes = await readCanonicalPermissionOutcomes( |
| projector.permissionRequestIds, |
| reader, |
| ); |
| for (const [id, outcome] of outcomes) canonicalPermissionOutcomes.set(id, outcome); |
| const projected = projector.finish(); |
| assertActiveOverlayBounded(projected.messages); |
| return projected; |
| }, |
| }; |
| } |
| |
| function isTerminalTurn(turn: TurnSnapshot): boolean { |
| return turn.status === 'completed' || turn.status === 'failed' || turn.status === 'cancelled'; |
| } |