blob: 68f18ccdec2d40dd1c6eaf1049c3ac398b9e5ca9 [file]
import {
interactionCanonicalOutcomesEquivalent,
type InteractionAnswer,
type InteractionCanonicalOutcome,
} from '@maka/core/interaction';
import { RuntimeInteractionInvariantError, type RuntimeUserQuestionOutcome } from '@maka/runtime';
import type {
InteractionRecord,
StoredInteractionOutcome,
StoredInteractionRequest,
} from '@maka/storage/interaction-store';
import {
INTERACTION_PENDING_REVISION,
INTERACTION_RESOLVED_REVISION,
INTERACTION_SCHEMA_VERSION,
type InteractionAnsweredSnapshot,
type InteractionPendingSnapshot,
type InteractionSnapshot,
type SessionInteractionProjection,
} from '../protocol/index.js';
export function projectInteractionRecord(record: InteractionRecord): InteractionSnapshot {
const base = {
schemaVersion: INTERACTION_SCHEMA_VERSION,
interactionId: record.request.requestId,
sessionId: record.request.sessionId,
turnId: record.request.turnId,
runId: record.request.runId,
request: record.request.request,
} as const;
const outcome = record.outcome?.outcome;
if (!outcome) {
return {
...base,
revision: INTERACTION_PENDING_REVISION,
status: 'pending',
outcome: null,
};
}
return outcome.kind === 'closure'
? { ...base, revision: INTERACTION_RESOLVED_REVISION, status: 'closed', outcome }
: { ...base, revision: INTERACTION_RESOLVED_REVISION, status: 'answered', outcome };
}
export function projectPendingInteraction(
request: StoredInteractionRequest,
): InteractionPendingSnapshot {
return {
schemaVersion: INTERACTION_SCHEMA_VERSION,
interactionId: request.requestId,
sessionId: request.sessionId,
turnId: request.turnId,
runId: request.runId,
revision: INTERACTION_PENDING_REVISION,
request: request.request,
status: 'pending',
outcome: null,
};
}
export function projectSessionInteractions(
pending: readonly StoredInteractionRequest[],
): SessionInteractionProjection {
return {
pending: [...pending].sort(compareStoredInteractionRequests).map(projectPendingInteraction),
};
}
export function compareStoredInteractionRequests(
left: StoredInteractionRequest,
right: StoredInteractionRequest,
): number {
return left.createdAt - right.createdAt || left.requestId.localeCompare(right.requestId);
}
export function questionCanonicalOutcome(
answer: Extract<InteractionAnswer, { kind: 'question' }>,
committedAt: number,
): Extract<InteractionCanonicalOutcome, { kind: 'question_answer' }> {
return { kind: 'question_answer', answers: [...answer.answers], committedAt };
}
export function runtimeQuestionOutcome(
outcome: InteractionCanonicalOutcome,
): RuntimeUserQuestionOutcome {
if (outcome.kind === 'closure') {
if (outcome.reason === 'timed_out') {
throw new RuntimeInteractionInvariantError('Question Interaction resolved with a timeout');
}
return { kind: 'closure', reason: outcome.reason };
}
if (outcome.kind !== 'question_answer') {
throw new RuntimeInteractionInvariantError(
'Question Interaction resolved with a permission answer',
);
}
return { kind: 'question_answer', answer: { answers: [...outcome.answers] } };
}
export function answerOutcome(
record: InteractionRecord & { outcome: StoredInteractionOutcome },
candidate: InteractionAnswer,
):
| { readonly ok: true; readonly result: InteractionAnsweredSnapshot }
| {
readonly ok: false;
readonly error: { readonly code: 'already_resolved'; readonly message: string };
} {
const snapshot = projectInteractionRecord(record);
if (snapshot.status === 'closed') {
return {
ok: false,
error: { code: 'already_resolved', message: 'Interaction was already closed' },
};
}
if (snapshot.status !== 'answered') {
throw new RuntimeInteractionInvariantError('Committed Interaction did not project as resolved');
}
const candidateOutcome = canonicalOutcomeForHistoricalAnswer(
candidate,
snapshot.outcome.committedAt,
);
return interactionCanonicalOutcomesEquivalent(snapshot.outcome, candidateOutcome)
? { ok: true, result: snapshot }
: {
ok: false,
error: {
code: 'already_resolved',
message: 'Interaction has a different canonical answer',
},
};
}
function canonicalOutcomeForHistoricalAnswer(
answer: InteractionAnswer,
committedAt: number,
): Exclude<InteractionCanonicalOutcome, { kind: 'closure' }> {
if (answer.kind === 'question') {
return questionCanonicalOutcome(answer, committedAt);
}
return answer.decision === 'deny'
? {
kind: 'permission_answer',
decision: 'deny',
rememberForTurn: false,
reviewer: 'user',
committedAt,
}
: {
kind: 'permission_answer',
decision: 'allow',
rememberForTurn: answer.rememberForTurn,
reviewer: 'user',
committedAt,
};
}