blob: dede884cfa5be297693fe21c43f236c4363d91df [file]
import type {
AgentRunHeader,
AgentRunStore,
RuntimeEvent,
RuntimeEventStore,
StoredMessage,
TurnRecord,
} from '@maka/core';
import { deriveTurnRecords, isSessionInlineRun, isTerminalRuntimeEvent } from '@maka/core';
import type {
CanonicalPermissionOutcomeReader,
CanonicalPermissionOutcomeRecord,
} from './interaction-authority.js';
import {
classifyRuntimeEventTerminalFact,
compareRuntimeReadModelMessages,
isHardRuntimeEventReadModelDiagnostic,
projectRuntimeEventsToStoredMessages,
type RuntimeEventReadModelDiagnostic,
type RuntimeEventTerminalFact,
} from './runtime-event-read-model.js';
import {
buildRuntimeEventModelReplayPlan,
type RuntimeEventModelReplayPlan,
} from './model-history.js';
import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js';
import {
effectiveRunHeaderFromTerminalFact,
terminalRunHeaderMatchesFact,
} from './terminal-run-commit.js';
const CANONICAL_PERMISSION_READ_CONCURRENCY = 8;
export interface RuntimeReadModelProjectionCache {
readMessages(sessionId: string): Promise<StoredMessage[]>;
}
export interface RuntimeReadModelDeps {
runStore: AgentRunStore;
runtimeEventStore: RuntimeEventStore;
projectionCache?: RuntimeReadModelProjectionCache;
canonicalPermissionOutcomes?: CanonicalPermissionOutcomeReader;
}
export interface RuntimeReadModelSessionView {
source: 'runtime_events';
messages: StoredMessage[];
turns: TurnRecord[];
events: RuntimeEvent[];
runs: AgentRunHeader[];
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 runs: AgentRunHeader[];
try {
runs = await this.deps.runStore.listSessionRuns(sessionId);
} catch (error) {
throw new RuntimeReadModelError('RuntimeReadModel could not list AgentRun headers', [
readModelDiagnostic('unsupported_event', 'AgentRunStore.listSessionRuns failed', {
error: errorMessage(error),
}),
]);
}
const inlineRuns = runs.filter(isSessionInlineRun);
if (inlineRuns.length === 0) {
return this.buildView({ runs: inlineRuns, events: [], diagnostics });
}
const ordered: Array<{ event: RuntimeEvent; runIndex: number; eventIndex: number }> = [];
const terminalFacts: RuntimeEventTerminalFact[] = [];
for (let runIndex = 0; runIndex < inlineRuns.length; runIndex += 1) {
const run = inlineRuns[runIndex]!;
if (!isTerminalRunStatus(run.status)) {
const activeRunContext = await this.readNonTerminalRunContext(sessionId, run);
if (activeRunContext?.fact) {
inlineRuns[runIndex] = effectiveRunHeaderFromTerminalFact(run, activeRunContext.fact);
terminalFacts.push(activeRunContext.fact);
diagnostics.push(...activeRunContext.fact.diagnostics);
for (let eventIndex = 0; eventIndex < activeRunContext.events.length; eventIndex += 1) {
ordered.push({ event: activeRunContext.events[eventIndex]!, runIndex, eventIndex });
}
continue;
}
const diagnostic = readModelDiagnostic(
'incomplete_event',
'active run is using the in-flight projection cache',
{
runId: run.runId,
turnId: run.turnId,
status: run.status,
},
);
diagnostics.push(diagnostic);
inFlightTurnIds.add(run.turnId);
if (!this.deps.projectionCache) {
throw new RuntimeReadModelError('RuntimeEvent ledger is incomplete for an active run', [
readModelDiagnostic(
'incomplete_event',
'active run has no stable RuntimeEvent read projection',
{
runId: run.runId,
turnId: run.turnId,
status: run.status,
},
),
]);
}
const overlayEvents = activeRunContext?.events.flatMap(activeInteractionOverlayEvent) ?? [];
for (let eventIndex = 0; eventIndex < overlayEvents.length; eventIndex += 1) {
ordered.push({ event: overlayEvents[eventIndex]!, runIndex, eventIndex });
}
continue;
}
let runEvents: RuntimeEvent[];
try {
runEvents = await this.deps.runtimeEventStore.readRuntimeEvents(sessionId, run.runId);
} catch (error) {
throw new RuntimeReadModelError('RuntimeEvent ledger read failed', [
readModelDiagnostic('unsupported_event', 'RuntimeEventStore.readRuntimeEvents failed', {
runId: run.runId,
error: errorMessage(error),
}),
]);
}
if (runEvents.length === 0) {
const recovered = await this.backfillMissingRuntimeEvents(sessionId, run);
if (recovered.length === 0 || !recovered.some(isTerminalRuntimeEvent)) {
throw new RuntimeReadModelError('RuntimeEvent ledger is missing for a terminal run', [
readModelDiagnostic(
'incomplete_event',
'terminal run has no readable RuntimeEvent ledger',
{
runId: run.runId,
turnId: run.turnId,
},
),
]);
}
diagnostics.push(
readModelDiagnostic(
'incomplete_event',
'terminal run recovered from legacy projection cache',
{
runId: run.runId,
turnId: run.turnId,
},
),
);
runEvents = recovered;
}
if (!runEvents.some(isTerminalRuntimeEvent)) {
throw new RuntimeReadModelError(
'RuntimeEvent ledger has no terminal fact for a terminal run',
[
readModelDiagnostic('incomplete_event', 'terminal run has no terminal RuntimeEvent', {
runId: run.runId,
turnId: run.turnId,
}),
],
);
}
const terminalFact = classifyRuntimeEventTerminalFact(run, runEvents);
diagnostics.push(...terminalFact.diagnostics);
if (!terminalFact.fact) {
throw new RuntimeReadModelError(
'RuntimeEvent ledger has no valid terminal fact for a terminal run',
diagnostics,
);
}
if (!terminalRunHeaderMatchesFact(run, terminalFact.fact)) {
diagnostics.push(
readModelDiagnostic(
'incomplete_event',
'terminal run header does not match RuntimeEvent terminal fact',
{
runId: run.runId,
turnId: run.turnId,
headerStatus: run.status,
factStatus: terminalFact.fact.runStatus,
headerFailureClass: run.failureClass,
factFailureClass: terminalFact.fact.failureClass,
headerAbortSource: run.abortSource,
factAbortSource: terminalFact.fact.abortSource,
},
),
);
}
inlineRuns[runIndex] = effectiveRunHeaderFromTerminalFact(run, terminalFact.fact);
terminalFacts.push(terminalFact.fact);
for (let eventIndex = 0; eventIndex < runEvents.length; eventIndex += 1) {
ordered.push({ event: runEvents[eventIndex]!, runIndex, eventIndex });
}
}
ordered.sort(
(a, b) =>
a.event.ts - b.event.ts ||
a.runIndex - b.runIndex ||
a.eventIndex - b.eventIndex ||
a.event.id.localeCompare(b.event.id),
);
return this.buildView({
runs: inlineRuns,
events: ordered.map((item) => item.event),
diagnostics,
terminalFacts,
inFlightTurnIds,
});
}
private async readNonTerminalRunContext(
sessionId: string,
run: AgentRunHeader,
): Promise<{ events: RuntimeEvent[]; fact?: RuntimeEventTerminalFact } | undefined> {
let runEvents: RuntimeEvent[];
try {
runEvents = await this.deps.runtimeEventStore.readRuntimeEvents(sessionId, run.runId);
} catch {
return undefined;
}
const fact = classifyRuntimeEventTerminalFact(run, runEvents).fact;
return {
events: runEvents,
...(fact ? { fact } : {}),
};
}
private async backfillMissingRuntimeEvents(
sessionId: string,
run: AgentRunHeader,
): Promise<RuntimeEvent[]> {
if (!this.deps.projectionCache) return [];
let messages: StoredMessage[];
try {
messages = await this.deps.projectionCache.readMessages(sessionId);
} catch {
return [];
}
return backfillRuntimeEventsFromStoredMessages({ run, messages }).events;
}
private async buildView(input: {
runs: AgentRunHeader[];
events: RuntimeEvent[];
diagnostics: RuntimeEventReadModelDiagnostic[];
terminalFacts?: RuntimeEventTerminalFact[];
inFlightTurnIds?: ReadonlySet<string>;
}): Promise<RuntimeReadModelSessionView> {
const canonicalPermissionRead = await this.readCanonicalPermissionOutcomes(input.events);
const projected = projectRuntimeEventsToStoredMessages(input.events, {
runHeaders: input.runs,
canonicalPermissionOutcomes: canonicalPermissionRead.outcomes,
});
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 sessionId = input.runs[0]?.sessionId;
let cachedMessages: StoredMessage[] | undefined;
if (sessionId && this.deps.projectionCache) {
try {
cachedMessages = await this.deps.projectionCache.readMessages(sessionId);
} catch (error) {
const diagnostic = readModelDiagnostic(
'unsupported_event',
'SessionProjectionCache.readMessages failed',
{
error: errorMessage(error),
},
);
diagnostics.push(diagnostic);
if (input.inFlightTurnIds && input.inFlightTurnIds.size > 0) {
throw new RuntimeReadModelError(
'RuntimeEvent active projection cache read failed',
diagnostics,
);
}
}
}
const messages =
input.inFlightTurnIds && input.inFlightTurnIds.size > 0
? mergeInFlightProjectionCache(
projected.messages,
cachedMessages ?? [],
input.inFlightTurnIds,
)
: projected.messages;
diagnostics.push(
...this.compareProjectionCache(messages, cachedMessages, canonicalPermissionRead.outcomes),
);
return {
source: 'runtime_events',
messages,
turns: deriveTurnRecords(messages),
events: input.events,
runs: input.runs,
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 };
}
private compareProjectionCache(
messages: readonly StoredMessage[],
cached: readonly StoredMessage[] | undefined,
canonicalPermissionOutcomes: ReadonlyMap<string, CanonicalPermissionOutcomeRecord>,
): RuntimeEventReadModelDiagnostic[] {
if (!cached) return [];
const canonicalRequestIds = new Set(canonicalPermissionOutcomes.keys());
const excludesCanonicalPermission = (message: StoredMessage): boolean =>
message.type === 'permission_decision' && canonicalRequestIds.has(message.id);
return compareRuntimeReadModelMessages(
messages.filter((message) => !excludesCanonicalPermission(message)),
cached.filter((message) => !excludesCanonicalPermission(message)),
).diagnostics;
}
}
/**
* The interaction facts an active run must keep even while its messages come
* from the in-flight projection cache. Permission prompts were always carried
* here; sandbox boundary requests and decisions belong for the same reason
* (#1612): they are the only durable record that a prompt was raised and how
* it settled, so dropping them makes a pending request invisible to anything
* reading the view instead of the live backend.
*/
function activeInteractionOverlayEvent(event: RuntimeEvent): RuntimeEvent[] {
const permissionRequest = event.actions?.permissionRequest;
const permissionAnswerAccepted = event.actions?.permissionAnswerAccepted;
const permissionClosureAccepted = event.actions?.permissionClosureAccepted;
const sandboxBoundaryRequest = event.actions?.stateDelta?.sandboxBoundaryRequest;
const sandboxBoundaryDecision = event.actions?.stateDelta?.sandboxBoundaryDecision;
if (
!permissionRequest &&
!permissionAnswerAccepted &&
!permissionClosureAccepted &&
sandboxBoundaryRequest === undefined &&
sandboxBoundaryDecision === undefined
) {
return [];
}
const overlay = { ...event };
delete overlay.content;
delete overlay.status;
const stateDelta = {
...(sandboxBoundaryRequest !== undefined ? { sandboxBoundaryRequest } : {}),
...(sandboxBoundaryDecision !== undefined ? { sandboxBoundaryDecision } : {}),
};
overlay.actions = {
...(permissionRequest ? { permissionRequest } : {}),
...(permissionAnswerAccepted ? { permissionAnswerAccepted } : {}),
...(permissionClosureAccepted ? { permissionClosureAccepted } : {}),
...(Object.keys(stateDelta).length > 0 ? { stateDelta } : {}),
};
return [overlay];
}
function mergeInFlightProjectionCache(
runtimeMessages: readonly StoredMessage[],
cachedMessages: readonly StoredMessage[],
inFlightTurnIds: ReadonlySet<string>,
): StoredMessage[] {
const merged = runtimeMessages.map((message, index) => ({ message, index }));
const seenIds = new Set(runtimeMessages.map((message) => message.id));
for (const cached of cachedMessages) {
const turnId = messageTurnId(cached);
if (!turnId || !inFlightTurnIds.has(turnId) || seenIds.has(cached.id)) continue;
seenIds.add(cached.id);
merged.push({ message: cached, index: merged.length });
}
return merged
.sort((a, b) => a.message.ts - b.message.ts || a.index - b.index)
.map((entry) => entry.message);
}
function messageTurnId(message: StoredMessage): string | undefined {
return 'turnId' in message && typeof message.turnId === 'string' ? message.turnId : undefined;
}
function readModelDiagnostic(
code: RuntimeEventReadModelDiagnostic['code'],
message: string,
detail?: unknown,
): RuntimeEventReadModelDiagnostic {
return {
code,
message,
...(detail !== undefined ? { detail } : {}),
};
}
function isTerminalRunStatus(status: AgentRunHeader['status']): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}