mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
import { Buffer } from 'node:buffer';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
// Model Invocation owns durable ambiguity resolution alongside its transaction contract.
|
|
|
|
import {
|
|
normalizeStepRunMutation,
|
|
transitionStepRunMutation,
|
|
type StepRunMutation,
|
|
type StepRunStatus,
|
|
} from '@qinglong/runtime-core/step-run';
|
|
|
|
import {
|
|
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES,
|
|
ModelInvocationConflictError,
|
|
ModelInvocationRepositoryUnavailableError,
|
|
createModelInvocationMutationIdentity,
|
|
normalizeModelInvocationCompletionRecord,
|
|
type CommitModelInvocationResult,
|
|
type ModelInvocationCompletionRecord,
|
|
type ModelInvocationRepository,
|
|
} from './modelInvocation';
|
|
|
|
export const MODEL_INVOCATION_RESOLUTION_SCHEMA =
|
|
'qinglong/model-invocation-resolution@v1' as const;
|
|
export const MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA =
|
|
'qinglong/model-invocation-resolution-command@v1' as const;
|
|
export const MODEL_INVOCATION_RESOLUTION_DECISIONS = [
|
|
'retry',
|
|
'fail',
|
|
'cancel',
|
|
] as const;
|
|
|
|
export type ModelInvocationResolutionDecision =
|
|
(typeof MODEL_INVOCATION_RESOLUTION_DECISIONS)[number];
|
|
|
|
export interface ModelInvocationResolutionRecord {
|
|
readonly schema: typeof MODEL_INVOCATION_RESOLUTION_SCHEMA;
|
|
readonly resolutionId: string;
|
|
readonly invocationId: string;
|
|
readonly projectId: string;
|
|
readonly runId: string;
|
|
readonly stepRunId: string;
|
|
readonly traceId: string;
|
|
readonly completionDigest: string;
|
|
readonly decision: ModelInvocationResolutionDecision;
|
|
readonly resolvedByUserId: string;
|
|
readonly resolvedStepRunVersion: number;
|
|
readonly stepRunMutationId: string;
|
|
readonly stepRunMutationDigest: string;
|
|
readonly resolvedStepRunDigest: string;
|
|
readonly runEventId: string;
|
|
readonly resolvedAtMs: number;
|
|
readonly resolutionDigest: string;
|
|
}
|
|
|
|
export interface ModelInvocationResolutionCommand {
|
|
readonly schema: typeof MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA;
|
|
readonly completion: Readonly<ModelInvocationCompletionRecord>;
|
|
readonly resolution: Readonly<ModelInvocationResolutionRecord>;
|
|
readonly stepRunMutation: Readonly<StepRunMutation>;
|
|
readonly commandDigest: string;
|
|
}
|
|
|
|
export interface ModelInvocationResolutionRepository
|
|
extends ModelInvocationRepository {
|
|
findResolution(
|
|
invocationId: string,
|
|
): Promise<Readonly<ModelInvocationResolutionRecord> | null>;
|
|
resolve(
|
|
command: ModelInvocationResolutionCommand,
|
|
): Promise<
|
|
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
|
>;
|
|
}
|
|
|
|
export class InvalidModelInvocationResolutionError extends TypeError {
|
|
readonly code = 'MODEL_INVOCATION_RESOLUTION_INVALID';
|
|
|
|
constructor(message: string) {
|
|
super(`Model invocation resolution is invalid: ${message}`);
|
|
this.name = 'InvalidModelInvocationResolutionError';
|
|
}
|
|
}
|
|
|
|
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
const RESOLUTION_DIGEST_DOMAIN = Buffer.from(
|
|
'qinglong/model-invocation-resolution-digest@v1\0',
|
|
'utf8',
|
|
);
|
|
const RESOLUTION_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
|
'qinglong/model-invocation-resolution-command-digest@v1\0',
|
|
'utf8',
|
|
);
|
|
|
|
function invalid(message: string): never {
|
|
throw new InvalidModelInvocationResolutionError(message);
|
|
}
|
|
|
|
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
Object.getPrototypeOf(value) !== Object.prototype
|
|
) {
|
|
return invalid(`${label} must be a plain object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(
|
|
value: object,
|
|
expected: readonly string[],
|
|
label: string,
|
|
): void {
|
|
const actual = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
actual.length !== canonical.length ||
|
|
actual.some((key, index) => key !== canonical[index])
|
|
) {
|
|
invalid(`${label} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function identifier(value: unknown, label: string): string {
|
|
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
|
return invalid(`${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function digest(value: unknown, label: string): string {
|
|
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
|
return invalid(`${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function integer(
|
|
value: unknown,
|
|
minimum: number,
|
|
maximum: number,
|
|
label: string,
|
|
): number {
|
|
if (
|
|
!Number.isSafeInteger(value) ||
|
|
(value as number) < minimum ||
|
|
(value as number) > maximum
|
|
) {
|
|
return invalid(`${label} is invalid`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function hash(domain: Uint8Array, value: unknown): string {
|
|
return createHash('sha256')
|
|
.update(domain)
|
|
.update(JSON.stringify(value))
|
|
.digest('hex');
|
|
}
|
|
|
|
function withoutDigest(
|
|
value: Readonly<ModelInvocationResolutionRecord>,
|
|
): Omit<ModelInvocationResolutionRecord, 'resolutionDigest'> {
|
|
const { resolutionDigest: _resolutionDigest, ...unsigned } = value;
|
|
return unsigned;
|
|
}
|
|
|
|
export function resolutionTransition(
|
|
decision: ModelInvocationResolutionDecision,
|
|
): Readonly<{
|
|
to: StepRunStatus;
|
|
resultCode?: string;
|
|
errorSummary?: string;
|
|
}> {
|
|
if (decision === 'retry') return Object.freeze({ to: 'ready' });
|
|
if (decision === 'fail') {
|
|
return Object.freeze({
|
|
to: 'failed',
|
|
resultCode: 'model_outcome_rejected',
|
|
errorSummary: 'Unknown model outcome rejected by operator',
|
|
});
|
|
}
|
|
if (decision === 'cancel') {
|
|
return Object.freeze({
|
|
to: 'cancelled',
|
|
resultCode: 'model_outcome_cancelled',
|
|
});
|
|
}
|
|
return invalid('decision is invalid');
|
|
}
|
|
|
|
export function normalizeModelInvocationResolutionRecord(
|
|
value: ModelInvocationResolutionRecord,
|
|
): Readonly<ModelInvocationResolutionRecord> {
|
|
const candidate = dataRecord(value, 'resolution record');
|
|
exactKeys(
|
|
candidate,
|
|
[
|
|
'completionDigest',
|
|
'decision',
|
|
'invocationId',
|
|
'projectId',
|
|
'resolutionDigest',
|
|
'resolutionId',
|
|
'resolvedAtMs',
|
|
'resolvedByUserId',
|
|
'resolvedStepRunDigest',
|
|
'resolvedStepRunVersion',
|
|
'runEventId',
|
|
'runId',
|
|
'schema',
|
|
'stepRunId',
|
|
'stepRunMutationDigest',
|
|
'stepRunMutationId',
|
|
'traceId',
|
|
],
|
|
'resolution record',
|
|
);
|
|
if (
|
|
value.schema !== MODEL_INVOCATION_RESOLUTION_SCHEMA ||
|
|
!MODEL_INVOCATION_RESOLUTION_DECISIONS.includes(value.decision)
|
|
) {
|
|
invalid('resolution schema or decision is invalid');
|
|
}
|
|
const normalized = Object.freeze({
|
|
schema: MODEL_INVOCATION_RESOLUTION_SCHEMA,
|
|
resolutionId: identifier(value.resolutionId, 'resolution id'),
|
|
invocationId: identifier(value.invocationId, 'invocation id'),
|
|
projectId: identifier(value.projectId, 'project id'),
|
|
runId: identifier(value.runId, 'Run id'),
|
|
stepRunId: identifier(value.stepRunId, 'StepRun id'),
|
|
traceId: identifier(value.traceId, 'trace id'),
|
|
completionDigest: digest(value.completionDigest, 'completion digest'),
|
|
decision: value.decision,
|
|
resolvedByUserId: identifier(value.resolvedByUserId, 'resolving user id'),
|
|
resolvedStepRunVersion: integer(
|
|
value.resolvedStepRunVersion,
|
|
4,
|
|
2_147_483_647,
|
|
'resolved StepRun version',
|
|
),
|
|
stepRunMutationId: identifier(
|
|
value.stepRunMutationId,
|
|
'StepRun mutation id',
|
|
),
|
|
stepRunMutationDigest: digest(
|
|
value.stepRunMutationDigest,
|
|
'StepRun mutation digest',
|
|
),
|
|
resolvedStepRunDigest: digest(
|
|
value.resolvedStepRunDigest,
|
|
'resolved StepRun digest',
|
|
),
|
|
runEventId: identifier(value.runEventId, 'RunEvent id'),
|
|
resolvedAtMs: integer(
|
|
value.resolvedAtMs,
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
'resolved time',
|
|
),
|
|
resolutionDigest: digest(value.resolutionDigest, 'resolution digest'),
|
|
});
|
|
const identity = createModelInvocationMutationIdentity(
|
|
normalized.invocationId,
|
|
'resolution',
|
|
);
|
|
if (
|
|
normalized.resolutionId !== identity.dedupeKey ||
|
|
normalized.stepRunMutationId !== identity.mutationId ||
|
|
normalized.runEventId !== identity.eventId ||
|
|
hash(RESOLUTION_DIGEST_DOMAIN, withoutDigest(normalized)) !==
|
|
normalized.resolutionDigest
|
|
) {
|
|
invalid('resolution identity or digest is invalid');
|
|
}
|
|
if (
|
|
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
|
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES
|
|
) {
|
|
invalid('resolution record exceeds its JSON budget');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function createModelInvocationResolutionCommand(
|
|
completionValue: ModelInvocationCompletionRecord,
|
|
decision: ModelInvocationResolutionDecision,
|
|
resolvedByUserIdValue: string,
|
|
mutationValue: StepRunMutation,
|
|
): Readonly<ModelInvocationResolutionCommand> {
|
|
const completion = normalizeModelInvocationCompletionRecord(completionValue);
|
|
const transition = resolutionTransition(decision);
|
|
const resolvedByUserId = identifier(
|
|
resolvedByUserIdValue,
|
|
'resolving user id',
|
|
);
|
|
const mutation = normalizeStepRunMutation(mutationValue);
|
|
const identity = createModelInvocationMutationIdentity(
|
|
completion.invocationId,
|
|
'resolution',
|
|
);
|
|
if (
|
|
completion.outcome !== 'outcome_unknown' ||
|
|
mutation.previousStatus !== 'lost' ||
|
|
mutation.expectedStepRunVersion !== completion.completedStepRunVersion ||
|
|
mutation.expectedStepRunDigest !== completion.completedStepRunDigest ||
|
|
mutation.mutationId !== identity.mutationId ||
|
|
mutation.runId !== completion.runId ||
|
|
mutation.stepRun.id !== completion.stepRunId ||
|
|
mutation.stepRun.runId !== completion.runId ||
|
|
mutation.stepRun.kind !== 'model' ||
|
|
mutation.stepRun.status !== transition.to ||
|
|
mutation.event.id !== identity.eventId ||
|
|
mutation.event.dedupeKey !== identity.dedupeKey ||
|
|
mutation.event.type !== `step.${transition.to}` ||
|
|
mutation.event.actorType !== 'user' ||
|
|
mutation.event.actorId !== resolvedByUserId ||
|
|
mutation.stepRun.updatedAtMs < completion.completedAtMs ||
|
|
(transition.to === 'ready'
|
|
? mutation.stepRun.outputRef !== null ||
|
|
mutation.stepRun.resultCode !== null ||
|
|
mutation.stepRun.errorSummary !== null
|
|
: mutation.stepRun.resultCode !== transition.resultCode ||
|
|
mutation.stepRun.errorSummary !==
|
|
(transition.errorSummary === undefined
|
|
? null
|
|
: transition.errorSummary))
|
|
) {
|
|
invalid('resolution completion or StepRun mutation is not exact');
|
|
}
|
|
const unsigned = Object.freeze({
|
|
schema: MODEL_INVOCATION_RESOLUTION_SCHEMA,
|
|
resolutionId: identity.dedupeKey,
|
|
invocationId: completion.invocationId,
|
|
projectId: completion.projectId,
|
|
runId: completion.runId,
|
|
stepRunId: completion.stepRunId,
|
|
traceId: completion.traceId,
|
|
completionDigest: completion.completionDigest,
|
|
decision,
|
|
resolvedByUserId,
|
|
resolvedStepRunVersion: mutation.stepRun.version,
|
|
stepRunMutationId: mutation.mutationId,
|
|
stepRunMutationDigest: mutation.mutationDigest,
|
|
resolvedStepRunDigest: mutation.stepRun.stepRunDigest,
|
|
runEventId: mutation.event.id,
|
|
resolvedAtMs: mutation.stepRun.updatedAtMs,
|
|
});
|
|
const resolution = normalizeModelInvocationResolutionRecord({
|
|
...unsigned,
|
|
resolutionDigest: hash(RESOLUTION_DIGEST_DOMAIN, unsigned),
|
|
});
|
|
const commandUnsigned = Object.freeze({
|
|
schema: MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA,
|
|
completion,
|
|
resolution,
|
|
stepRunMutation: mutation,
|
|
});
|
|
return Object.freeze({
|
|
...commandUnsigned,
|
|
commandDigest: hash(RESOLUTION_COMMAND_DIGEST_DOMAIN, commandUnsigned),
|
|
});
|
|
}
|
|
|
|
export function normalizeModelInvocationResolutionCommand(
|
|
value: ModelInvocationResolutionCommand,
|
|
): Readonly<ModelInvocationResolutionCommand> {
|
|
const candidate = dataRecord(value, 'resolution command');
|
|
exactKeys(
|
|
candidate,
|
|
['commandDigest', 'completion', 'resolution', 'schema', 'stepRunMutation'],
|
|
'resolution command',
|
|
);
|
|
if (value.schema !== MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA) {
|
|
invalid('resolution command schema is invalid');
|
|
}
|
|
const completion = normalizeModelInvocationCompletionRecord(value.completion);
|
|
const resolution = normalizeModelInvocationResolutionRecord(value.resolution);
|
|
const mutation = normalizeStepRunMutation(value.stepRunMutation);
|
|
const canonical = createModelInvocationResolutionCommand(
|
|
completion,
|
|
resolution.decision,
|
|
resolution.resolvedByUserId,
|
|
mutation,
|
|
);
|
|
if (
|
|
canonical.resolution.resolutionDigest !== resolution.resolutionDigest ||
|
|
digest(value.commandDigest, 'command digest') !== canonical.commandDigest
|
|
) {
|
|
invalid('resolution command is not canonical');
|
|
}
|
|
return canonical;
|
|
}
|
|
|
|
export function assertResolutionMatchesDecision(
|
|
resolutionValue: ModelInvocationResolutionRecord,
|
|
completionValue: ModelInvocationCompletionRecord,
|
|
decision: ModelInvocationResolutionDecision,
|
|
resolvedByUserId: string,
|
|
): Readonly<ModelInvocationResolutionRecord> {
|
|
const resolution = normalizeModelInvocationResolutionRecord(resolutionValue);
|
|
const completion = normalizeModelInvocationCompletionRecord(completionValue);
|
|
if (
|
|
resolution.invocationId !== completion.invocationId ||
|
|
resolution.projectId !== completion.projectId ||
|
|
resolution.runId !== completion.runId ||
|
|
resolution.stepRunId !== completion.stepRunId ||
|
|
resolution.traceId !== completion.traceId ||
|
|
resolution.completionDigest !== completion.completionDigest ||
|
|
resolution.decision !== decision ||
|
|
resolution.resolvedByUserId !== resolvedByUserId
|
|
) {
|
|
throw new ModelInvocationConflictError();
|
|
}
|
|
return resolution;
|
|
}
|
|
|
|
export interface ResolveModelInvocationOptions {
|
|
readonly invocationId: string;
|
|
readonly decision: ModelInvocationResolutionDecision;
|
|
readonly resolvedByUserId: string;
|
|
readonly resolvedAtMs: number;
|
|
}
|
|
|
|
const MAX_RESOLUTION_COORDINATOR_ATTEMPTS = 3;
|
|
|
|
export class DurableModelInvocationResolutionCoordinator {
|
|
constructor(
|
|
private readonly repository: ModelInvocationResolutionRepository,
|
|
) {
|
|
if (
|
|
!repository ||
|
|
typeof repository.findCompletion !== 'function' ||
|
|
typeof repository.findResolution !== 'function' ||
|
|
typeof repository.readAuthority !== 'function' ||
|
|
typeof repository.resolve !== 'function'
|
|
) {
|
|
throw new ModelInvocationRepositoryUnavailableError();
|
|
}
|
|
}
|
|
|
|
async resolve(
|
|
optionsValue: ResolveModelInvocationOptions,
|
|
): Promise<
|
|
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
|
> {
|
|
const options = dataRecord(optionsValue, 'resolution options');
|
|
exactKeys(
|
|
options,
|
|
['decision', 'invocationId', 'resolvedAtMs', 'resolvedByUserId'],
|
|
'resolution options',
|
|
);
|
|
const invocationId = identifier(optionsValue.invocationId, 'invocation id');
|
|
const decision = optionsValue.decision;
|
|
resolutionTransition(decision);
|
|
const resolvedByUserId = identifier(
|
|
optionsValue.resolvedByUserId,
|
|
'resolving user id',
|
|
);
|
|
const resolvedAtMs = integer(
|
|
optionsValue.resolvedAtMs,
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
'resolved time',
|
|
);
|
|
const completion = await this.repository.findCompletion(invocationId);
|
|
if (!completion || completion.outcome !== 'outcome_unknown') {
|
|
throw new ModelInvocationConflictError();
|
|
}
|
|
const existing = await this.repository.findResolution(invocationId);
|
|
if (existing) {
|
|
return Object.freeze({
|
|
status: 'existing',
|
|
record: assertResolutionMatchesDecision(
|
|
existing,
|
|
completion,
|
|
decision,
|
|
resolvedByUserId,
|
|
),
|
|
});
|
|
}
|
|
|
|
for (
|
|
let attempt = 0;
|
|
attempt < MAX_RESOLUTION_COORDINATOR_ATTEMPTS;
|
|
attempt += 1
|
|
) {
|
|
const authority = await this.repository.readAuthority({
|
|
projectId: completion.projectId,
|
|
runId: completion.runId,
|
|
stepRunId: completion.stepRunId,
|
|
});
|
|
if (
|
|
!authority ||
|
|
authority.stepRun.kind !== 'model' ||
|
|
authority.stepRun.status !== 'lost' ||
|
|
authority.stepRun.version !== completion.completedStepRunVersion ||
|
|
authority.stepRun.stepRunDigest !== completion.completedStepRunDigest
|
|
) {
|
|
throw new ModelInvocationConflictError();
|
|
}
|
|
const transition = resolutionTransition(decision);
|
|
const identity = createModelInvocationMutationIdentity(
|
|
invocationId,
|
|
'resolution',
|
|
);
|
|
const command = createModelInvocationResolutionCommand(
|
|
completion,
|
|
decision,
|
|
resolvedByUserId,
|
|
transitionStepRunMutation(
|
|
authority.stepRun,
|
|
{
|
|
expectedVersion: authority.stepRun.version,
|
|
expectedDigest: authority.stepRun.stepRunDigest,
|
|
mutationId: identity.mutationId,
|
|
to: transition.to,
|
|
atMs: resolvedAtMs,
|
|
...(transition.resultCode === undefined
|
|
? {}
|
|
: { resultCode: transition.resultCode }),
|
|
...(transition.errorSummary === undefined
|
|
? {}
|
|
: { errorSummary: transition.errorSummary }),
|
|
},
|
|
{
|
|
expectedRunVersion: authority.runVersion,
|
|
expectedRunEventSequence: authority.runEventSequence,
|
|
eventId: identity.eventId,
|
|
dedupeKey: identity.dedupeKey,
|
|
actor: { type: 'user', id: resolvedByUserId },
|
|
},
|
|
),
|
|
);
|
|
try {
|
|
return await this.repository.resolve(command);
|
|
} catch (error) {
|
|
const stored = await this.#resolutionAfterFailure(
|
|
completion,
|
|
decision,
|
|
resolvedByUserId,
|
|
error,
|
|
);
|
|
if (stored) {
|
|
return Object.freeze({ status: 'existing', record: stored });
|
|
}
|
|
if (
|
|
!(error instanceof ModelInvocationConflictError) ||
|
|
attempt + 1 >= MAX_RESOLUTION_COORDINATOR_ATTEMPTS
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
throw new ModelInvocationConflictError();
|
|
}
|
|
|
|
async #resolutionAfterFailure(
|
|
completion: Readonly<ModelInvocationCompletionRecord>,
|
|
decision: ModelInvocationResolutionDecision,
|
|
resolvedByUserId: string,
|
|
original: unknown,
|
|
): Promise<Readonly<ModelInvocationResolutionRecord> | null> {
|
|
try {
|
|
const stored = await this.repository.findResolution(
|
|
completion.invocationId,
|
|
);
|
|
return stored
|
|
? assertResolutionMatchesDecision(
|
|
stored,
|
|
completion,
|
|
decision,
|
|
resolvedByUserId,
|
|
)
|
|
: null;
|
|
} catch {
|
|
throw original;
|
|
}
|
|
}
|
|
}
|