blob: d0bc4ae9dab13923abcd56865ff576895a68794a [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 type { RuntimeEvent } from '@maka/core/runtime-event';
import type { RuntimeEventStore } from '@maka/core/runtime-event-store';
import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation';
import type { StoredMessage, TurnRecord } from '@maka/core/session';
import { deriveTurnRecords } from '@maka/core/session';
import { isSessionInlineInvocation } from '@maka/core/runtime-invocation';
import type {
CanonicalPermissionOutcomeReader,
CanonicalPermissionOutcomeRecord,
} from './interaction-authority.js';
import {
activePresentationRuntimeEvents,
classifyRuntimeEventTerminalFact,
isHardRuntimeEventReadModelDiagnostic,
projectRuntimeEventsToStoredMessages,
projectTranscriptToolResult,
type RuntimeEventReadModelDiagnostic,
type RuntimeEventTerminalFact,
} from './runtime-event-read-model.js';
import {
buildRuntimeEventModelReplayPlan,
type RuntimeEventModelReplayPlan,
} from './model-history.js';
const CANONICAL_PERMISSION_READ_CONCURRENCY = 8;
export interface RuntimeReadModelDeps {
runtimeEventStore: RuntimeEventStore;
canonicalPermissionOutcomes?: CanonicalPermissionOutcomeReader;
}
export interface RuntimeReadModelSessionView {
source: 'runtime_events';
messages: StoredMessage[];
turns: TurnRecord[];
events: RuntimeEvent[];
invocations: RuntimeInvocationRecord[];
diagnostics: RuntimeEventReadModelDiagnostic[];
terminalFacts: RuntimeEventTerminalFact[];
replayPlan: RuntimeEventModelReplayPlan;
}
export class RuntimeReadModelError extends Error {
readonly diagnostics: RuntimeEventReadModelDiagnostic[];
constructor(message: string, diagnostics: RuntimeEventReadModelDiagnostic[]) {
super(message);
this.name = 'RuntimeReadModelError';
this.diagnostics = diagnostics;
}
}
export class RuntimeReadModel {
constructor(private readonly deps: RuntimeReadModelDeps) {}
async getSessionMessages(sessionId: string): Promise<StoredMessage[]> {
return (await this.getSessionView(sessionId)).messages;
}
async getSessionTurns(sessionId: string): Promise<TurnRecord[]> {
return (await this.getSessionView(sessionId)).turns;
}
async getSessionView(sessionId: string): Promise<RuntimeReadModelSessionView> {
const diagnostics: RuntimeEventReadModelDiagnostic[] = [];
const inFlightTurnIds = new Set<string>();
let invocations: RuntimeInvocationRecord[];
try {
invocations = (await this.deps.runtimeEventStore.listSessionInvocations(sessionId)).filter(
(invocation) => isSessionInlineInvocation(invocation.opening),
);
} catch (error) {
throw new RuntimeReadModelError('RuntimeReadModel could not list Session invocations', [
readModelDiagnostic(
'unsupported_event',
'RuntimeEventStore.listSessionInvocations failed',
{
error: errorMessage(error),
},
),
]);
}
if (invocations.length === 0) {
return this.buildView({ invocations, events: [], diagnostics });
}
const durableEventOrdinals = await this.readSessionRuntimeEventOrdinals(sessionId);
const durableEventOrdinalById = new Map(
durableEventOrdinals.map(({ event, ordinal }) => [event.id, ordinal]),
);
const ordered: OrderedRuntimeEvent[] = [];
const terminalFacts: RuntimeEventTerminalFact[] = [];
for (let runIndex = 0; runIndex < invocations.length; runIndex += 1) {
const invocation = invocations[runIndex]!;
let runEvents: RuntimeEvent[];
try {
runEvents = await this.deps.runtimeEventStore.readRuntimeEvents(
sessionId,
invocation.runId,
);
} catch (error) {
throw new RuntimeReadModelError('RuntimeEvent ledger read failed', [
readModelDiagnostic('unsupported_event', 'RuntimeEventStore.readRuntimeEvents failed', {
runId: invocation.runId,
error: errorMessage(error),
}),
]);
}
// No terminal event yet: the invocation is still open, or the process died
// holding it. Either way its own events are the whole truth about it, read
// as a running turn reads — the arriving text presented as settled. No
// durable ordinals exist for them yet, so they keep ledger order.
if (!invocation.terminalEvent) {
inFlightTurnIds.add(invocation.turnId);
appendOrderedEvents(ordered, activePresentationRuntimeEvents(runEvents), runIndex);
continue;
}
const terminalFact = classifyRuntimeEventTerminalFact(invocation, runEvents);
diagnostics.push(...terminalFact.diagnostics);
if (!terminalFact.fact) {
throw new RuntimeReadModelError(
'RuntimeEvent ledger has no valid terminal fact for a terminal run',
diagnostics,
);
}
terminalFacts.push(terminalFact.fact);
appendOrderedEvents(ordered, runEvents, runIndex, durableEventOrdinalById);
}
ordered.sort(compareOrderedRuntimeEvents);
return this.buildView({
invocations,
events: ordered.map((item) => item.event),
diagnostics,
terminalFacts,
inFlightTurnIds,
});
}
private async readSessionRuntimeEventOrdinals(
sessionId: string,
): Promise<ReadonlyArray<{ ordinal: number; event: RuntimeEvent }>> {
try {
return await this.deps.runtimeEventStore.readSessionRuntimeEventEntries(sessionId);
} catch (error) {
throw new RuntimeReadModelError('RuntimeEvent session order read failed', [
readModelDiagnostic(
'unsupported_event',
'RuntimeEventStore.readSessionRuntimeEventEntries failed',
{ error: errorMessage(error) },
),
]);
}
}
private async buildView(input: {
invocations: RuntimeInvocationRecord[];
events: RuntimeEvent[];
diagnostics: RuntimeEventReadModelDiagnostic[];
terminalFacts?: RuntimeEventTerminalFact[];
inFlightTurnIds?: ReadonlySet<string>;
}): Promise<RuntimeReadModelSessionView> {
const canonicalPermissionRead = await this.readCanonicalPermissionOutcomes(input.events);
const projected = projectRuntimeEventsToStoredMessages(input.events, {
invocations: input.invocations,
canonicalPermissionOutcomes: canonicalPermissionRead.outcomes,
projectToolResult: projectTranscriptToolResult,
});
const diagnostics = [
...input.diagnostics,
...canonicalPermissionRead.diagnostics,
...projected.diagnostics,
];
if (canonicalPermissionRead.diagnostics.length > 0) {
throw new RuntimeReadModelError('Canonical permission outcome read failed', diagnostics);
}
if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) {
throw new RuntimeReadModelError('RuntimeEvent read projection is incomplete', diagnostics);
}
const messages = projected.messages;
return {
source: 'runtime_events',
messages,
turns: runningTurnRecords(deriveTurnRecords(messages), input.inFlightTurnIds),
events: input.events,
invocations: input.invocations,
diagnostics,
terminalFacts: input.terminalFacts ?? [],
replayPlan: buildRuntimeEventModelReplayPlan(input.events),
};
}
private async readCanonicalPermissionOutcomes(events: readonly RuntimeEvent[]): Promise<{
outcomes: Map<string, CanonicalPermissionOutcomeRecord>;
diagnostics: RuntimeEventReadModelDiagnostic[];
}> {
const requestIds = [
...new Set(
events.flatMap((event) =>
event.actions?.permissionAnswerAccepted
? [event.actions.permissionAnswerAccepted.requestId]
: [],
),
),
];
const outcomes = new Map<string, CanonicalPermissionOutcomeRecord>();
const diagnostics: RuntimeEventReadModelDiagnostic[] = [];
const reader = this.deps.canonicalPermissionOutcomes;
if (!reader) return { outcomes, diagnostics };
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < requestIds.length) {
const requestId = requestIds[nextIndex]!;
nextIndex += 1;
try {
const outcome = await reader.readPermissionOutcome(requestId);
if (outcome) outcomes.set(requestId, outcome);
} catch (error) {
diagnostics.push(
readModelDiagnostic(
'incomplete_event',
'CanonicalPermissionOutcomeReader.readPermissionOutcome failed',
{ requestId, error: errorMessage(error) },
),
);
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(CANONICAL_PERMISSION_READ_CONCURRENCY, requestIds.length) },
worker,
),
);
return { outcomes, diagnostics };
}
}
/**
* A turn whose invocation has not ended is running.
*
* The transcript has no row that says so, and it should not: "still running" is
* the absence of the terminal event, read off the invocation itself. Rows are
* what the turn produced, and a turn that has produced an answer but not ended
* would otherwise read as finished.
*/
function runningTurnRecords(
turns: readonly TurnRecord[],
inFlightTurnIds: ReadonlySet<string> | undefined,
): TurnRecord[] {
if (!inFlightTurnIds || inFlightTurnIds.size === 0) return [...turns];
const running = new Set(inFlightTurnIds);
const marked = turns.map((turn) => {
if (!running.delete(turn.turnId)) return turn;
return { ...turn, status: 'running' as const, statusSource: 'recorded' as const };
});
// An invocation that has opened but produced nothing yet still has a turn.
for (const turnId of running) {
marked.push({
turnId,
status: 'running',
statusSource: 'recorded',
});
}
return marked;
}
function readModelDiagnostic(
code: RuntimeEventReadModelDiagnostic['code'],
message: string,
detail?: unknown,
): RuntimeEventReadModelDiagnostic {
return {
code,
message,
...(detail !== undefined ? { detail } : {}),
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
interface OrderedRuntimeEvent {
event: RuntimeEvent;
runIndex: number;
eventIndex: number;
ordinal?: number;
}
function appendOrderedEvents(
ordered: OrderedRuntimeEvent[],
events: readonly RuntimeEvent[],
runIndex: number,
ordinals?: ReadonlyMap<string, number>,
): void {
const nextOrdinals: Array<number | undefined> = new Array(events.length);
let nextOrdinal: number | undefined;
for (let eventIndex = events.length - 1; eventIndex >= 0; eventIndex -= 1) {
nextOrdinal = ordinals?.get(events[eventIndex]!.id) ?? nextOrdinal;
nextOrdinals[eventIndex] = nextOrdinal;
}
let previousOrdinal: number | undefined;
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
const event = events[eventIndex]!;
const durableOrdinal = ordinals?.get(event.id);
if (durableOrdinal !== undefined) previousOrdinal = durableOrdinal;
const ordinal = durableOrdinal ?? previousOrdinal ?? nextOrdinals[eventIndex];
ordered.push({
event,
runIndex,
eventIndex,
...(ordinal !== undefined ? { ordinal } : {}),
});
}
}
function compareOrderedRuntimeEvents(a: OrderedRuntimeEvent, b: OrderedRuntimeEvent): number {
if (a.ordinal !== undefined || b.ordinal !== undefined) {
if (a.ordinal === undefined) return 1;
if (b.ordinal === undefined) return -1;
return (
a.ordinal - b.ordinal || a.eventIndex - b.eventIndex || a.event.id.localeCompare(b.event.id)
);
}
return (
a.event.ts - b.event.ts ||
a.runIndex - b.runIndex ||
a.eventIndex - b.eventIndex ||
a.event.id.localeCompare(b.event.id)
);
}