Files
Genarrative/server-node/src/services/customWorldAgentSessionStore.ts
高物 75944b1f1f
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 21:06:48 +08:00

861 lines
26 KiB
TypeScript

import crypto from 'node:crypto';
import type {
CreatorIntentReadiness,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldAgentStage,
CustomWorldAssetCoverageSummary,
CustomWorldDraftCardSummary,
CustomWorldPendingClarification,
CustomWorldSuggestedAction,
EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
resolveCreatorIntentStage,
} from './customWorldAgentClarificationService.js';
import {
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
import {
buildAnchorPackFromEightAnchorContent,
buildCreatorIntentFromEightAnchorContent,
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
createEmptyEightAnchorContent,
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-';
export type CustomWorldAgentSessionRecord = {
sessionId: string;
userId: string;
seedText: string;
currentTurn: number;
anchorContent: EightAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: CreatorIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: CustomWorldAgentMessage[];
draftCards: CustomWorldDraftCardSummary[];
pendingClarifications: CustomWorldPendingClarification[];
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies: string[];
qualityFindings: Array<{
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}>;
assetCoverage: CustomWorldAssetCoverageSummary;
operations: CustomWorldAgentOperationRecord[];
checkpoints: Array<{
checkpointId: string;
createdAt: string;
label: string;
}>;
createdAt: string;
updatedAt: string;
};
type CreateSessionInput = {
seedText?: string;
welcomeMessage: string;
currentTurn?: number;
anchorContent?: EightAnchorContent;
progressPercent?: number;
lastAssistantReply?: string | null;
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
creatorIntentReadiness?: CreatorIntentReadiness;
anchorPack?: CustomWorldAgentSessionRecord['anchorPack'];
draftProfile?: CustomWorldAgentSessionRecord['draftProfile'];
stage?: CustomWorldAgentStage;
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies?: string[];
};
function cloneRecord<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isStage(value: unknown): value is CustomWorldAgentStage {
return (
value === 'collecting_intent' ||
value === 'clarifying' ||
value === 'foundation_review' ||
value === 'object_refining' ||
value === 'visual_refining' ||
value === 'long_tail_review' ||
value === 'ready_to_publish' ||
value === 'published' ||
value === 'error'
);
}
function isAgentSessionRecord(
value: unknown,
): value is CustomWorldAgentSessionRecord {
const record = toRecord(value);
if (!record) {
return false;
}
return (
typeof record.sessionId === 'string' &&
record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) &&
typeof record.userId === 'string' &&
isStage(record.stage) &&
Array.isArray(record.messages) &&
Array.isArray(record.operations) &&
typeof record.createdAt === 'string' &&
typeof record.updatedAt === 'string'
);
}
function isCreatorIntentReadiness(
value: unknown,
): value is CreatorIntentReadiness {
const record = toRecord(value);
if (!record) {
return false;
}
return (
typeof record.isReady === 'boolean' &&
Array.isArray(record.completedKeys) &&
Array.isArray(record.missingKeys)
);
}
function mapLegacyClarificationTargetKey(id: string) {
if (id === 'world_hook') return 'world_hook';
if (id === 'player_premise') return 'player_premise';
if (id === 'theme_and_tone' || id === 'tone_boundary') {
return 'theme_and_tone';
}
if (id === 'core_conflict') return 'core_conflict';
if (id === 'relationship_seed' || id === 'relationship_hook') {
return 'relationship_seed';
}
if (id === 'iconic_element' || id === 'iconic_elements') {
return 'iconic_element';
}
return null;
}
function hasUserInput(record: CustomWorldAgentSessionRecord) {
return (
Boolean(record.seedText.trim()) ||
record.messages.some(
(message) => message.role === 'user' && message.text.trim(),
)
);
}
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent(
normalizeEightAnchorContent(
(record as Record<string, unknown>).anchorContent ?? null,
),
);
if (
compatibleAnchorIntent &&
(compatibleAnchorIntent.worldHook ||
compatibleAnchorIntent.rawSettingText ||
compatibleAnchorIntent.playerPremise ||
compatibleAnchorIntent.openingSituation ||
compatibleAnchorIntent.coreConflicts.length > 0 ||
compatibleAnchorIntent.keyCharacters.length > 0 ||
compatibleAnchorIntent.iconicElements.length > 0)
) {
return compatibleAnchorIntent;
}
return normalizeCreatorIntentRecord(record.creatorIntent);
}
function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) {
if (typeof (record as Record<string, unknown>).currentTurn === 'number') {
return Math.max(
0,
Math.round((record as Record<string, unknown>).currentTurn as number),
);
}
return record.messages.filter((message) => message.role === 'user').length;
}
function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) {
const normalized = normalizeEightAnchorContent(
(record as Record<string, unknown>).anchorContent ?? null,
);
if (
normalized.worldPromise ||
normalized.playerFantasy ||
normalized.themeBoundary ||
normalized.playerEntryPoint ||
normalized.coreConflict ||
normalized.keyRelationships.length > 0 ||
normalized.hiddenLines ||
normalized.iconicElements
) {
return normalized;
}
return buildEightAnchorContentFromCreatorIntent(
buildCompatibleCreatorIntent(record),
);
}
function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) {
const rawProgress = (record as Record<string, unknown>).progressPercent;
if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) {
return Math.max(0, Math.min(100, Math.round(rawProgress)));
}
if (
record.stage === 'foundation_review' ||
record.stage === 'object_refining' ||
record.stage === 'visual_refining' ||
record.stage === 'long_tail_review' ||
record.stage === 'ready_to_publish' ||
record.stage === 'published'
) {
return 100;
}
return estimateProgressPercentFromAnchorContent(
buildCompatibleAnchorContent(record),
);
}
function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) {
const existingReply = (record as Record<string, unknown>).lastAssistantReply;
if (typeof existingReply === 'string') {
return existingReply;
}
const lastAssistantMessage = [...record.messages]
.reverse()
.find((message) => message.role === 'assistant' && message.text.trim());
return lastAssistantMessage?.text ?? null;
}
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
if (
isCreatorIntentReadiness(
(record as Record<string, unknown>).creatorIntentReadiness,
)
) {
return record.creatorIntentReadiness;
}
return evaluateCreatorIntentReadiness(
normalizeCreatorIntentRecord(record.creatorIntent),
);
}
function buildCompatiblePendingClarifications(
record: CustomWorldAgentSessionRecord,
) {
const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent);
const readiness = buildCompatibleReadiness(record);
const legacyClarifications = Array.isArray(record.pendingClarifications)
? record.pendingClarifications
: [];
const nextClarifications = legacyClarifications
.map((entry, index) => {
const targetKey = mapLegacyClarificationTargetKey(entry.id);
if (!targetKey) {
return null;
}
return {
id: entry.id || targetKey,
label: entry.label || '待补充问题',
question: entry.question || '',
targetKey,
priority:
typeof entry.priority === 'number' ? entry.priority : index + 1,
answer: entry.answer,
} satisfies CustomWorldPendingClarification;
})
.filter((entry): entry is CustomWorldPendingClarification =>
Boolean(entry?.question),
)
.slice(0, 3);
if (nextClarifications.length > 0) {
return nextClarifications;
}
return buildPendingClarifications(normalizedIntent, readiness);
}
function buildCompatibleDraftProfile(
record: CustomWorldAgentSessionRecord,
) {
const anchorContent = buildCompatibleAnchorContent(record);
const existingDraftProfile = toRecord(record.draftProfile);
const hasFoundationContent = Boolean(
existingDraftProfile &&
(typeof existingDraftProfile.name === 'string' ||
Array.isArray(existingDraftProfile.playableNpcs) ||
Array.isArray(existingDraftProfile.landmarks) ||
Array.isArray(existingDraftProfile.factions) ||
Array.isArray(existingDraftProfile.threads) ||
Array.isArray(existingDraftProfile.chapters)),
);
if (hasFoundationContent) {
return {
...existingDraftProfile,
name:
toText(existingDraftProfile?.name) ||
toText(existingDraftProfile?.title) ||
buildDraftTitleFromEightAnchorContent(anchorContent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromEightAnchorContent(anchorContent),
};
}
return {
...(existingDraftProfile ?? {}),
title:
toText(existingDraftProfile?.title) ||
buildDraftTitleFromEightAnchorContent(anchorContent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromEightAnchorContent(anchorContent),
};
}
function buildCompatibleSuggestedActions(params: {
record: CustomWorldAgentSessionRecord;
stage: CustomWorldAgentStage;
readiness: CreatorIntentReadiness;
draftProfile: Record<string, unknown>;
}) {
if (params.record.suggestedActions.length > 0) {
return params.record.suggestedActions;
}
const actions: CustomWorldSuggestedAction[] = [
{
id: 'request_summary',
type: 'request_summary',
label:
params.stage === 'object_refining' || params.stage === 'visual_refining'
? '总结当前世界底稿'
: '总结当前设定',
},
];
const playableNpcs = Array.isArray(params.draftProfile.playableNpcs)
? params.draftProfile.playableNpcs
: [];
const storyNpcs = Array.isArray(params.draftProfile.storyNpcs)
? params.draftProfile.storyNpcs
: [];
const landmarks = Array.isArray(params.draftProfile.landmarks)
? params.draftProfile.landmarks
: [];
if (params.stage === 'foundation_review' && params.readiness.isReady) {
actions.push({
id: 'draft_foundation',
type: 'draft_foundation',
label: '整理一版世界底稿',
});
return actions;
}
if (params.stage === 'object_refining' || params.stage === 'visual_refining') {
const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]);
const firstLandmark = toRecord(landmarks[0]);
actions.push({
id: 'refine_world',
type: 'refine_focus_target',
label: '先看世界总卡',
targetId: 'world-foundation',
});
if (firstCharacter) {
actions.push({
id: `refine-character-${toText(firstCharacter.id) || 'seed'}`,
type: 'refine_focus_target',
label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`,
targetId: toText(firstCharacter.id) || null,
});
}
if (firstLandmark) {
actions.push({
id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`,
type: 'refine_focus_target',
label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`,
targetId: toText(firstLandmark.id) || null,
});
}
}
return actions;
}
function normalizeRecommendedReplies(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 3);
}
function buildCompatibleAssetCoverage(
record: CustomWorldAgentSessionRecord,
draftProfile: Record<string, unknown>,
) {
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
const existingCoverage = toRecord(record.assetCoverage);
const sceneAssets =
derivedCoverage.sceneAssets.length > 0
? derivedCoverage.sceneAssets
: Array.isArray(existingCoverage?.sceneAssets)
? existingCoverage.sceneAssets
: [];
const allSceneAssetsReady =
derivedCoverage.sceneAssets.length > 0
? derivedCoverage.allSceneAssetsReady
: typeof existingCoverage?.allSceneAssetsReady === 'boolean'
? existingCoverage.allSceneAssetsReady
: false;
return {
...derivedCoverage,
sceneAssets,
allSceneAssetsReady,
} satisfies CustomWorldAssetCoverageSummary;
}
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
const creatorIntent = buildCompatibleCreatorIntent(record);
const currentTurn = buildCompatibleCurrentTurn(record);
const anchorContent = buildCompatibleAnchorContent(record);
const progressPercent = buildCompatibleProgressPercent(record);
const lastAssistantReply = buildCompatibleLastAssistantReply(record);
const creatorIntentReadiness =
progressPercent >= 100
? {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}
: evaluateCreatorIntentReadiness(creatorIntent);
const stage =
record.stage === 'object_refining' ||
record.stage === 'visual_refining' ||
record.stage === 'long_tail_review' ||
record.stage === 'ready_to_publish' ||
record.stage === 'published'
? record.stage
: progressPercent >= 100
? ('foundation_review' as const)
: resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
});
const pendingClarifications = buildCompatiblePendingClarifications({
...record,
creatorIntent,
creatorIntentReadiness,
});
const draftProfile = buildCompatibleDraftProfile(record);
return {
...record,
currentTurn,
anchorContent,
progressPercent,
lastAssistantReply,
stage,
creatorIntent,
creatorIntentReadiness,
anchorPack:
record.anchorPack && Object.keys(record.anchorPack).length > 0
? record.anchorPack
: buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent),
draftProfile,
pendingClarifications,
suggestedActions: buildCompatibleSuggestedActions({
record,
stage,
readiness: creatorIntentReadiness,
draftProfile,
}),
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
recommendedReplies: normalizeRecommendedReplies(
(record as Record<string, unknown>).recommendedReplies,
),
} satisfies CustomWorldAgentSessionRecord;
}
function toSnapshot(
record: CustomWorldAgentSessionRecord,
): CustomWorldAgentSessionSnapshot {
return {
sessionId: record.sessionId,
currentTurn: record.currentTurn,
anchorContent: cloneRecord(record.anchorContent),
progressPercent: record.progressPercent,
lastAssistantReply: record.lastAssistantReply,
stage: record.stage,
focusCardId: record.focusCardId,
creatorIntent: cloneRecord(record.creatorIntent),
creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness),
anchorPack: cloneRecord(record.anchorPack),
lockState: cloneRecord(record.lockState),
draftProfile: cloneRecord(record.draftProfile),
messages: cloneRecord(record.messages),
draftCards: cloneRecord(record.draftCards),
pendingClarifications: cloneRecord(record.pendingClarifications),
suggestedActions: cloneRecord(record.suggestedActions),
recommendedReplies: cloneRecord(record.recommendedReplies),
qualityFindings: cloneRecord(record.qualityFindings),
assetCoverage: cloneRecord(record.assetCoverage),
updatedAt: record.updatedAt,
};
}
export class CustomWorldAgentSessionStore {
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
private async persist(record: CustomWorldAgentSessionRecord) {
await this.runtimeRepository.upsertCustomWorldSession(
record.userId,
record.sessionId,
record as unknown as LegacyCustomWorldSessionRecord,
);
return cloneRecord(record);
}
private async mutate(
userId: string,
sessionId: string,
mutateFn: (record: CustomWorldAgentSessionRecord) => void,
) {
const current = await this.get(userId, sessionId);
if (!current) {
return null;
}
const nextRecord = cloneRecord(current);
mutateFn(nextRecord);
nextRecord.updatedAt = new Date().toISOString();
return this.persist(nextRecord);
}
async create(userId: string, input: CreateSessionInput) {
const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const welcomeMessage: CustomWorldAgentMessage = {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'chat',
text: input.welcomeMessage,
createdAt: now,
relatedOperationId: null,
};
const record: CustomWorldAgentSessionRecord = {
sessionId,
userId,
seedText: input.seedText?.trim() ?? '',
currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)),
anchorContent: normalizeEightAnchorContent(
input.anchorContent ?? createEmptyEightAnchorContent(),
),
progressPercent: Math.max(
0,
Math.min(100, Math.round(input.progressPercent ?? 0)),
),
lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage,
stage: input.stage ?? 'collecting_intent',
focusCardId: null,
creatorIntent: cloneRecord(input.creatorIntent ?? {}),
creatorIntentReadiness: input.creatorIntentReadiness ?? {
isReady: false,
completedKeys: [],
missingKeys: [],
},
anchorPack: cloneRecord(input.anchorPack ?? {}),
lockState: {},
draftProfile: cloneRecord(input.draftProfile ?? {}),
messages: [welcomeMessage],
draftCards: [],
pendingClarifications: cloneRecord(input.pendingClarifications),
suggestedActions: cloneRecord(input.suggestedActions),
recommendedReplies: cloneRecord(input.recommendedReplies ?? []),
qualityFindings: [],
assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}),
operations: [],
checkpoints: [],
createdAt: now,
updatedAt: now,
};
const compatibleRecord = applyCompatibility(record);
await this.persist(compatibleRecord);
return cloneRecord(compatibleRecord);
}
async list(userId: string) {
const records =
await this.runtimeRepository.listCustomWorldSessions(userId);
return records
.filter((record) => isAgentSessionRecord(record))
.map((record) => cloneRecord(applyCompatibility(record)))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async get(userId: string, sessionId: string) {
if (!sessionId.trim()) {
return null;
}
const record = await this.runtimeRepository.getCustomWorldSession(
userId,
sessionId,
);
if (!isAgentSessionRecord(record)) {
return null;
}
return cloneRecord(applyCompatibility(record));
}
async getSnapshot(userId: string, sessionId: string) {
const record = await this.get(userId, sessionId);
return record ? toSnapshot(record) : null;
}
async appendMessage(
userId: string,
sessionId: string,
message: CustomWorldAgentMessage,
) {
return this.mutate(userId, sessionId, (record) => {
record.messages.push(cloneRecord(message));
});
}
async replaceDerivedState(
userId: string,
sessionId: string,
patch: Partial<
Pick<
CustomWorldAgentSessionRecord,
| 'currentTurn'
| 'anchorContent'
| 'progressPercent'
| 'lastAssistantReply'
| 'stage'
| 'creatorIntent'
| 'creatorIntentReadiness'
| 'anchorPack'
| 'lockState'
| 'draftProfile'
| 'pendingClarifications'
| 'suggestedActions'
| 'recommendedReplies'
| 'draftCards'
| 'qualityFindings'
| 'focusCardId'
| 'assetCoverage'
>
>,
) {
return this.mutate(userId, sessionId, (record) => {
if (typeof patch.currentTurn === 'number') {
record.currentTurn = Math.max(0, Math.round(patch.currentTurn));
}
if (patch.anchorContent !== undefined) {
record.anchorContent = normalizeEightAnchorContent(patch.anchorContent);
}
if (typeof patch.progressPercent === 'number') {
record.progressPercent = Math.max(
0,
Math.min(100, Math.round(patch.progressPercent)),
);
}
if (patch.lastAssistantReply !== undefined) {
record.lastAssistantReply = patch.lastAssistantReply;
}
if (patch.stage) {
record.stage = patch.stage;
}
if (patch.focusCardId !== undefined) {
record.focusCardId = patch.focusCardId;
}
if (patch.creatorIntent !== undefined) {
record.creatorIntent = cloneRecord(patch.creatorIntent);
}
if (patch.creatorIntentReadiness !== undefined) {
record.creatorIntentReadiness = cloneRecord(
patch.creatorIntentReadiness,
);
}
if (patch.anchorPack !== undefined) {
record.anchorPack = cloneRecord(patch.anchorPack);
}
if (patch.lockState !== undefined) {
record.lockState = cloneRecord(patch.lockState);
}
if (patch.draftProfile !== undefined) {
record.draftProfile = cloneRecord(patch.draftProfile);
}
if (patch.pendingClarifications !== undefined) {
record.pendingClarifications = cloneRecord(patch.pendingClarifications);
}
if (patch.suggestedActions !== undefined) {
record.suggestedActions = cloneRecord(patch.suggestedActions);
}
if (patch.recommendedReplies !== undefined) {
record.recommendedReplies = cloneRecord(patch.recommendedReplies);
}
if (patch.draftCards !== undefined) {
record.draftCards = cloneRecord(patch.draftCards);
}
if (patch.qualityFindings !== undefined) {
record.qualityFindings = cloneRecord(patch.qualityFindings);
}
if (patch.assetCoverage !== undefined) {
record.assetCoverage = cloneRecord(patch.assetCoverage);
}
});
}
async createOperation(
userId: string,
sessionId: string,
operation: CustomWorldAgentOperationRecord,
) {
return this.mutate(userId, sessionId, (record) => {
record.operations.push(cloneRecord(operation));
});
}
async getOperation(userId: string, sessionId: string, operationId: string) {
const record = await this.get(userId, sessionId);
if (!record) {
return null;
}
const operation = record.operations.find(
(item) => item.operationId === operationId,
);
return operation ? cloneRecord(operation) : null;
}
async updateOperation(
userId: string,
sessionId: string,
operationId: string,
patch: Partial<CustomWorldAgentOperationRecord>,
) {
return this.mutate(userId, sessionId, (record) => {
const operation = record.operations.find(
(item) => item.operationId === operationId,
);
if (!operation) {
return;
}
if (patch.type) {
operation.type = patch.type;
}
if (patch.status) {
operation.status = patch.status;
}
if (patch.phaseLabel) {
operation.phaseLabel = patch.phaseLabel;
}
if (patch.phaseDetail) {
operation.phaseDetail = patch.phaseDetail;
}
if (typeof patch.progress === 'number') {
operation.progress = patch.progress;
}
if (patch.error !== undefined) {
operation.error = patch.error;
}
});
}
async appendCheckpoint(
userId: string,
sessionId: string,
input: {
checkpointId?: string;
label: string;
},
) {
return this.mutate(userId, sessionId, (record) => {
record.checkpoints.push({
checkpointId:
input.checkpointId ||
`checkpoint-${crypto.randomBytes(8).toString('hex')}`,
createdAt: new Date().toISOString(),
label: input.label,
});
});
}
async listDraftCards(userId: string, sessionId: string) {
const record = await this.get(userId, sessionId);
return record ? cloneRecord(record.draftCards) : null;
}
}